@wibly/sdk 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,6 +1,23 @@
1
1
  # `@wibly/sdk` — Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.1.1 — 2026-05-30
4
+
5
+ ### Fixed
6
+
7
+ - `SdkCallKind` and `SdkQualityTier` are now type-only re-exports of
8
+ `@platform/manifest`'s `CallKind` and `QualityTier` instead of
9
+ hand-duplicated string-literal unions that had drifted from the
10
+ manifest's `CallKindSchema` / `QualityTierSchema`. The previous
11
+ literals included `judge_submissions` and `host_persona_response`
12
+ (neither valid manifest call kinds) and `preview` / `high` quality
13
+ tiers (the manifest set is `fast | standard | premium | creative`);
14
+ any caller using those literals would have failed Gateway request
15
+ validation at the first network hop. Type-only `import` keeps the
16
+ SDK's runtime weight unchanged. The `inference.judge` convenience
17
+ wrapper now sends `callKind: 'host_judge'` (was the invalid
18
+ `judge_submissions`).
19
+
20
+ ## 0.1.0 — 2026-05-18
4
21
 
5
22
  Initial Studio SDK surface (chunk B7).
6
23
 
@@ -20,7 +37,7 @@ Initial Studio SDK surface (chunk B7).
20
37
  - `events.{onEvent, onAnyEvent}` and `lifecycle.{onSessionOpened,
21
38
  onSessionClosed, onPhaseEntered, onPhaseExited, onHostReclaimed}`.
22
39
  - `requestConsent(payload)` to forward an accepted consent decision
23
- to the User Portal endpoint (chunk B17a; returns
40
+ to the User Portal endpoint (chunk B19a; returns
24
41
  `consent_persistence_not_wired` until then).
25
42
  - `close()` to tear down the WebSocket + dispose listeners.
26
43
  - React bindings at `@wibly/sdk/react`: `SessionProvider`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wibly/sdk",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Wibly @wibly/sdk",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -18,9 +18,9 @@
18
18
  "access": "public"
19
19
  },
20
20
  "dependencies": {
21
- "@wibly/internal-manifest": "0.1.0",
22
- "@wibly/internal-protocol": "0.1.0",
23
- "@wibly/internal-shared": "0.1.0",
21
+ "@wibly/internal-manifest": "0.1.1",
22
+ "@wibly/internal-protocol": "0.1.1",
23
+ "@wibly/internal-shared": "0.1.1",
24
24
  "zod": "^3.25.76"
25
25
  },
