@wibly/sdk 0.1.0 → 0.1.2
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 +28 -2
- package/package.json +4 -4
- package/src/client.ts +336 -40
- package/src/consent.ts +25 -3
- package/src/control.ts +8 -2
- package/src/errors.ts +3 -3
- package/src/index.ts +4 -1
- package/src/inference.ts +7 -91
- package/src/lifecycle.ts +22 -1
- package/src/react.ts +32 -1
- package/src/store.ts +44 -0
- package/src/time.ts +17 -1
- package/src/voice.ts +23 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,32 @@
|
|
|
1
1
|
# `@wibly/sdk` — Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
3
|
+
## 0.1.2 — 2026-06-08
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `session.host.abort()` — a universal mid-game abort. Emits the new
|
|
8
|
+
`host.abort` control verb; the Runtime force-jumps to the manifest's
|
|
9
|
+
`workflow.abortPhaseId` (or the last declared phase when omitted) from
|
|
10
|
+
any phase. Games no longer need per-phase abort transitions.
|
|
11
|
+
|
|
12
|
+
## 0.1.1 — 2026-05-30
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- `SdkCallKind` and `SdkQualityTier` are now type-only re-exports of
|
|
17
|
+
`@platform/manifest`'s `CallKind` and `QualityTier` instead of
|
|
18
|
+
hand-duplicated string-literal unions that had drifted from the
|
|
19
|
+
manifest's `CallKindSchema` / `QualityTierSchema`. The previous
|
|
20
|
+
literals included `judge_submissions` and `host_persona_response`
|
|
21
|
+
(neither valid manifest call kinds) and `preview` / `high` quality
|
|
22
|
+
tiers (the manifest set is `fast | standard | premium | creative`);
|
|
23
|
+
any caller using those literals would have failed Gateway request
|
|
24
|
+
validation at the first network hop. Type-only `import` keeps the
|
|
25
|
+
SDK's runtime weight unchanged. The `inference.judge` convenience
|
|
26
|
+
wrapper now sends `callKind: 'host_judge'` (was the invalid
|
|
27
|
+
`judge_submissions`).
|
|
28
|
+
|
|
29
|
+
## 0.1.0 — 2026-05-18
|
|
4
30
|
|
|
5
31
|
Initial Studio SDK surface (chunk B7).
|
|
6
32
|
|
|
@@ -20,7 +46,7 @@ Initial Studio SDK surface (chunk B7).
|
|
|
20
46
|
- `events.{onEvent, onAnyEvent}` and `lifecycle.{onSessionOpened,
|
|
21
47
|
onSessionClosed, onPhaseEntered, onPhaseExited, onHostReclaimed}`.
|
|
22
48
|
- `requestConsent(payload)` to forward an accepted consent decision
|
|
23
|
-
to the User Portal endpoint (chunk
|
|
49
|
+
to the User Portal endpoint (chunk B19a; returns
|
|
24
50
|
`consent_persistence_not_wired` until then).
|
|
25
51
|
- `close()` to tear down the WebSocket + dispose listeners.
|
|
26
52
|
- 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.
|
|
3
|
+
"version": "0.1.2",
|
|
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.
|
|
22
|
-
"@wibly/internal-protocol": "0.1.
|
|
23
|
-
"@wibly/internal-shared": "0.1.
|
|
21
|
+
"@wibly/internal-manifest": "0.1.2",
|
|
22
|
+
"@wibly/internal-protocol": "0.1.2",
|
|
23
|
+
"@wibly/internal-shared": "0.1.2",
|
|
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
|
|
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
|
|
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. */
|
|
@@ -148,6 +181,25 @@ export type Session = {
|
|
|
148
181
|
readonly advancePhase: (
|
|
149
182
|
detail?: unknown,
|
|
150
183
|
) => Result<{ readonly id: string }, SdkError>;
|
|
184
|
+
/**
|
|
185
|
+
* Universal mid-game abort. The Runtime force-jumps to the
|
|
186
|
+
* manifest's abort/terminal phase (`workflow.abortPhaseId`, else
|
|
187
|
+
* the last declared phase) from wherever the session currently is.
|
|
188
|
+
* No per-phase wiring required.
|
|
189
|
+
*/
|
|
190
|
+
readonly abort: () => Result<{ readonly id: string }, SdkError>;
|
|
191
|
+
readonly reportTtsPlayback: (detail: {
|
|
192
|
+
readonly state: 'active' | 'idle';
|
|
193
|
+
}) => Result<{ readonly id: string }, SdkError>;
|
|
194
|
+
readonly reportTtsBeat: (detail: {
|
|
195
|
+
readonly beatId: string;
|
|
196
|
+
readonly event: 'clip_start' | 'clip_end';
|
|
197
|
+
readonly clipId?: string;
|
|
198
|
+
readonly reveal?: {
|
|
199
|
+
readonly kind: 'none' | 'player_submission';
|
|
200
|
+
readonly playerId?: string;
|
|
201
|
+
};
|
|
202
|
+
}) => Result<{ readonly id: string }, SdkError>;
|
|
151
203
|
readonly reclaim: () => Result<{ readonly id: string }, SdkError>;
|
|
152
204
|
};
|
|
153
205
|
readonly inference: SessionInference;
|
|
@@ -160,7 +212,7 @@ export type Session = {
|
|
|
160
212
|
readonly store: SessionStore;
|
|
161
213
|
/**
|
|
162
214
|
* Forward an accepted consent decision to the persistence path.
|
|
163
|
-
* Returns `consent_persistence_not_wired` until chunk
|
|
215
|
+
* Returns `consent_persistence_not_wired` until chunk B19a binds
|
|
164
216
|
* the User Portal endpoint.
|
|
165
217
|
*/
|
|
166
218
|
readonly requestConsent: (
|
|
@@ -177,20 +229,31 @@ type PendingRecord = {
|
|
|
177
229
|
readonly timer?: () => void;
|
|
178
230
|
};
|
|
179
231
|
|
|
232
|
+
type PendingVoiceRecord = {
|
|
233
|
+
readonly resolve: (r: Result<unknown, SdkError>) => void;
|
|
234
|
+
readonly caption: string | null | undefined;
|
|
235
|
+
readonly cancelTimeout: () => void;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
const DEFAULT_VOICE_SPEAK_TIMEOUT_MS = 30_000;
|
|
239
|
+
|
|
180
240
|
export const createSession = (config: SessionConfig): Session => {
|
|
181
241
|
const store = createSessionStore();
|
|
182
242
|
const bus = createEventBus();
|
|
183
243
|
const time = createServerTimeSource({ now: config.now });
|
|
184
244
|
const lifecycle = createLifecycleBindings(bus);
|
|
245
|
+
let isPreviewFlag: boolean = config.isPreview ?? false;
|
|
185
246
|
const pendingSubmits = new Map<MessageId, PendingRecord>();
|
|
186
247
|
const pendingInference = new Map<
|
|
187
248
|
MessageId,
|
|
188
249
|
(r: Result<unknown, SdkError>) => void
|
|
189
250
|
>();
|
|
190
|
-
const pendingVoice = new Map<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
251
|
+
const pendingVoice = new Map<MessageId, PendingVoiceRecord>();
|
|
252
|
+
const voiceSpeakTimeoutMs =
|
|
253
|
+
config.voiceSpeakTimeoutMs ?? DEFAULT_VOICE_SPEAK_TIMEOUT_MS;
|
|
254
|
+
const scheduleTimer =
|
|
255
|
+
config.schedule ??
|
|
256
|
+
((cb: () => void, ms: number) => scheduleTimeout(ms, cb));
|
|
194
257
|
|
|
195
258
|
const handleStateDiff = (payload: StateDiffPayload): void => {
|
|
196
259
|
const action = store.applyDiff(
|
|
@@ -209,6 +272,18 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
209
272
|
|
|
210
273
|
const handleEvent = (payload: EventPayload): void => {
|
|
211
274
|
bus.dispatchEvent(payload);
|
|
275
|
+
if (payload.eventType === 'recovery_code.issued') {
|
|
276
|
+
const data = payload.data as {
|
|
277
|
+
code?: unknown;
|
|
278
|
+
seat?: unknown;
|
|
279
|
+
} | null;
|
|
280
|
+
if (data && typeof data.code === 'string') {
|
|
281
|
+
store.setRecoveryCode(
|
|
282
|
+
data.code,
|
|
283
|
+
typeof data.seat === 'string' ? data.seat : null,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
212
287
|
if (payload.eventType.startsWith(INFERENCE_EVENT_PREFIX)) {
|
|
213
288
|
maybeResolveInference(payload);
|
|
214
289
|
} else if (payload.eventType.startsWith(VOICE_EVENT_PREFIX)) {
|
|
@@ -219,6 +294,60 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
219
294
|
};
|
|
220
295
|
|
|
221
296
|
const handleLifecycle = (payload: LifecyclePayload): void => {
|
|
297
|
+
if (
|
|
298
|
+
payload.transition === 'session.metadata' &&
|
|
299
|
+
payload.detail !== null &&
|
|
300
|
+
typeof payload.detail === 'object' &&
|
|
301
|
+
'isPreview' in payload.detail &&
|
|
302
|
+
typeof (payload.detail as { isPreview: unknown }).isPreview === 'boolean'
|
|
303
|
+
) {
|
|
304
|
+
isPreviewFlag = (payload.detail as { isPreview: boolean }).isPreview;
|
|
305
|
+
cachedSessionState = null;
|
|
306
|
+
}
|
|
307
|
+
if (
|
|
308
|
+
payload.transition === 'session.closed' ||
|
|
309
|
+
payload.transition === 'session.aborted' ||
|
|
310
|
+
payload.transition === 'aborted'
|
|
311
|
+
) {
|
|
312
|
+
transport.close(1000, 'session ended');
|
|
313
|
+
}
|
|
314
|
+
if (payload.transition === 'phase.changed') {
|
|
315
|
+
const detail = payload.detail;
|
|
316
|
+
if (detail !== null && typeof detail === 'object') {
|
|
317
|
+
const toPhaseId = (detail as { toPhaseId?: unknown }).toPhaseId;
|
|
318
|
+
if (typeof toPhaseId === 'string') {
|
|
319
|
+
store.setPhaseId(toPhaseId);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (payload.transition === 'paused') {
|
|
324
|
+
const detail = payload.detail;
|
|
325
|
+
const via =
|
|
326
|
+
detail !== null &&
|
|
327
|
+
typeof detail === 'object' &&
|
|
328
|
+
(detail as { via?: unknown }).via === 'host_control'
|
|
329
|
+
? 'host'
|
|
330
|
+
: 'system';
|
|
331
|
+
store.setSessionPaused(true, via);
|
|
332
|
+
time.freeze();
|
|
333
|
+
}
|
|
334
|
+
if (payload.transition === 'continued') {
|
|
335
|
+
store.setSessionPaused(false, null);
|
|
336
|
+
time.unfreeze();
|
|
337
|
+
}
|
|
338
|
+
if (payload.transition === 'seat.recovery_code_issued') {
|
|
339
|
+
const detail = payload.detail;
|
|
340
|
+
if (detail !== null && typeof detail === 'object') {
|
|
341
|
+
const code = (detail as { code?: unknown }).code;
|
|
342
|
+
const seat = (detail as { seat?: unknown }).seat;
|
|
343
|
+
if (typeof code === 'string') {
|
|
344
|
+
store.setRecoveryCode(
|
|
345
|
+
code,
|
|
346
|
+
typeof seat === 'string' ? seat : null,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
222
351
|
bus.dispatchLifecycle(payload);
|
|
223
352
|
};
|
|
224
353
|
|
|
@@ -261,9 +390,10 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
261
390
|
}
|
|
262
391
|
const voice = pendingVoice.get(causeId);
|
|
263
392
|
if (voice !== undefined) {
|
|
393
|
+
voice.cancelTimeout();
|
|
264
394
|
pendingVoice.delete(causeId);
|
|
265
395
|
transport.confirmSend(causeId);
|
|
266
|
-
voice({
|
|
396
|
+
voice.resolve({
|
|
267
397
|
ok: false,
|
|
268
398
|
error: {
|
|
269
399
|
kind: 'submit_rejected',
|
|
@@ -366,15 +496,121 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
366
496
|
cb(ok(payload.data));
|
|
367
497
|
};
|
|
368
498
|
|
|
499
|
+
const dispatchVoiceAudio = (
|
|
500
|
+
causeId: MessageId,
|
|
501
|
+
data: {
|
|
502
|
+
audioBase64: string;
|
|
503
|
+
contentType?: unknown;
|
|
504
|
+
durationMs?: unknown;
|
|
505
|
+
beat?: unknown;
|
|
506
|
+
cues?: unknown;
|
|
507
|
+
},
|
|
508
|
+
caption: string | null | undefined,
|
|
509
|
+
): void => {
|
|
510
|
+
bus.dispatchEvent({
|
|
511
|
+
sessionId: config.sessionId,
|
|
512
|
+
eventType: 'voice.audio',
|
|
513
|
+
data: {
|
|
514
|
+
id: causeId,
|
|
515
|
+
audioBase64: data.audioBase64,
|
|
516
|
+
contentType:
|
|
517
|
+
typeof data.contentType === 'string'
|
|
518
|
+
? data.contentType
|
|
519
|
+
: 'audio/mpeg',
|
|
520
|
+
durationMs:
|
|
521
|
+
typeof data.durationMs === 'number' && data.durationMs > 0
|
|
522
|
+
? data.durationMs
|
|
523
|
+
: estimateAudioDurationMs(data.audioBase64),
|
|
524
|
+
caption: caption === undefined ? null : caption,
|
|
525
|
+
// Carry in-phase beat + lip-sync cues through to the Host shell.
|
|
526
|
+
// These drive Experience reveal choreography via `reportTtsBeat`
|
|
527
|
+
// → `onTtsBeat`; dropping them here silently breaks beat reveals.
|
|
528
|
+
...(data.beat !== undefined ? { beat: data.beat } : {}),
|
|
529
|
+
...(data.cues !== undefined ? { cues: data.cues } : {}),
|
|
530
|
+
},
|
|
531
|
+
});
|
|
532
|
+
};
|
|
533
|
+
|
|
369
534
|
const maybeResolveVoice = (payload: EventPayload): void => {
|
|
370
|
-
const data = payload.data as {
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
535
|
+
const data = payload.data as {
|
|
536
|
+
causeMessageId?: string;
|
|
537
|
+
audioBase64?: string;
|
|
538
|
+
contentType?: string;
|
|
539
|
+
durationMs?: number;
|
|
540
|
+
kind?: string;
|
|
541
|
+
beat?: unknown;
|
|
542
|
+
cues?: unknown;
|
|
543
|
+
} | null;
|
|
544
|
+
if (!data) return;
|
|
545
|
+
|
|
546
|
+
const audioBase64 = data.audioBase64;
|
|
547
|
+
const isSpeakResult =
|
|
548
|
+
payload.eventType === 'voice.speak.result' &&
|
|
549
|
+
typeof audioBase64 === 'string';
|
|
550
|
+
|
|
551
|
+
const causeId = (() => {
|
|
552
|
+
if (typeof data.causeMessageId === 'string' && data.causeMessageId.length > 0) {
|
|
553
|
+
return data.causeMessageId as MessageId;
|
|
554
|
+
}
|
|
555
|
+
if (isSpeakResult) {
|
|
556
|
+
return `voice-server-${audioBase64.length}-${audioBase64.slice(0, 12)}` as MessageId;
|
|
557
|
+
}
|
|
558
|
+
return null;
|
|
559
|
+
})();
|
|
560
|
+
|
|
561
|
+
if (isSpeakResult && causeId !== null) {
|
|
562
|
+
const pending =
|
|
563
|
+
typeof data.causeMessageId === 'string' && data.causeMessageId.length > 0
|
|
564
|
+
? pendingVoice.get(data.causeMessageId as MessageId)
|
|
565
|
+
: undefined;
|
|
566
|
+
dispatchVoiceAudio(
|
|
567
|
+
causeId,
|
|
568
|
+
{
|
|
569
|
+
audioBase64,
|
|
570
|
+
contentType: data.contentType,
|
|
571
|
+
durationMs: data.durationMs,
|
|
572
|
+
beat: data.beat,
|
|
573
|
+
cues: data.cues,
|
|
574
|
+
},
|
|
575
|
+
pending?.caption ?? null,
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (typeof data.causeMessageId !== 'string' || data.causeMessageId.length === 0) {
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const causeMessageId = data.causeMessageId as MessageId;
|
|
584
|
+
const pending = pendingVoice.get(causeMessageId);
|
|
585
|
+
|
|
586
|
+
if (pending === undefined) return;
|
|
587
|
+
pending.cancelTimeout();
|
|
588
|
+
pendingVoice.delete(causeMessageId);
|
|
589
|
+
transport.confirmSend(causeMessageId);
|
|
590
|
+
|
|
591
|
+
if (isSpeakResult) {
|
|
592
|
+
pending.resolve(
|
|
593
|
+
ok({
|
|
594
|
+
audioBase64,
|
|
595
|
+
contentType:
|
|
596
|
+
typeof data.contentType === 'string'
|
|
597
|
+
? data.contentType
|
|
598
|
+
: 'audio/mpeg',
|
|
599
|
+
durationMs:
|
|
600
|
+
typeof data.durationMs === 'number' && data.durationMs > 0
|
|
601
|
+
? data.durationMs
|
|
602
|
+
: estimateAudioDurationMs(audioBase64),
|
|
603
|
+
} satisfies SpeakSuccess),
|
|
604
|
+
);
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (payload.eventType === 'voice.speak.error') {
|
|
609
|
+
pending.resolve({
|
|
610
|
+
ok: false,
|
|
611
|
+
error: { kind: 'runtime_not_wired' },
|
|
612
|
+
});
|
|
613
|
+
}
|
|
378
614
|
};
|
|
379
615
|
|
|
380
616
|
const maybeHandleConsent = (payload: EventPayload): void => {
|
|
@@ -409,18 +645,40 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
409
645
|
})();
|
|
410
646
|
};
|
|
411
647
|
|
|
648
|
+
// `useSyncExternalStore` compares snapshots with `Object.is`. A fresh
|
|
649
|
+
// object on every `getState()` call makes React think the store
|
|
650
|
+
// changed on every read → infinite re-renders in shell hooks.
|
|
651
|
+
let cachedStoreSnapshot: ReturnType<SessionStore['getSnapshot']> | null =
|
|
652
|
+
null;
|
|
653
|
+
let cachedSessionState: SessionState | null = null;
|
|
654
|
+
|
|
655
|
+
const readSessionState = (): SessionState => {
|
|
656
|
+
const snap = store.getSnapshot();
|
|
657
|
+
if (cachedStoreSnapshot === snap && cachedSessionState !== null) {
|
|
658
|
+
return cachedSessionState;
|
|
659
|
+
}
|
|
660
|
+
cachedStoreSnapshot = snap;
|
|
661
|
+
cachedSessionState = {
|
|
662
|
+
connectionState: snap.connectionState,
|
|
663
|
+
state: snap.state,
|
|
664
|
+
projectedState: store.getProjectedState(),
|
|
665
|
+
appliedSeq: snap.appliedSeq,
|
|
666
|
+
phaseId: snap.phaseId,
|
|
667
|
+
sessionPaused: snap.sessionPaused,
|
|
668
|
+
pauseReason: snap.pauseReason,
|
|
669
|
+
recoveryCode: snap.recoveryCode,
|
|
670
|
+
recoveryCodeHint: snap.recoveryCodeHint,
|
|
671
|
+
isPreview: isPreviewFlag,
|
|
672
|
+
};
|
|
673
|
+
return cachedSessionState;
|
|
674
|
+
};
|
|
675
|
+
|
|
412
676
|
const session: Session = {
|
|
413
677
|
sessionId: config.sessionId,
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
return {
|
|
417
|
-
connectionState: snap.connectionState,
|
|
418
|
-
state: snap.state,
|
|
419
|
-
projectedState: store.getProjectedState(),
|
|
420
|
-
appliedSeq: snap.appliedSeq,
|
|
421
|
-
phaseId: snap.phaseId,
|
|
422
|
-
};
|
|
678
|
+
get isPreview(): boolean {
|
|
679
|
+
return isPreviewFlag;
|
|
423
680
|
},
|
|
681
|
+
getState: readSessionState,
|
|
424
682
|
subscribe: (listener) => store.subscribe(listener),
|
|
425
683
|
submit: (args) => {
|
|
426
684
|
const payload = buildSubmitPayload(config.sessionId, args);
|
|
@@ -501,6 +759,32 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
501
759
|
const sent = transport.send('emit', payload);
|
|
502
760
|
return ok({ id: sent.id as unknown as string });
|
|
503
761
|
},
|
|
762
|
+
abort: () => {
|
|
763
|
+
const payload = buildHostEmitPayload(
|
|
764
|
+
config.sessionId,
|
|
765
|
+
HOST_EVENT_TYPES.abort,
|
|
766
|
+
);
|
|
767
|
+
const sent = transport.send('emit', payload);
|
|
768
|
+
return ok({ id: sent.id as unknown as string });
|
|
769
|
+
},
|
|
770
|
+
reportTtsPlayback: (detail) => {
|
|
771
|
+
const payload = buildHostEmitPayload(
|
|
772
|
+
config.sessionId,
|
|
773
|
+
HOST_EVENT_TYPES.ttsPlayback,
|
|
774
|
+
detail,
|
|
775
|
+
);
|
|
776
|
+
const sent = transport.send('emit', payload);
|
|
777
|
+
return ok({ id: sent.id as unknown as string });
|
|
778
|
+
},
|
|
779
|
+
reportTtsBeat: (detail) => {
|
|
780
|
+
const payload = buildHostEmitPayload(
|
|
781
|
+
config.sessionId,
|
|
782
|
+
HOST_EVENT_TYPES.ttsBeat,
|
|
783
|
+
detail,
|
|
784
|
+
);
|
|
785
|
+
const sent = transport.send('emit', payload);
|
|
786
|
+
return ok({ id: sent.id as unknown as string });
|
|
787
|
+
},
|
|
504
788
|
reclaim: () => {
|
|
505
789
|
const payload = buildHostEmitPayload(
|
|
506
790
|
config.sessionId,
|
|
@@ -515,7 +799,13 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
515
799
|
transport,
|
|
516
800
|
pendingInference,
|
|
517
801
|
),
|
|
518
|
-
voice: buildVoiceVerbs(
|
|
802
|
+
voice: buildVoiceVerbs(
|
|
803
|
+
config.sessionId,
|
|
804
|
+
transport,
|
|
805
|
+
pendingVoice,
|
|
806
|
+
scheduleTimer,
|
|
807
|
+
voiceSpeakTimeoutMs,
|
|
808
|
+
),
|
|
519
809
|
events: { onEvent: bus.onEvent, onAnyEvent: bus.onAnyEvent },
|
|
520
810
|
lifecycle,
|
|
521
811
|
time: { serverNow: time.serverNow, recordEvent: time.recordEvent },
|
|
@@ -537,8 +827,9 @@ export const createSession = (config: SessionConfig): Session => {
|
|
|
537
827
|
cb({ ok: false, error: { kind: 'not_connected' } });
|
|
538
828
|
}
|
|
539
829
|
pendingInference.clear();
|
|
540
|
-
for (const [,
|
|
541
|
-
|
|
830
|
+
for (const [, pending] of pendingVoice) {
|
|
831
|
+
pending.cancelTimeout();
|
|
832
|
+
pending.resolve({ ok: false, error: { kind: 'not_connected' } });
|
|
542
833
|
}
|
|
543
834
|
pendingVoice.clear();
|
|
544
835
|
bus.dispose();
|
|
@@ -588,7 +879,7 @@ const buildInferenceVerbs = (
|
|
|
588
879
|
});
|
|
589
880
|
const sent = transport.send('emit', {
|
|
590
881
|
sessionId,
|
|
591
|
-
eventType: `${INFERENCE_EVENT_PREFIX}${input.
|
|
882
|
+
eventType: `${INFERENCE_EVENT_PREFIX}${input.templateId}`,
|
|
592
883
|
data: serialised,
|
|
593
884
|
});
|
|
594
885
|
pending.set(sent.id, resolveFn);
|
|
@@ -607,22 +898,23 @@ const buildInferenceVerbs = (
|
|
|
607
898
|
};
|
|
608
899
|
return {
|
|
609
900
|
call,
|
|
610
|
-
host: (input) => call({ ...input, callKind: 'host_open_phase' }),
|
|
611
|
-
judge: (input) => call({ ...input, callKind: 'judge_submissions' }),
|
|
612
|
-
classify: (input) => call({ ...input, callKind: 'classify' }),
|
|
613
901
|
};
|
|
614
902
|
};
|
|
615
903
|
|
|
616
904
|
const buildVoiceVerbs = (
|
|
617
905
|
sessionId: SessionId,
|
|
618
906
|
transport: Transport,
|
|
619
|
-
pending: Map<MessageId,
|
|
907
|
+
pending: Map<MessageId, PendingVoiceRecord>,
|
|
908
|
+
schedule: (cb: () => void, ms: number) => () => void,
|
|
909
|
+
timeoutMs: number,
|
|
620
910
|
): SessionVoice => ({
|
|
621
911
|
speak: async (input) => {
|
|
622
912
|
let resolveFn!: (r: Result<unknown, SdkError>) => void;
|
|
623
913
|
const promise = new Promise<Result<unknown, SdkError>>((res) => {
|
|
624
914
|
resolveFn = res;
|
|
625
915
|
});
|
|
916
|
+
const caption =
|
|
917
|
+
input.caption === null ? null : (input.caption ?? input.text);
|
|
626
918
|
const sent = transport.send('emit', {
|
|
627
919
|
sessionId,
|
|
628
920
|
eventType: `${VOICE_EVENT_PREFIX}speak`,
|
|
@@ -630,17 +922,21 @@ const buildVoiceVerbs = (
|
|
|
630
922
|
personaId: input.personaId,
|
|
631
923
|
text: input.text,
|
|
632
924
|
options: input.options ?? {},
|
|
633
|
-
caption
|
|
925
|
+
caption,
|
|
634
926
|
},
|
|
635
927
|
});
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
if (cb === undefined) return;
|
|
928
|
+
const cancelTimeout = schedule(() => {
|
|
929
|
+
const record = pending.get(sent.id);
|
|
930
|
+
if (record === undefined) return;
|
|
640
931
|
pending.delete(sent.id);
|
|
641
932
|
transport.confirmSend(sent.id);
|
|
642
|
-
|
|
643
|
-
},
|
|
933
|
+
record.resolve({ ok: false, error: { kind: 'runtime_not_wired' } });
|
|
934
|
+
}, timeoutMs);
|
|
935
|
+
pending.set(sent.id, {
|
|
936
|
+
resolve: resolveFn,
|
|
937
|
+
caption,
|
|
938
|
+
cancelTimeout,
|
|
939
|
+
});
|
|
644
940
|
const result = await promise;
|
|
645
941
|
return result as Result<SpeakSuccess, SdkError>;
|
|
646
942
|
},
|
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
|
|
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` (
|
|
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 (
|
|
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,8 +13,11 @@
|
|
|
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.
|
|
16
|
+
* - `host.advancePhase` — request the next phase. The Runtime
|
|
17
17
|
* may reject if no transition matches.
|
|
18
|
+
* - `host.abort` — universal mid-game abort. The Runtime
|
|
19
|
+
* force-jumps to the manifest's abort/
|
|
20
|
+
* terminal phase (no per-phase wiring).
|
|
18
21
|
* - `host.reclaim` — reclaim the host slot from a hung
|
|
19
22
|
* host (the player who fires this
|
|
20
23
|
* becomes the new host if allowed).
|
|
@@ -25,7 +28,10 @@ import type { EmitPayload } from '@wibly/internal-protocol';
|
|
|
25
28
|
export const HOST_EVENT_TYPES = {
|
|
26
29
|
pause: 'host.pause',
|
|
27
30
|
resume: 'host.resume',
|
|
28
|
-
advancePhase: 'host.
|
|
31
|
+
advancePhase: 'host.advancePhase',
|
|
32
|
+
abort: 'host.abort',
|
|
33
|
+
ttsPlayback: 'host.ttsPlayback',
|
|
34
|
+
ttsBeat: 'host.ttsBeat',
|
|
29
35
|
reclaim: 'host.reclaim',
|
|
30
36
|
} as const;
|
|
31
37
|
|
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
|
|
26
|
-
*
|
|
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
|
|
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,
|
|
@@ -66,7 +67,6 @@ export {
|
|
|
66
67
|
buildInferenceRequest,
|
|
67
68
|
type InferenceCallInput,
|
|
68
69
|
type InferenceCallSuccess,
|
|
69
|
-
type SdkCallKind,
|
|
70
70
|
type SdkQualityTier,
|
|
71
71
|
type SerialisedInferenceRequest,
|
|
72
72
|
type SessionInference,
|
|
@@ -78,6 +78,9 @@ export {
|
|
|
78
78
|
type SessionVoice,
|
|
79
79
|
type SpeakInput,
|
|
80
80
|
type SpeakSuccess,
|
|
81
|
+
type TtsBeatMeta,
|
|
82
|
+
type TtsBeatReveal,
|
|
83
|
+
type VoiceAudioPayload,
|
|
81
84
|
} from './voice.js';
|
|
82
85
|
|
|
83
86
|
export {
|
package/src/inference.ts
CHANGED
|
@@ -1,37 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Inference verbs
|
|
3
|
-
* helpers: session.inference.host(slots), .judge(slots),
|
|
4
|
-
* .classify(slots), etc. — calls the Gateway through the Runtime
|
|
5
|
-
* (the SDK never holds the gateway-auth key).").
|
|
6
|
-
*
|
|
7
|
-
* The chunk-B7 contract is:
|
|
8
|
-
*
|
|
9
|
-
* 1. The caller invokes `session.inference.<verb>({ slots, output,
|
|
10
|
-
* qualityTier? })`.
|
|
11
|
-
* 2. The SDK serialises any caller-declared Zod output schema to
|
|
12
|
-
* JSON Schema (via `@platform/shared/json-schema.ts`).
|
|
13
|
-
* 3. The SDK sends an `emit` frame with a reserved `inference.*`
|
|
14
|
-
* event type. The Runtime (chunk B8a) receives it, signs the
|
|
15
|
-
* request, forwards to the Gateway, and emits a follow-up
|
|
16
|
-
* `event` frame with the result keyed by the original message
|
|
17
|
-
* id.
|
|
18
|
-
* 4. The SDK matches the inbound event against the pending call's
|
|
19
|
-
* id and resolves the typed result.
|
|
20
|
-
*
|
|
21
|
-
* **Why `emit` and not a new wire kind?** The protocol already
|
|
22
|
-
* carries `emit` for asynchronous client → server work; reserving an
|
|
23
|
-
* `inference.*` namespace on `eventType` keeps the surface compact
|
|
24
|
-
* and avoids a `PROTOCOL_VERSION` bump. The chunk-B8a Runtime is
|
|
25
|
-
* what enforces the gating + signing.
|
|
26
|
-
*
|
|
27
|
-
* Until chunk B8a wires the Runtime-side handler, the SDK's
|
|
28
|
-
* inference verbs return `Err({ kind: 'runtime_not_wired' })`. The
|
|
29
|
-
* SDK still serialises the schema + payload so the chunk-B7 surface
|
|
30
|
-
* is testable (the JSON Schema + outbound `emit` shape are
|
|
31
|
-
* regression-protected); the runtime-side roundtrip lights up with
|
|
32
|
-
* B8a.
|
|
2
|
+
* Inference verbs — game code calls by manifest `templateId`.
|
|
33
3
|
*/
|
|
34
4
|
|
|
5
|
+
import type { QualityTier } from '@wibly/internal-manifest';
|
|
35
6
|
import type { z, ZodTypeAny } from 'zod';
|
|
36
7
|
|
|
37
8
|
import { zodToJsonSchema, type JsonSchema } from '@wibly/internal-shared';
|
|
@@ -39,52 +10,16 @@ import type { Result } from '@wibly/internal-shared';
|
|
|
39
10
|
|
|
40
11
|
import type { SdkError } from './errors.js';
|
|
41
12
|
|
|
42
|
-
/**
|
|
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).
|
|
48
|
-
*/
|
|
49
|
-
export type SdkQualityTier = 'preview' | 'standard' | 'high';
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* Recognised call kinds. Matches `CallKindSchema`'s enum; duplicated
|
|
53
|
-
* here to keep the SDK type-only against the manifest package.
|
|
54
|
-
*
|
|
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.
|
|
59
|
-
*/
|
|
60
|
-
export type SdkCallKind =
|
|
61
|
-
| 'host_open_phase'
|
|
62
|
-
| 'narrate_event'
|
|
63
|
-
| 'judge_submissions'
|
|
64
|
-
| 'host_persona_response'
|
|
65
|
-
| 'classify'
|
|
66
|
-
| (string & {});
|
|
67
|
-
|
|
68
13
|
export type InferenceCallInput<TOutput extends ZodTypeAny> = {
|
|
69
|
-
readonly
|
|
14
|
+
readonly templateId: string;
|
|
70
15
|
readonly slots: Readonly<Record<string, unknown>>;
|
|
71
16
|
readonly output?: TOutput;
|
|
72
|
-
readonly qualityTier?: SdkQualityTier;
|
|
73
|
-
/**
|
|
74
|
-
* Optional idempotency key. The Runtime forwards it to the
|
|
75
|
-
* Gateway's `metadata.idempotencyKey`. Use case: a host that
|
|
76
|
-
* wants to retry without double-billing.
|
|
77
|
-
*/
|
|
78
17
|
readonly idempotencyKey?: string;
|
|
79
18
|
};
|
|
80
19
|
|
|
81
20
|
export type InferenceCallSuccess<TOutput extends ZodTypeAny> = {
|
|
82
|
-
/** Raw model output (the Gateway's `output`). */
|
|
83
21
|
readonly output: string;
|
|
84
|
-
/** Parsed structured response. `null` if no schema was provided. */
|
|
85
22
|
readonly structured: TOutput extends ZodTypeAny ? z.infer<TOutput> | null : null;
|
|
86
|
-
/** Gateway usage block. Surface for debug; the operator-side
|
|
87
|
-
* dashboards use the audit ledger instead. */
|
|
88
23
|
readonly usage: {
|
|
89
24
|
readonly model: string;
|
|
90
25
|
readonly tokensIn: number;
|
|
@@ -95,8 +30,7 @@ export type InferenceCallSuccess<TOutput extends ZodTypeAny> = {
|
|
|
95
30
|
};
|
|
96
31
|
|
|
97
32
|
export type SerialisedInferenceRequest = {
|
|
98
|
-
readonly
|
|
99
|
-
readonly qualityTier: SdkQualityTier;
|
|
33
|
+
readonly templateId: string;
|
|
100
34
|
readonly slots: Readonly<Record<string, unknown>>;
|
|
101
35
|
readonly outputSchema: JsonSchema | undefined;
|
|
102
36
|
readonly idempotencyKey: string | undefined;
|
|
@@ -104,37 +38,19 @@ export type SerialisedInferenceRequest = {
|
|
|
104
38
|
|
|
105
39
|
export const INFERENCE_EVENT_PREFIX = 'inference.' as const;
|
|
106
40
|
|
|
107
|
-
/**
|
|
108
|
-
* Build the wire payload for an inference call. Surfaced as a pure
|
|
109
|
-
* helper so the testkit can assert the serialised shape without
|
|
110
|
-
* spinning a transport.
|
|
111
|
-
*/
|
|
112
41
|
export const buildInferenceRequest = <TOutput extends ZodTypeAny>(
|
|
113
42
|
input: InferenceCallInput<TOutput>,
|
|
114
43
|
): SerialisedInferenceRequest => ({
|
|
115
|
-
|
|
116
|
-
qualityTier: input.qualityTier ?? 'standard',
|
|
44
|
+
templateId: input.templateId,
|
|
117
45
|
slots: input.slots,
|
|
118
46
|
outputSchema: input.output ? zodToJsonSchema(input.output) : undefined,
|
|
119
47
|
idempotencyKey: input.idempotencyKey,
|
|
120
48
|
});
|
|
121
49
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
* `client.ts` to the live transport; the Runtime wires up the
|
|
125
|
-
* server-side response under chunk B8a.
|
|
126
|
-
*/
|
|
50
|
+
export type SdkQualityTier = QualityTier;
|
|
51
|
+
|
|
127
52
|
export type SessionInference = {
|
|
128
53
|
readonly call: <TOutput extends ZodTypeAny = ZodTypeAny>(
|
|
129
54
|
input: InferenceCallInput<TOutput>,
|
|
130
55
|
) => Promise<Result<InferenceCallSuccess<TOutput>, SdkError>>;
|
|
131
|
-
readonly host: <TOutput extends ZodTypeAny = ZodTypeAny>(
|
|
132
|
-
input: Omit<InferenceCallInput<TOutput>, 'callKind'>,
|
|
133
|
-
) => Promise<Result<InferenceCallSuccess<TOutput>, SdkError>>;
|
|
134
|
-
readonly judge: <TOutput extends ZodTypeAny = ZodTypeAny>(
|
|
135
|
-
input: Omit<InferenceCallInput<TOutput>, 'callKind'>,
|
|
136
|
-
) => Promise<Result<InferenceCallSuccess<TOutput>, SdkError>>;
|
|
137
|
-
readonly classify: <TOutput extends ZodTypeAny = ZodTypeAny>(
|
|
138
|
-
input: Omit<InferenceCallInput<TOutput>, 'callKind'>,
|
|
139
|
-
) => Promise<Result<InferenceCallSuccess<TOutput>, SdkError>>;
|
|
140
56
|
};
|
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) =>
|
|
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/time.ts
CHANGED
|
@@ -24,6 +24,10 @@ export type ServerTimeSource = {
|
|
|
24
24
|
readonly recordedEvents: () => readonly RecordedEvent[];
|
|
25
25
|
/** Internal: update the measured skew on a fresh pong. */
|
|
26
26
|
readonly updateSkew: (skewMs: number) => void;
|
|
27
|
+
/** Freeze `serverNow()` while the session is paused. */
|
|
28
|
+
readonly freeze: () => void;
|
|
29
|
+
/** Resume advancing `serverNow()` after a pause. */
|
|
30
|
+
readonly unfreeze: () => void;
|
|
27
31
|
};
|
|
28
32
|
|
|
29
33
|
export type RecordedEvent = {
|
|
@@ -40,10 +44,14 @@ export const createServerTimeSource = (
|
|
|
40
44
|
): ServerTimeSource => {
|
|
41
45
|
const nowFn = opts.now ?? (() => Date.now());
|
|
42
46
|
let skew = 0;
|
|
47
|
+
let frozenServerNow: number | null = null;
|
|
43
48
|
const events: RecordedEvent[] = [];
|
|
44
49
|
|
|
45
50
|
return {
|
|
46
|
-
serverNow: () =>
|
|
51
|
+
serverNow: () => {
|
|
52
|
+
if (frozenServerNow !== null) return frozenServerNow;
|
|
53
|
+
return nowFn() + skew;
|
|
54
|
+
},
|
|
47
55
|
recordEvent: (eventId) => {
|
|
48
56
|
const clientTs = nowFn();
|
|
49
57
|
events.push({ eventId, clientTs });
|
|
@@ -54,5 +62,13 @@ export const createServerTimeSource = (
|
|
|
54
62
|
updateSkew: (newSkew) => {
|
|
55
63
|
skew = newSkew;
|
|
56
64
|
},
|
|
65
|
+
freeze: () => {
|
|
66
|
+
if (frozenServerNow === null) {
|
|
67
|
+
frozenServerNow = nowFn() + skew;
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
unfreeze: () => {
|
|
71
|
+
frozenServerNow = null;
|
|
72
|
+
},
|
|
57
73
|
};
|
|
58
74
|
};
|
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?:
|
|
34
|
+
readonly qualityTier?: SdkQualityTier;
|
|
34
35
|
};
|
|
35
36
|
/**
|
|
36
37
|
* Caption text rendered through the SDK's `voice.caption` event.
|
|
@@ -46,6 +47,27 @@ export type SpeakSuccess = {
|
|
|
46
47
|
readonly durationMs: number;
|
|
47
48
|
};
|
|
48
49
|
|
|
50
|
+
/** Optional beat metadata for in-phase TTS choreography (chunk B26). */
|
|
51
|
+
export type TtsBeatReveal = {
|
|
52
|
+
readonly kind: 'none' | 'player_submission';
|
|
53
|
+
readonly playerId?: string;
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
export type TtsBeatMeta = {
|
|
57
|
+
readonly beatId: string;
|
|
58
|
+
readonly sequence: number;
|
|
59
|
+
readonly reveal?: TtsBeatReveal;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Wire shape on `voice.audio` events (Host + Experience bundles). */
|
|
63
|
+
export type VoiceAudioPayload = SpeakSuccess & {
|
|
64
|
+
readonly id?: string;
|
|
65
|
+
readonly causeMessageId?: string;
|
|
66
|
+
readonly caption?: string | null;
|
|
67
|
+
readonly beat?: TtsBeatMeta;
|
|
68
|
+
readonly cues?: ReadonlyArray<{ readonly at: number; readonly kind: string }>;
|
|
69
|
+
};
|
|
70
|
+
|
|
49
71
|
export type SessionVoice = {
|
|
50
72
|
readonly speak: (input: SpeakInput) => Promise<Result<SpeakSuccess, SdkError>>;
|
|
51
73
|
};
|