@wibly/sdk 0.1.1 → 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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # `@wibly/sdk` — Changelog
2
2
 
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
+
3
12
  ## 0.1.1 — 2026-05-30
4
13
 
5
14
  ### Fixed
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wibly/sdk",
3
- "version": "0.1.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.1",
22
- "@wibly/internal-protocol": "0.1.1",
23
- "@wibly/internal-shared": "0.1.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
@@ -181,6 +181,25 @@ export type Session = {
181
181
  readonly advancePhase: (
182
182
  detail?: unknown,
183
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>;
184
203
  readonly reclaim: () => Result<{ readonly id: string }, SdkError>;
185
204
  };
186
205
  readonly inference: SessionInference;
@@ -310,9 +329,11 @@ export const createSession = (config: SessionConfig): Session => {
310
329
  ? 'host'
311
330
  : 'system';
312
331
  store.setSessionPaused(true, via);
332
+ time.freeze();
313
333
  }
314
334
  if (payload.transition === 'continued') {
315
335
  store.setSessionPaused(false, null);
336
+ time.unfreeze();
316
337
  }
317
338
  if (payload.transition === 'seat.recovery_code_issued') {
318
339
  const detail = payload.detail;
@@ -481,6 +502,8 @@ export const createSession = (config: SessionConfig): Session => {
481
502
  audioBase64: string;
482
503
  contentType?: unknown;
483
504
  durationMs?: unknown;
505
+ beat?: unknown;
506
+ cues?: unknown;
484
507
  },
485
508
  caption: string | null | undefined,
486
509
  ): void => {
@@ -499,6 +522,11 @@ export const createSession = (config: SessionConfig): Session => {
499
522
  ? data.durationMs
500
523
  : estimateAudioDurationMs(data.audioBase64),
501
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 } : {}),
502
530
  },
503
531
  });
504
532
  };
@@ -510,37 +538,57 @@ export const createSession = (config: SessionConfig): Session => {
510
538
  contentType?: string;
511
539
  durationMs?: number;
512
540
  kind?: string;
541
+ beat?: unknown;
542
+ cues?: unknown;
513
543
  } | null;
514
- if (!data || typeof data.causeMessageId !== 'string') return;
515
- const causeId = data.causeMessageId as unknown as MessageId;
516
- const pending = pendingVoice.get(causeId);
517
- const caption = pending?.caption;
518
- const audioBase64 = data.audioBase64;
544
+ if (!data) return;
519
545
 
520
- if (
546
+ const audioBase64 = data.audioBase64;
547
+ const isSpeakResult =
521
548
  payload.eventType === 'voice.speak.result' &&
522
- typeof audioBase64 === 'string'
523
- ) {
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;
524
566
  dispatchVoiceAudio(
525
567
  causeId,
526
568
  {
527
569
  audioBase64,
528
570
  contentType: data.contentType,
529
571
  durationMs: data.durationMs,
572
+ beat: data.beat,
573
+ cues: data.cues,
530
574
  },
531
- caption ?? null,
575
+ pending?.caption ?? null,
532
576
  );
533
577
  }
534
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
+
535
586
  if (pending === undefined) return;
536
587
  pending.cancelTimeout();
537
- pendingVoice.delete(causeId);
538
- transport.confirmSend(causeId);
588
+ pendingVoice.delete(causeMessageId);
589
+ transport.confirmSend(causeMessageId);
539
590
 