26
26
  "peerDependencies": {
package/src/client.ts CHANGED
@@ -27,7 +27,7 @@
27
27
  * **Consent callback.** When the Runtime emits a `consent_required`
28
28
  * event, the SDK calls the registered callback and (on
29
29
  * `accepted`) calls `session.requestConsent(...)` to forward the
30
- * grant to the User Portal endpoint (chunk B17a).
30
+ * grant to the User Portal endpoint (chunk B19a).
31
31
  */
32
32
 
33
33
  import { ok, type Result } from '@wibly/internal-shared';
@@ -68,6 +68,7 @@ import {
68
68
  INFERENCE_EVENT_PREFIX,
69
69
  } from './inference.js';
70
70
  import {
71
+ estimateAudioDurationMs,
71
72
  type SessionVoice,
72
73
  type SpeakSuccess,
73
74
  VOICE_EVENT_PREFIX,
@@ -94,6 +95,17 @@ export type SessionConfig = {
94
95
  readonly wsUrl: string;
95
96
  readonly sessionId: SessionId;
96
97
  readonly auth?: string;
98
+ /**
99
+ * Marks the connected Session as a preview run (Chunk B16).
100
+ *
101
+ * The Portal/Admin that opens the Session passes this flag when
102
+ * the Runtime's `GET /sessions/:id` returned
103
+ * `visibility === 'preview'` (or the redemption response set
104
+ * `isPreview: true`). Shells use the value to render a "Preview"
105
+ * watermark on host/player chrome and to suppress production-only
106
+ * affordances. Defaults to `false`.
107
+ */
108
+ readonly isPreview?: boolean;
97
109
  readonly onConsentRequired?: ConsentRequiredCallback;
98
110
  /** Test seam — pass a fake WebSocket constructor. */
99
111
  readonly factory?: WebSocketFactory;
@@ -107,10 +119,12 @@ export type SessionConfig = {
107
119
  * Submit confirmation timeout. Default 30s. See `submit.ts`.
108
120
  */
109
121
  readonly submitTimeoutMs?: number;
122
+ /** Voice speak confirmation timeout. Default 30s. */
123
+ readonly voiceSpeakTimeoutMs?: number;
110
124
  /**
111
125
  * Persistence endpoint for accepted consents. When the player
112
126
  * accepts a consent prompt, the SDK invokes `persistConsent` to
113
- * write the grant. Chunk B17a wires the real implementation
127
+ * write the grant. Chunk B19a wires the real implementation
114
128
  * against the User Portal; MVP leaves it `undefined` and
115
129
  * `requestConsent` returns `consent_persistence_not_wired`.
116
130
  */
@@ -129,10 +143,29 @@ export type SessionState = {
129
143
  readonly appliedSeq: number;
130
144
  /** Current phase id from the most recent snapshot. */
131
145
  readonly phaseId: string | null;
146
+ /** Whether the Runtime has broadcast a `lifecycle.paused` frame. */
147
+ readonly sessionPaused: boolean;
148
+ readonly pauseReason: 'host' | 'system' | null;
149
+ /** Host-visible orphan-seat recovery code, when active. */
150
+ readonly recoveryCode: string | null;
151
+ readonly recoveryCodeHint: string | null;
152
+ /**
153
+ * Whether the connected Session is a preview run (Chunk B16).
154
+ * Populated from `SessionConfig.isPreview` and updated when a
155
+ * lifecycle frame with `transition === 'session.metadata'`
156
+ * carries `{ isPreview: boolean }` in its detail.
157
+ */
158
+ readonly isPreview: boolean;
132
159
  };
133
160
 
134
161
  export type Session = {
135
162
  readonly sessionId: SessionId;
163
+ /**
164
+ * Whether the Session is a preview run (Chunk B16). Mirrors
165
+ * {@link SessionState.isPreview} so callers that don't need the
166
+ * full state snapshot can read the flag directly.
167
+ */
168
+ readonly isPreview: boolean;
136
169
  /** Current snapshot — calling getter triggers no work. */
137
170
  readonly getState: () => SessionState;
138
171
  /** Reactive subscription. The callback fires on store changes. */
@@ -160,7 +193,7 @@ export type Session = {
160
193
  readonly store: SessionStore;
161
194
  /**
162
195
  * Forward an accepted consent decision to the persistence path.
163
- * Returns `consent_persistence_not_wired` until chunk B17a binds
196
+ * Returns `consent_persistence_not_wired` until chunk B19a binds
164
197
  * the User Portal endpoint.
165
198
  */
166
199
  readonly requestConsent: (
@@ -177,20 +210,31 @@ type PendingRecord = {
177
210
  readonly timer?: () => void;
178
211
  };
179
212
 
213
+ type PendingVoiceRecord = {
214
+ readonly resolve: (r: Result<unknown, SdkError>) => void;
215
+ readonly caption: string | null | undefined;
216
+ readonly cancelTimeout: () => void;
217
+ };
218
+
219
+ const DEFAULT_VOICE_SPEAK_TIMEOUT_MS = 30_000;
220
+
180
221
  export const createSession = (config: SessionConfig): Session => {
181
222
  const store = createSessionStore();
182
223
  const bus = createEventBus();
183
224
  const time = createServerTimeSource({ now: config.now });
184
225
  const lifecycle = createLifecycleBindings(bus);
226
+ let isPreviewFlag: boolean = config.isPreview ?? false;
185
227
  const pendingSubmits = new Map<MessageId, PendingRecord>();
186
228
  const pendingInference = new Map<
187
229
  MessageId,
188
230
  (r: Result<unknown, SdkError>) => void
189
231
  >();
190
- const pendingVoice = new Map<
191
- MessageId,
192
- (r: Result<unknown, SdkError>) => void
193
- >();
232
+ const pendingVoice = new Map<MessageId, PendingVoiceRecord>();
233
+ const voiceSpeakTimeoutMs =
234
+ config.voiceSpeakTimeoutMs ?? DEFAULT_VOICE_SPEAK_TIMEOUT_MS;
235
+ const scheduleTimer =
236
+ config.schedule ??
237
+ ((cb: () => void, ms: number) => scheduleTimeout(ms, cb));
194
238
 
195
239
  const handleStateDiff = (payload: StateDiffPayload): void => {
196
240
  const action = store.applyDiff(
@@ -209,6 +253,18 @@ export const createSession = (config: SessionConfig): Session => {
209
253
 
210
254
  const handleEvent = (payload: EventPayload): void => {
211
255
  bus.dispatchEvent(payload);
256
+ if (payload.eventType === 'recovery_code.issued') {
257
+ const data = payload.data as {
258
+ code?: unknown;
259
+ seat?: unknown;
260
+ } | null;
261
+ if (data && typeof data.code === 'string') {
262
+ store.setRecoveryCode(
263
+ data.code,
264
+ typeof data.seat === 'string' ? data.seat : null,
265
+ );
266
+ }
267
+ }
212
268
  if (payload.eventType.startsWith(INFERENCE_EVENT_PREFIX)) {
213
269
  maybeResolveInference(payload);
214
270
  } else if (payload.eventType.startsWith(VOICE_EVENT_PREFIX)) {
@@ -219,6 +275,58 @@ export const createSession = (config: SessionConfig): Session => {
219
275
  };
220
276
 
221
277
  const handleLifecycle = (payload: LifecyclePayload): void => {
278
+ if (
279
+ payload.transition === 'session.metadata' &&
280
+ payload.detail !== null &&
281
+ typeof payload.detail === 'object' &&
282
+ 'isPreview' in payload.detail &&
283
+ typeof (payload.detail as { isPreview: unknown }).isPreview === 'boolean'
284
+ ) {
285
+ isPreviewFlag = (payload.detail as { isPreview: boolean }).isPreview;
286
+ cachedSessionState = null;
287
+ }
288
+ if (
289
+ payload.transition === 'session.closed' ||
290
+ payload.transition === 'session.aborted' ||
291
+ payload.transition === 'aborted'
292
+ ) {
293
+ transport.close(1000, 'session ended');
294
+ }
295
+ if (payload.transition === 'phase.changed') {
296
+ const detail = payload.detail;
297
+ if (detail !== null && typeof detail === 'object') {
298
+ const toPhaseId = (detail as { toPhaseId?: unknown }).toPhaseId;
299
+ if (typeof toPhaseId === 'string') {
300
+ store.setPhaseId(toPhaseId);
301
+ }
302
+ }
303
+ }
304
+ if (payload.transition === 'paused') {
305
+ const detail = payload.detail;
306
+ const via =
307
+ detail !== null &&
308
+ typeof detail === 'object' &&
309
+ (detail as { via?: unknown }).via === 'host_control'
310
+ ? 'host'
311
+ : 'system';
312
+ store.setSessionPaused(true, via);
313
+ }
314
+ if (payload.transition === 'continued') {
315
+ store.setSessionPaused(false, null);
316
+ }
317
+ if (payload.transition === 'seat.recovery_code_issued') {
318
+ const detail = payload.detail;
319
+ if (detail !== null && typeof detail === 'object') {
320
+ const code = (detail as { code?: unknown }).code;
321
+ const seat = (detail as { seat?: unknown }).seat;
322
+ if (typeof code === 'string') {
323
+ store.setRecoveryCode(
324
+ code,
325
+ typeof seat === 'string' ? seat : null,
326
+ );
327
+ }
328
+ }
329
+ }
222
330
  bus.dispatchLifecycle(payload);
223
331
  };
224
332
 
@@ -261,9 +369,10 @@ export const createSession = (config: SessionConfig): Session => {
261
369
  }
262
370
  const voice = pendingVoice.get(causeId);
263
371
  if (voice !== undefined) {
372
+ voice.cancelTimeout();
264
373
  pendingVoice.delete(causeId);
265
374
  transport.confirmSend(causeId);
266
- voice({
375
+ voice.resolve({
267
376
  ok: false,
268
377
  error: {
269
378
  kind: 'submit_rejected',
@@ -366,15 +475,94 @@ export const createSession = (config: SessionConfig): Session => {
366
475
  cb(ok(payload.data));
367
476
  };
368
477
 
478
+ const dispatchVoiceAudio = (
479
+ causeId: MessageId,
480
+ data: {
481
+ audioBase64: string;
482
+ contentType?: unknown;
483
+ durationMs?: unknown;
484
+ },
485
+ caption: string | null | undefined,
486
+ ): void => {
487
+ bus.dispatchEvent({
488
+ sessionId: config.sessionId,
489
+ eventType: 'voice.audio',
490
+ data: {
491
+ id: causeId,
492
+ audioBase64: data.audioBase64,
493
+ contentType:
494
+ typeof data.contentType === 'string'
495
+ ? data.contentType
496
+ : 'audio/mpeg',
497
+ durationMs:
498
+ typeof data.durationMs === 'number' && data.durationMs > 0
499
+ ? data.durationMs
500
+ : estimateAudioDurationMs(data.audioBase64),
501
+ caption: caption === undefined ? null : caption,
502
+ },
503
+ });
504
+ };
505
+
369
506
  const maybeResolveVoice = (payload: EventPayload): void => {
370
- const data = payload.data as { causeMessageId?: string } | null;
507
+ const data = payload.data as {
508
+ causeMessageId?: string;
509
+ audioBase64?: string;
510
+ contentType?: string;
511
+ durationMs?: number;
512
+ kind?: string;
513
+ } | null;
371
514
  if (!data || typeof data.causeMessageId !== 'string') return;
372
515
  const causeId = data.causeMessageId as unknown as MessageId;
373
- const cb = pendingVoice.get(causeId);
374
- if (cb === undefined) return;
516
+ const pending = pendingVoice.get(causeId);
517
+ const caption = pending?.caption;
518
+ const audioBase64 = data.audioBase64;
519
+
520
+ if (
521
+ payload.eventType === 'voice.speak.result' &&
522
+ typeof audioBase64 === 'string'
523
+ ) {
524
+ dispatchVoiceAudio(
525
+ causeId,
526
+ {
527
+ audioBase64,
528
+ contentType: data.contentType,
529
+ durationMs: data.durationMs,
530
+ },
531
+ caption ?? null,
532
+ );
533
+ }
534
+
535
+ if (pending === undefined) return;
536
+ pending.cancelTimeout();
375
537
  pendingVoice.delete(causeId);
376
538
  transport.confirmSend(causeId);
377
- cb(ok(payload.data));
539
+
540
+ if (
541
+ payload.eventType === 'voice.speak.result' &&
542
+ typeof audioBase64 === 'string'
543
+ ) {
544
+ pending.resolve(
545
+ ok({
546
+ audioBase64,
547
+ contentType:
548
+ typeof data.contentType === 'string'
549
+ ? data.contentType
550
+ : 'audio/mpeg',
551
+ durationMs:
552
+ typeof data.durationMs === 'number' && data.durationMs > 0
553
+ ? data.durationMs
554
+ : estimateAudioDurationMs(audioBase64),
555
+ } satisfies SpeakSuccess),
556
+ );
557
+ return;
558
+ }
559
+
560
+ if (payload.eventType === 'voice.speak.error') {
561
+ pending.resolve({
562
+ ok: false,
563
+ error: { kind: 'runtime_not_wired' },
564
+ });
565
+ }
378
566
  };
379
567
 
380
568
  const maybeHandleConsent = (payload: EventPayload): void => {
@@ -409,18 +597,40 @@ export const createSession = (config: SessionConfig): Session => {
409
597
  })();
410
598
  };
411
599
 
600
+ // `useSyncExternalStore` compares snapshots with `Object.is`. A fresh
601
+ // object on every `getState()` call makes React think the store
602
+ // changed on every read → infinite re-renders in shell hooks.
603
+ let cachedStoreSnapshot: ReturnType<SessionStore['getSnapshot']> | null =
604
+ null;
605
+ let cachedSessionState: SessionState | null = null;
606
+
607
+ const readSessionState = (): SessionState => {
608
+ const snap = store.getSnapshot();
609
+ if (cachedStoreSnapshot === snap && cachedSessionState !== null) {
610
+ return cachedSessionState;
611
+ }
612
+ cachedStoreSnapshot = snap;
613
+ cachedSessionState = {
614
+ connectionState: snap.connectionState,
615
+ state: snap.state,
616
+ projectedState: store.getProjectedState(),
617
+ appliedSeq: snap.appliedSeq,
618
+ phaseId: snap.phaseId,
619
+ sessionPaused: snap.sessionPaused,
620
+ pauseReason: snap.pauseReason,
621
+ recoveryCode: snap.recoveryCode,
622
+ recoveryCodeHint: snap.recoveryCodeHint,
623
+ isPreview: isPreviewFlag,
624
+ };
625
+ return cachedSessionState;
626
+ };
627
+
412
628
  const session: Session = {
413
629
  sessionId: config.sessionId,
414
- getState: () => {
415
- const snap = store.getSnapshot();
416
- return {
417
- connectionState: snap.connectionState,
418
- state: snap.state,
419
- projectedState: store.getProjectedState(),
420
- appliedSeq: snap.appliedSeq,
421
- phaseId: snap.phaseId,
422
- };
630
+ get isPreview(): boolean {
631
+ return isPreviewFlag;
423
632
  },
633
+ getState: readSessionState,
424
634
  subscribe: (listener) => store.subscribe(listener),
425
635
  submit: (args) => {
426
636
  const payload = buildSubmitPayload(config.sessionId, args);
@@ -515,7 +725,13 @@ export const createSession = (config: SessionConfig): Session => {
515
725
  transport,
516
726
  pendingInference,
517
727
  ),
518
- voice: buildVoiceVerbs(config.sessionId, transport, pendingVoice),
728
+ voice: buildVoiceVerbs(
729
+ config.sessionId,
730
+ transport,
731
+ pendingVoice,
732
+ scheduleTimer,
733
+ voiceSpeakTimeoutMs,
734
+ ),
519
735
  events: { onEvent: bus.onEvent, onAnyEvent: bus.onAnyEvent },
520
736
  lifecycle,
521
737
  time: { serverNow: time.serverNow, recordEvent: time.recordEvent },
@@ -537,8 +753,9 @@ export const createSession = (config: SessionConfig): Session => {
537
753
  cb({ ok: false, error: { kind: 'not_connected' } });
538
754
  }
539
755
  pendingInference.clear();
540
- for (const [, cb] of pendingVoice) {
541
- cb({ ok: false, error: { kind: 'not_connected' } });
756
+ for (const [, pending] of pendingVoice) {
757
+ pending.cancelTimeout();
758
+ pending.resolve({ ok: false, error: { kind: 'not_connected' } });
542
759
  }
543
760
  pendingVoice.clear();
544
761
  bus.dispose();
@@ -607,8 +824,15 @@ const buildInferenceVerbs = (
607
824
  };
608
825
  return {
609
826
  call,
827
+ // Convenience wrappers map to the most-common manifest `CallKind`s
828
+ // (per `@platform/manifest`'s `CallKindSchema` and
829
+ // `docs/conventions/prompt-composition.md`). Bundles that need a
830
+ // less-common kind (`host_resolve`, `host_recap`, `judge_funniness`,
831
+ // `compose_clue`, `narrate_event`) call `inference.call({ callKind,
832
+ // ... })` directly — the wrappers are a comfort for the dominant
833
+ // open-phase + judge paths, not an exhaustive cover.
610
834
  host: (input) => call({ ...input, callKind: 'host_open_phase' }),
611
- judge: (input) => call({ ...input, callKind: 'judge_submissions' }),
835
+ judge: (input) => call({ ...input, callKind: 'host_judge' }),
612
836
  classify: (input) => call({ ...input, callKind: 'classify' }),
613
837
  };
614
838
  };
@@ -616,13 +840,17 @@ const buildInferenceVerbs = (
616
840
  const buildVoiceVerbs = (
617
841
  sessionId: SessionId,
618
842
  transport: Transport,
619
- pending: Map<MessageId, (r: Result<unknown, SdkError>) => void>,
843
+ pending: Map<MessageId, PendingVoiceRecord>,
844
+ schedule: (cb: () => void, ms: number) => () => void,
845
+ timeoutMs: number,
620
846
  ): SessionVoice => ({
621
847
  speak: async (input) => {
622
848
  let resolveFn!: (r: Result<unknown, SdkError>) => void;
623
849
  const promise = new Promise<Result<unknown, SdkError>>((res) => {
624
850
  resolveFn = res;
625
851
  });
852
+ const caption =
853
+ input.caption === null ? null : (input.caption ?? input.text);
626
854
  const sent = transport.send('emit', {
627
855
  sessionId,
628
856
  eventType: `${VOICE_EVENT_PREFIX}speak`,
@@ -630,17 +858,21 @@ const buildVoiceVerbs = (
630
858
  personaId: input.personaId,
631
859
  text: input.text,
632
860
  options: input.options ?? {},
633
- caption: input.caption === null ? null : (input.caption ?? input.text),
861
+ caption,
634
862
  },
635
863
  });
636
- pending.set(sent.id, resolveFn);
637
- setTimeout(() => {
638
- const cb = pending.get(sent.id);
639
- if (cb === undefined) return;
864
+ const cancelTimeout = schedule(() => {
865
+ const record = pending.get(sent.id);
866
+ if (record === undefined) return;
640
867
  pending.delete(sent.id);
641
868
  transport.confirmSend(sent.id);
642
- cb({ ok: false, error: { kind: 'runtime_not_wired' } });
643
- }, 0);
869
+ record.resolve({ ok: false, error: { kind: 'runtime_not_wired' } });
870
+ }, timeoutMs);
871
+ pending.set(sent.id, {
872
+ resolve: resolveFn,
873
+ caption,
874
+ cancelTimeout,
875
+ });
644
876
  const result = await promise;
645
877
  return result as Result<SpeakSuccess, SdkError>;
646
878
  },
package/src/consent.ts CHANGED
@@ -18,12 +18,12 @@
18
18
  *
19
19
  * **Persistence path.** When the player accepts, the shell calls
20
20
  * `session.requestConsent(...)` which forwards to the User Portal's
21
- * consent endpoint (chunk B17a). Until B17a lands the endpoint, the
21
+ * consent endpoint (chunk B19a). Until B19a lands the endpoint, the
22
22
  * verb returns `Result.err({ kind: 'consent_persistence_not_wired' })`
23
23
  * — the prompt callback still fires; only the grant-write is deferred.
24
24
  *
25
25
  * **Trap discipline** (chunk B7): the consent management surface lives
26
- * in `apps/portal` (B17a), not here. The SDK exposes the mid-Session
26
+ * in `apps/portal` (B19a), not here. The SDK exposes the mid-Session
27
27
  * prompt verbs only — `requestConsent` (grant a fresh consent),
28
28
  * surfaces via `useEvent('consent_required')`. Revoke / list lives on
29
29
  * the User Portal Settings page.
@@ -34,6 +34,21 @@
34
34
  * change here is a PROTOCOL_VERSION bump.
35
35
  */
36
36
 
37
+ /**
38
+ * Per-Persona override copy for the mid-Session consent dialog
39
+ * (chunk B15). Either sub-field may be omitted to fall back to the
40
+ * platform default copy in the Player Web Shell's
41
+ * `apps-shells/player-web/src/lib/player-meta.ts` (`CONSENT_PROMPT_COPY`).
42
+ *
43
+ * Sourced from `personas.consent_copy` (chunk B15 schema addition);
44
+ * the Runtime's persona-forwarder enriches the wire `consent_required`
45
+ * event payload with this field when the Persona has it set.
46
+ */
47
+ export type ConsentCopyOverride = {
48
+ readonly title?: string;
49
+ readonly description?: string;
50
+ };
51
+
37
52
  /**
38
53
  * Payload the SDK surfaces on a `consent_required` callback. Mirrors
39
54
  * the shape the Persona Service emits (chunk B3) when `readMemoryEntry`
@@ -44,11 +59,18 @@
44
59
  * prompt copy.
45
60
  * - `scope` — `session | group | player` per the
46
61
  * `player_consent_scope` enum.
62
+ * - `consentCopy` — chunk B15 per-Persona override copy.
63
+ * Optional; falls back to the
64
+ * platform-default copy when absent. The
65
+ * field itself is optional (back-compatible
66
+ * addition); a missing-or-undefined value
67
+ * behaves the same way.
47
68
  */
48
69
  export type ConsentRequiredPayload = {
49
70
  readonly personaId: string;
50
71
  readonly personaDisplayName: string;
51
72
  readonly scope: 'session' | 'group' | 'player';
73
+ readonly consentCopy?: ConsentCopyOverride;
52
74
  };
53
75
 
54
76
  /**
@@ -62,7 +84,7 @@ export type ConsentDecision = 'accepted' | 'declined';
62
84
  /**
63
85
  * The SDK callback signature the Player shell binds against. The
64
86
  * component resolves the returned promise; the SDK forwards the
65
- * decision to the persistence path (B17a) on accept.
87
+ * decision to the persistence path (B19a) on accept.
66
88
  */
67
89
  export type ConsentRequiredCallback = (
68
90
  payload: ConsentRequiredPayload,
package/src/control.ts CHANGED
@@ -13,7 +13,7 @@
13
13
  * - `host.pause` — request a pause; the server emits a
14
14
  * `lifecycle` `session.paused`.
15
15
  * - `host.resume` — resume from pause.
16
- * - `host.advance_phase` — request the next phase. The Runtime
16
+ * - `host.advancePhase` — request the next phase. The Runtime
17
17
  * may reject if no transition matches.
18
18
  * - `host.reclaim` — reclaim the host slot from a hung
19
19
  * host (the player who fires this
@@ -25,7 +25,7 @@ import type { EmitPayload } from '@wibly/internal-protocol';
25
25
  export const HOST_EVENT_TYPES = {
26
26
  pause: 'host.pause',
27
27
  resume: 'host.resume',
28
- advancePhase: 'host.advance_phase',
28
+ advancePhase: 'host.advancePhase',
29
29
  reclaim: 'host.reclaim',
30
30
  } as const;
31
31
 
package/src/errors.ts CHANGED
@@ -22,8 +22,8 @@
22
22
  * player shell renders the dialog.
23
23
  * - `consent_persistence_not_wired` — the SDK forwards a granted
24
24
  * consent to the User Portal endpoint
25
- * (chunk B17a); MVP returns this until
26
- * B17a ships the route. The mid-Session
25
+ * (chunk B19a); MVP returns this until
26
+ * B19a ships the route. The mid-Session
27
27
  * callback still fires so the shell
28
28
  * renders the prompt; this error is
29
29
  * returned from `session.requestConsent`
@@ -79,7 +79,7 @@ export const formatSdkError = (e: SdkError): string => {
79
79
  case 'consent_required':
80
80
  return 'sdk: consent required';
81
81
  case 'consent_persistence_not_wired':
82
- return 'sdk: consent persistence endpoint is not yet wired (chunk B17a)';
82
+ return 'sdk: consent persistence endpoint is not yet wired (chunk B19a)';
83
83
  case 'runtime_not_wired':
84
84
  return 'sdk: runtime endpoint is not yet wired (chunk B8a)';
85
85
  case 'timeout':
package/src/index.ts CHANGED
@@ -36,6 +36,7 @@ export {
36
36
 
37
37
  export {
38
38
  CONSENT_REQUIRED_EVENT_TYPE,
39
+ type ConsentCopyOverride,
39
40
  type ConsentDecision,
40
41
  type ConsentRequiredCallback,
41
42
  type ConsentRequiredEventType,
package/src/inference.ts CHANGED
@@ -36,34 +36,42 @@ import type { z, ZodTypeAny } from 'zod';
36
36
 
37
37
  import { zodToJsonSchema, type JsonSchema } from '@wibly/internal-shared';
38
38
  import type { Result } from '@wibly/internal-shared';
39
+ import type { CallKind, QualityTier } from '@wibly/internal-manifest';
39
40
 
40
41
  import type { SdkError } from './errors.js';
41
42
 
42
43
  /**
43
- * Quality tier surfaced on the SDK boundary. Mirrors
44
- * `QualityTierSchema` from `@wibly/internal-manifest` but kept as a string
45
- * literal here so the SDK doesn't pull in the manifest's full Zod
46
- * runtime (the manifest schemas are heavy; the SDK only needs the
47
- * literal set).
44
+ * Quality tier surfaced on the SDK boundary. Type-only re-export of
45
+ * `@platform/manifest`'s `QualityTier` so the manifest stays the
46
+ * single source of truth for the on-the-wire enum. The `import type`
47
+ * is erased at compile time, so the SDK still does not pull in the
48
+ * manifest's Zod runtime — only the type literals.
49
+ *
50
+ * The B7 close note ("kept as a string literal here so the SDK
51
+ * doesn't pull in the manifest's full Zod runtime") was right about
52
+ * the runtime concern but wrong to duplicate the literals — the
53
+ * duplication had already drifted from the manifest's enum at chunk
54
+ * close. Type-only re-export gives the same runtime weight (zero)
55
+ * with the contract honoured at compile time.
48
56
  */
49
- export type SdkQualityTier = 'preview' | 'standard' | 'high';
57
+ export type SdkQualityTier = QualityTier;
50
58
 
51
59
  /**
52
- * Recognised call kinds. Matches `CallKindSchema`'s enum; duplicated
53
- * here to keep the SDK type-only against the manifest package.
60
+ * Recognised call kinds. Type-only re-export of `@platform/manifest`'s
61
+ * `CallKind`; the manifest's `CallKindSchema` (enumerating
62
+ * `host_open_phase`, `host_judge`, `host_resolve`, `host_recap`,
63
+ * `judge_funniness`, `narrate_event`, `classify`, `compose_clue`) is
64
+ * the canonical wire-side set. New `callKind`s land in
65
+ * `@platform/manifest` and `docs/conventions/prompt-composition.md`
66
+ * in the same commit per chunk B4's convention; the SDK's enum
67
+ * follows automatically because it's a type alias.
54
68
  *
55
- * `host_open_phase` / `narrate_event` / `judge_submissions` /
56
- * `host_persona_response` / `classify` is the chunk-B2 set; chunk
57
- * B12 extends it. The SDK accepts any string at runtime — the
58
- * `CallKind` union exists as an authoring aid in TypeScript.
69
+ * The trailing `(string & {})` widening preserves the chunk-B7
70
+ * authoring-aid behaviour ("the SDK accepts any string at runtime")
71
+ * while keeping the literal set as IntelliSense-discoverable
72
+ * autocompletes for in-tree game bundles.
59
73
  */
60
- export type SdkCallKind =
61
- | 'host_open_phase'
62
- | 'narrate_event'
63
- | 'judge_submissions'
64
- | 'host_persona_response'
65
- | 'classify'
66
- | (string & {});
74
+ export type SdkCallKind = CallKind | (string & {});
67
75
 
68
76
  export type InferenceCallInput<TOutput extends ZodTypeAny> = {
69
77
  readonly callKind: SdkCallKind;
package/src/lifecycle.ts CHANGED
@@ -47,7 +47,16 @@ const PHASE_EXITED_PREFIX = 'phase.exited';
47
47
 
48
48
  export const createLifecycleBindings = (bus: EventBus): LifecycleBindings => ({
49
49
  onSessionOpened: (handler) => bus.onLifecycle('session.opened', handler),
50
- onSessionClosed: (handler) => bus.onLifecycle('session.closed', handler),
50
+ onSessionClosed: (handler) => {
51
+ const offClosed = bus.onLifecycle('session.closed', handler);
52
+ const offAborted = bus.onLifecycle('session.aborted', handler);
53
+ const offLegacy = bus.onLifecycle('aborted', handler);
54
+ return () => {
55
+ offClosed();
56
+ offAborted();
57
+ offLegacy();
58
+ };
59
+ },
51
60
  onPhaseEntered: (handler) => {
52
61
  return bus.onAnyLifecycle((payload) => {
53
62
  if (
@@ -55,6 +64,18 @@ export const createLifecycleBindings = (bus: EventBus): LifecycleBindings => ({
55
64
  payload.transition.startsWith(`${PHASE_ENTERED_PREFIX}:`)
56
65
  ) {
57
66
  handler(payload);
67
+ return;
68
+ }
69
+ if (payload.transition === 'phase.changed') {
70
+ const detail = payload.detail;
71
+ if (detail === null || typeof detail !== 'object') return;
72
+ const toPhaseId = (detail as { toPhaseId?: unknown }).toPhaseId;
73
+ if (typeof toPhaseId !== 'string') return;
74
+ handler({
75
+ ...payload,
76
+ transition: `${PHASE_ENTERED_PREFIX}:${toPhaseId}`,
77
+ detail: { ...detail, phaseId: toPhaseId },
78
+ });
58
79
  }
59
80
  });
60
81
  },
package/src/react.ts CHANGED
@@ -26,6 +26,7 @@ import {
26
26
  useContext,
27
27
  useEffect,
28
28
  useMemo,
29
+ useRef,
29
30
  useState,
30
31
  useSyncExternalStore,
31
32
  type ReactNode,
@@ -34,7 +35,8 @@ import {
34
35
  import type { EventPayload, LifecyclePayload } from '@wibly/internal-protocol';
35
36
 
36
37
  import type { ConsentDecision, ConsentRequiredPayload } from './consent.js';
37
- import type { Session, SessionState } from './client.js';
38
+ import type { Session, SessionConfig, SessionState } from './client.js';
39
+ import { createSession } from './client.js';
38
40
 
39
41
  const SessionContext = createContext<Session | null>(null);
40
42
 
@@ -55,6 +57,35 @@ export const SessionProvider = (props: SessionProviderProps): ReactNode =>
55
57
  props.children,
56
58
  );
57
59
 
60
+ const clientSessionKey = (config: SessionConfig): string =>
61
+ `${config.sessionId}\0${config.wsUrl}\0${config.auth ?? ''}`;
62
+
63
+ /**
64
+ * Create a {@link Session} only in the browser after mount.
65
+ *
66
+ * Shell surfaces must not call `createSession` during SSR — Node can
67
+ * open a WebSocket that is orphaned on hydration, and React Strict
68
+ * Mode's mount/unmount/remount cycle permanently closes a session
69
+ * created in `useMemo`. Returns `null` until the client effect runs.
70
+ */
71
+ export const useClientSession = (config: SessionConfig): Session | null => {
72
+ const configRef = useRef(config);
73
+ configRef.current = config;
74
+ const [session, setSession] = useState<Session | null>(null);
75
+ const key = clientSessionKey(config);
76
+
77
+ useEffect(() => {
78
+ const live = createSession(configRef.current);
79
+ setSession(live);
80
+ return () => {
81
+ live.close();
82
+ setSession(null);
83
+ };
84
+ }, [key]);
85
+
86
+ return session;
87
+ };
88
+
58
89
  /**
59
90
  * Access the live `Session`. Throws if no provider is mounted (a
60
91
  * deliberate fail-fast — the alternative is a silent null that
package/src/store.ts CHANGED
@@ -43,6 +43,12 @@ export type SessionStoreSnapshot = {
43
43
  readonly state: unknown;
44
44
  readonly phaseId: string | null;
45
45
  readonly appliedSeq: number;
46
+ /** Set when the Runtime broadcasts `lifecycle.paused`. */
47
+ readonly sessionPaused: boolean;
48
+ readonly pauseReason: 'host' | 'system' | null;
49
+ /** Active orphan-seat recovery code for the host (chunk B8a). */
50
+ readonly recoveryCode: string | null;
51
+ readonly recoveryCodeHint: string | null;
46
52
  /**
47
53
  * Identifiers of the optimistic projections currently applied on
48
54
  * top of `state`. The actual projection functions are stored
@@ -61,6 +67,15 @@ export type SessionStore = {
61
67
  /** Internal mutators — not exposed past `createSession`. */
62
68
  readonly setConnectionState: (next: ConnectionState) => void;
63
69
  readonly applySnapshot: (state: unknown, phaseId: string, baseSeq: number) => void;
70
+ readonly setPhaseId: (phaseId: string | null) => void;
71
+ readonly setSessionPaused: (
72
+ paused: boolean,
73
+ reason?: 'host' | 'system' | null,
74
+ ) => void;
75
+ readonly setRecoveryCode: (
76
+ code: string | null,
77
+ hint?: string | null,
78
+ ) => void;
64
79
  readonly applyDiff: (
65
80
  patches: readonly JsonPatchOp[],
66
81
  fromSeq: number,
@@ -85,6 +100,10 @@ export const createSessionStore = (): SessionStore => {
85
100
  state: null,
86
101
  phaseId: null,
87
102
  appliedSeq: 0,
103
+ sessionPaused: false,
104
+ pauseReason: null,
105
+ recoveryCode: null,
106
+ recoveryCodeHint: null,
88
107
  pendingProjectionIds: [],
89
108
  };
90
109
 
@@ -123,6 +142,31 @@ export const createSessionStore = (): SessionStore => {
123
142
  applySnapshot: (state, phaseId, baseSeq) => {
124
143
  refreshSnapshot({ state, phaseId, appliedSeq: baseSeq });
125
144
  },
145
+ setPhaseId: (phaseId) => {
146
+ if (phaseId === snapshot.phaseId) return;
147
+ refreshSnapshot({ phaseId });
148
+ },
149
+ setSessionPaused: (paused, reason = null) => {
150
+ if (paused === snapshot.sessionPaused && reason === snapshot.pauseReason) {
151
+ return;
152
+ }
153
+ refreshSnapshot({
154
+ sessionPaused: paused,
155
+ pauseReason: paused ? reason : null,
156
+ });
157
+ },
158
+ setRecoveryCode: (code, hint = null) => {
159
+ if (
160
+ code === snapshot.recoveryCode &&
161
+ hint === snapshot.recoveryCodeHint
162
+ ) {
163
+ return;
164
+ }
165
+ refreshSnapshot({
166
+ recoveryCode: code,
167
+ recoveryCodeHint: hint,
168
+ });
169
+ },
126
170
  applyDiff: (patches, fromSeq, toSeq) => {
127
171
  if (fromSeq !== snapshot.appliedSeq) {
128
172
  return 'resync_required';
package/src/voice.ts CHANGED
@@ -23,6 +23,7 @@
23
23
  import type { Result } from '@wibly/internal-shared';
24
24
 
25
25
  import type { SdkError } from './errors.js';
26
+ import type { SdkQualityTier } from './inference.js';
26
27
 
27
28
  export type SpeakInput = {
28
29
  readonly personaId: string;
@@ -30,7 +31,7 @@ export type SpeakInput = {
30
31
  readonly options?: {
31
32
  readonly voiceId?: string;
32
33
  readonly modelId?: string;
33
- readonly qualityTier?: 'preview' | 'standard' | 'high';
34
+ readonly qualityTier?: SdkQualityTier;
34
35
  };
35
36
  /**
36
37
  * Caption text rendered through the SDK's `voice.caption` event.