540
- if (
541
- payload.eventType === 'voice.speak.result' &&
542
- typeof audioBase64 === 'string'
543
- ) {
591
+ if (isSpeakResult) {
544
592
  pending.resolve(
545
593
  ok({
546
594
  audioBase64,
@@ -711,6 +759,32 @@ export const createSession = (config: SessionConfig): Session => {
711
759
  const sent = transport.send('emit', payload);
712
760
  return ok({ id: sent.id as unknown as string });
713
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
+ },
714
788
  reclaim: () => {
715
789
  const payload = buildHostEmitPayload(
716
790
  config.sessionId,
@@ -805,7 +879,7 @@ const buildInferenceVerbs = (
805
879
  });
806
880
  const sent = transport.send('emit', {
807
881
  sessionId,
808
- eventType: `${INFERENCE_EVENT_PREFIX}${input.callKind}`,
882
+ eventType: `${INFERENCE_EVENT_PREFIX}${input.templateId}`,
809
883
  data: serialised,
810
884
  });
811
885
  pending.set(sent.id, resolveFn);
@@ -824,16 +898,6 @@ const buildInferenceVerbs = (
824
898
  };
825
899
  return {
826
900
  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.
834
- host: (input) => call({ ...input, callKind: 'host_open_phase' }),
835
- judge: (input) => call({ ...input, callKind: 'host_judge' }),
836
- classify: (input) => call({ ...input, callKind: 'classify' }),
837
901
  };
838
902
  };
839
903
 
package/src/control.ts CHANGED
@@ -15,6 +15,9 @@
15
15
  * - `host.resume` — resume from pause.
16
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).
@@ -26,6 +29,9 @@ export const HOST_EVENT_TYPES = {
26
29
  pause: 'host.pause',
27
30
  resume: 'host.resume',
28
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/index.ts CHANGED
@@ -67,7 +67,6 @@ export {
67
67
  buildInferenceRequest,
68
68
  type InferenceCallInput,
69
69
  type InferenceCallSuccess,
70
- type SdkCallKind,
71
70
  type SdkQualityTier,
72
71
  type SerialisedInferenceRequest,
73
72
  type SessionInference,
@@ -79,6 +78,9 @@ export {
79
78
  type SessionVoice,
80
79
  type SpeakInput,
81
80
  type SpeakSuccess,
81
+ type TtsBeatMeta,
82
+ type TtsBeatReveal,
83
+ type VoiceAudioPayload,
82
84
  } from './voice.js';
83
85
 
84
86
  export {
package/src/inference.ts CHANGED
@@ -1,98 +1,25 @@
1
1
  /**
2
- * Inference verbs (per chunk B7 build: "inference.ts typed call
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';
38
9
  import type { Result } from '@wibly/internal-shared';
39
- import type { CallKind, QualityTier } from '@wibly/internal-manifest';
40
10
 
41
11
  import type { SdkError } from './errors.js';
42
12
 
43
- /**
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.
56
- */
57
- export type SdkQualityTier = QualityTier;
58
-
59
- /**
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.
68
- *
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.
73
- */
74
- export type SdkCallKind = CallKind | (string & {});
75
-
76
13
  export type InferenceCallInput<TOutput extends ZodTypeAny> = {
77
- readonly callKind: SdkCallKind;
14
+ readonly templateId: string;
78
15
  readonly slots: Readonly<Record<string, unknown>>;
79
16
  readonly output?: TOutput;
80
- readonly qualityTier?: SdkQualityTier;
81
- /**
82
- * Optional idempotency key. The Runtime forwards it to the
83
- * Gateway's `metadata.idempotencyKey`. Use case: a host that
84
- * wants to retry without double-billing.
85
- */
86
17
  readonly idempotencyKey?: string;
87
18
  };
88
19
 
89
20
  export type InferenceCallSuccess<TOutput extends ZodTypeAny> = {
90
- /** Raw model output (the Gateway's `output`). */
91
21
  readonly output: string;
92
- /** Parsed structured response. `null` if no schema was provided. */
93
22
  readonly structured: TOutput extends ZodTypeAny ? z.infer<TOutput> | null : null;
94
- /** Gateway usage block. Surface for debug; the operator-side
95
- * dashboards use the audit ledger instead. */
96
23
  readonly usage: {
97
24
  readonly model: string;
98
25
  readonly tokensIn: number;
@@ -103,8 +30,7 @@ export type InferenceCallSuccess<TOutput extends ZodTypeAny> = {
103
30
  };
104
31
 
105
32
  export type SerialisedInferenceRequest = {
106
- readonly callKind: SdkCallKind;
107
- readonly qualityTier: SdkQualityTier;
33
+ readonly templateId: string;
108
34
  readonly slots: Readonly<Record<string, unknown>>;
109
35
  readonly outputSchema: JsonSchema | undefined;
110
36
  readonly idempotencyKey: string | undefined;
@@ -112,37 +38,19 @@ export type SerialisedInferenceRequest = {
112
38
 
113
39
  export const INFERENCE_EVENT_PREFIX = 'inference.' as const;
114
40
 
115
- /**
116
- * Build the wire payload for an inference call. Surfaced as a pure
117
- * helper so the testkit can assert the serialised shape without
118
- * spinning a transport.
119
- */
120
41
  export const buildInferenceRequest = <TOutput extends ZodTypeAny>(
121
42
  input: InferenceCallInput<TOutput>,
122
43
  ): SerialisedInferenceRequest => ({
123
- callKind: input.callKind,
124
- qualityTier: input.qualityTier ?? 'standard',
44
+ templateId: input.templateId,
125
45
  slots: input.slots,
126
46
  outputSchema: input.output ? zodToJsonSchema(input.output) : undefined,
127
47
  idempotencyKey: input.idempotencyKey,
128
48
  });
129
49
 
130
- /**
131
- * The async `Session.inference` namespace. Each verb is bound by
132
- * `client.ts` to the live transport; the Runtime wires up the
133
- * server-side response under chunk B8a.
134
- */
50
+ export type SdkQualityTier = QualityTier;
51
+
135
52
  export type SessionInference = {
136
53
  readonly call: <TOutput extends ZodTypeAny = ZodTypeAny>(
137
54
  input: InferenceCallInput<TOutput>,
138
55
  ) => Promise<Result<InferenceCallSuccess<TOutput>, SdkError>>;
139
- readonly host: <TOutput extends ZodTypeAny = ZodTypeAny>(
140
- input: Omit<InferenceCallInput<TOutput>, 'callKind'>,
141
- ) => Promise<Result<InferenceCallSuccess<TOutput>, SdkError>>;
142
- readonly judge: <TOutput extends ZodTypeAny = ZodTypeAny>(
143
- input: Omit<InferenceCallInput<TOutput>, 'callKind'>,
144
- ) => Promise<Result<InferenceCallSuccess<TOutput>, SdkError>>;
145
- readonly classify: <TOutput extends ZodTypeAny = ZodTypeAny>(
146
- input: Omit<InferenceCallInput<TOutput>, 'callKind'>,
147
- ) => Promise<Result<InferenceCallSuccess<TOutput>, SdkError>>;
148
56
  };
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: () => nowFn() + skew,
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
@@ -47,6 +47,27 @@ export type SpeakSuccess = {
47
47
  readonly durationMs: number;
48
48
  };
49
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
+
50
71
  export type SessionVoice = {
51
72
  readonly speak: (input: SpeakInput) => Promise<Result<SpeakSuccess, SdkError>>;
52
73
  };