@warlock.js/ai-live 5.24.0 → 5.26.0

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
@@ -4,6 +4,18 @@ All notable changes to `@warlock.js/ai-live` are documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). `@warlock.js/*` packages are released in lockstep — every package shares the same version number, so a version below may list only the changes that affected this package.
6
6
 
7
+ ## 5.26.0 - 2026-09-30
8
+
9
+ ### Changed
10
+
11
+ - Lockstep release; package APIs are unchanged.
12
+
13
+ ## 5.25.0 - 2026-09-28
14
+
15
+ ### Changed
16
+
17
+ - Lockstep release; package APIs are unchanged.
18
+
7
19
  ## 5.24.0 - 2026-09-27
8
20
 
9
21
  ### Changed
package/llms-full.txt CHANGED
@@ -6,205 +6,205 @@
6
6
 
7
7
  ## use-ai-live `@warlock.js/ai-live/use-ai-live/SKILL.md`
8
8
 
9
- ---
10
- name: use-ai-live
11
- description: 'Live & generative rich-media add-on for @warlock.js/ai. A side-effect import `import "@warlock.js/ai-live"` mounts two heavy modalities onto the shared `ai.*` facade: `ai.video({ model, prompt })` — text-to-video (Sora / Veo / Kling-class), the async submit→poll job hidden behind the same uniform never-throws `{ data, error, usage, report }` envelope as `ai.image`, with per-second cost-truth folded into `Usage.cost`; and `ai.realtime({ transport, model })` — a stateful duplex VOICE SESSION over a pluggable `RealtimeTransport` (sendAudio / sendText / events() / close()→RealtimeReport). Videos are a discriminated `GeneratedVideo = { type: "url" } | { type: "base64" }`; session output is a `RealtimeEvent` union (audio | transcript | tool-call | error | done). Ships `MockVideoModel` + `MockRealtimeTransport` for HTTP-free tests. Triggers: `ai.video`, `ai.realtime`, `VideoModelContract`, `GeneratedVideo`, `VideoModelPricing`, `RealtimeSession`, `RealtimeTransport`, `RealtimeEvent`, `RealtimeReport`, `MockVideoModel`, `MockRealtimeTransport`; ''generate a video'', ''text to video'', ''sora'', ''veo'', ''realtime voice'', ''duplex voice session'', ''live voice agent'', ''barge-in'', ''per-second video cost''; typical import `import "@warlock.js/ai-live"` + `import { ai } from "@warlock.js/ai"`. Skip: still-image OUTPUT — [[generate-images]]; text-to-speech / transcription (one-shot audio, not a session) — [[generate-speech]]; chat agents / tools / workflows — `@warlock.js/ai/run-ai-agent/SKILL.md`.'
12
- ---
13
-
14
- # Live rich-media — `ai.video` + `ai.realtime` (`@warlock.js/ai-live`)
15
-
16
- `@warlock.js/ai` ships the synchronous modality verbs (`ai.agent`, `ai.image`, `ai.speech`, `ai.transcribe`). `ai-live` adds the two that need heavyweight, demand-gated machinery — a **long-running job** (video) and a **persistent network session** (realtime voice) — kept in their own package so the core stays dependency-light.
17
-
18
- Both mount onto the same `ai.*` facade by a **side-effect import**:
19
-
20
- ```ts
21
- import "@warlock.js/ai-live"; // lights up ai.video + ai.realtime on the shared Ai facade
22
- import { ai } from "@warlock.js/ai";
23
- ```
24
-
25
- The mount mirrors how `@warlock.js/ai-tools` mounts `ai.mcp` and `@warlock.js/ai-workspace` mounts `ai.workspace`: a `declare module "@warlock.js/ai"` interface-merge plus `ai.video = video; ai.realtime = realtime` at load. Forget the side-effect import and `ai.video` is a compile-time `undefined` — not a silent runtime miss.
26
-
27
- ## `ai.video` — text-to-video, uniform envelope
28
-
29
- Prompt in / one video out. The adapter hides the provider's async **submit→poll** lifecycle and resolves only when the clip is ready, so the verb returns the framework's uniform **never-throws** `{ data, error, usage, report }` — the exact shape `ai.image` returns, so video spend and traces fold into the same dashboards.
30
-
31
- ```ts
32
- import { ai } from "@warlock.js/ai";
33
-
34
- const { data, error, usage, report } = await ai.video({
35
- model: sora.video({ name: "sora-2", pricing: { perSecond: 0.1 } }), // VideoModelContract
36
- prompt: "a timelapse of a city skyline at dusk, cinematic",
37
- durationSeconds: 8,
38
- aspectRatio: "16:9",
39
- });
40
-
41
- if (error) {
42
- console.warn(error.code); // typed AIError — NEVER thrown
43
- } else {
44
- const clip = data.video; // GeneratedVideo (discriminated)
45
- if (clip.type === "url") download(clip.url);
46
- else save(Buffer.from(clip.base64, "base64"), clip.mediaType);
47
- }
48
- ```
49
-
50
- ### Shape
51
-
52
- ```ts
53
- type VideoResult = ExecuteResult<VideoData> & { type: "video"; report: VideoReport };
54
- type VideoData = { video: GeneratedVideo };
55
-
56
- type GeneratedVideo =
57
- | { type: "url"; url: string; mediaType?: string }
58
- | { type: "base64"; base64: string; mediaType: string };
59
-
60
- // VideoParams (provider-neutral) — the model is the only required field beyond prompt:
61
- await ai.video({
62
- model, // VideoModelContract from an adapter's video({ name })
63
- prompt: "...",
64
- durationSeconds: 8, // requested clip length
65
- aspectRatio: "9:16",
66
- resolution: "1080p", // hint
67
- negativePrompt: "blurry, watermark",
68
- signal, // AbortSignal → status "cancelled"
69
- observe: collector, // route the report to an Observer (panoptic), like agents
70
- sessionId: "campaign-42",
71
- name: "hero-clip", // report node name (defaults to "video")
72
- options: { seed: 7 }, // provider-specific escape hatch, forwarded verbatim
73
- });
74
- ```
75
-
76
- `VideoModelContract` is the moving-image sibling of `ImageModelContract` — `{ name, provider, pricing?, generate(prompt, options) }` — produced by an adapter's `video()` factory. An adapter with no video API simply doesn't define `video()`.
77
-
78
- ## Video cost-truth — per-second first
79
-
80
- `VideoModelPricing` carries `{ perSecond?, input?, output? }`. Per-second wins when set (video is metered by clip length): `usage.cost = { input: 0, output: durationSeconds × perSecond }`, attributed to `cost.output`. A token-metered model falls back to the standard `computeCost` against reported tokens. No usable pricing → `usage.cost` stays `undefined` (honest "cost unknown", never a false zero). A pre-priced adapter response is honored, not overwritten. The final `durationSeconds` from the provider (not just the requested one) drives the math and lands on `report.durationSeconds`.
81
-
82
- ## `ai.realtime` — a duplex voice SESSION (not a one-shot)
83
-
84
- Unlike every other `ai.*` verb, `ai.realtime()` returns a **stateful, long-lived session** — its closest sibling is `ai.orchestrator`. You open it over a pluggable **`RealtimeTransport`** (the low-level connection to the provider's realtime endpoint), push microphone audio / text turns in, consume an async **event stream** out, and `close()` to end it and receive a `RealtimeReport` for the cost/observability surfaces.
85
-
86
- ```ts
87
- import { ai } from "@warlock.js/ai";
88
-
89
- const session = await ai.realtime({
90
- transport: openAiRealtime({ apiKey }), // RealtimeTransport (own adapter)
91
- model: "gpt-realtime",
92
- voice: "alloy",
93
- instructions: "You are a friendly phone receptionist.",
94
- sessionId: "call-8891",
95
- });
96
-
97
- session.sendAudio(micChunkBase64, "audio/pcm"); // push mic audio upstream
98
- session.sendText("Please hold for one moment."); // or a text turn
99
-
100
- for await (const event of session.events()) { // duplex output
101
- switch (event.type) {
102
- case "audio": speaker.write(event.base64); break;
103
- case "transcript": if (event.final) log(event.role, event.text); break;
104
- case "tool-call": await handleTool(event.name, event.input); break;
105
- case "error": console.warn(event.error.code); break;
106
- case "done": break;
107
- }
108
- }
109
-
110
- const report = await session.close(); // RealtimeReport — idempotent
111
- ```
112
-
113
- ### Session contract & event union
114
-
115
- ```ts
116
- interface RealtimeSession {
117
- sendAudio(base64: string, mediaType: string): void;
118
- sendText(text: string): void;
119
- events(): AsyncIterable<RealtimeEvent>;
120
- close(): Promise<RealtimeReport>; // idempotent — second close() returns the first report
121
- }
122
-
123
- type RealtimeEvent =
124
- | { type: "audio"; base64: string; mediaType: string }
125
- | { type: "transcript"; role: "user" | "assistant"; text: string; final: boolean }
126
- | { type: "tool-call"; id: string; name: string; input: unknown }
127
- | { type: "error"; error: AIError } // typed, non-fatal
128
- | { type: "done" }; // server ended the session
129
-
130
- type RealtimeReport = {
131
- runId: string; rootRunId: string; type: "realtime";
132
- name: string; status: "completed" | "failed" | "cancelled";
133
- startedAt: string; endedAt: string; duration: number; sessionId?: string;
134
- };
135
- ```
136
-
137
- The transport seam is what lets `ai-live` ship the session surface without hard-wiring `ws` (it's an optional peer). A concrete transport implements just two interfaces: `RealtimeTransport.connect(config) → RealtimeConnection` (with `sendAudio` / `sendText` / `events()` / `close()`).
138
-
139
- ## Pattern — a live phone receptionist over a channel
140
-
141
- ```ts
142
- import "@warlock.js/ai-live";
143
- import { ai } from "@warlock.js/ai";
144
-
145
- async function handleCall(caller: PhoneChannel, transport: RealtimeTransport) {
146
- const session = await ai.realtime({
147
- transport,
148
- model: "gpt-realtime",
149
- voice: "alloy",
150
- instructions: "Greet the caller and route them to the right department.",
151
- sessionId: caller.id,
152
- });
153
-
154
- // Pump caller audio → session (fire-and-forget).
155
- caller.onAudio((chunk) => session.sendAudio(chunk, "audio/pcm"));
156
-
157
- // Pump session output → caller, until the model signals done.
158
- for await (const event of session.events()) {
159
- if (event.type === "audio") caller.playAudio(event.base64);
160
- if (event.type === "done") break;
161
- }
162
-
163
- const report = await session.close();
164
- metrics.record({ runId: report.runId, seconds: report.duration / 1000 });
165
- }
166
- ```
167
-
168
- ## Testing — mocks, no HTTP, no sockets
169
-
170
- `@warlock.js/ai-live` exports deterministic doubles so both verbs test offline.
171
-
172
- ```ts
173
- import { ai } from "@warlock.js/ai";
174
- import { MockVideoModel, MockRealtimeTransport } from "@warlock.js/ai-live";
175
-
176
- // Video — script the returned clip / usage / duration; assert cost math + recorded calls.
177
- const model = new MockVideoModel("mock-video", [{ durationSeconds: 8 }], { perSecond: 0.1 });
178
- const { data, usage } = await ai.video({ model, prompt: "x", durationSeconds: 8 });
179
- // data.video → { type: "url", url: "https://mock/video.mp4", ... }
180
- // usage.cost.output === 0.8 (8 × 0.1)
181
- // model.calls[0] → { prompt: "x", options: { durationSeconds: 8, ... } }
182
-
183
- // Realtime — script the outbound event stream; assert what the session sent.
184
- const transport = new MockRealtimeTransport([
185
- { type: "transcript", role: "assistant", text: "Hi!", final: true },
186
- { type: "done" },
187
- ]);
188
- const session = await ai.realtime({ transport, model: "mock-realtime" });
189
- session.sendText("hello");
190
- for await (const e of session.events()) { /* ... */ }
191
- const report = await session.close();
192
- // transport.lastConnection.sentText → ["hello"]
193
- // transport.connectConfigs[0] → { model: "mock-realtime", ... }
194
- // report.status === "completed"
195
- ```
196
-
197
- Feed `MockVideoModel` an `{ error }` response and `ai.video` lands it on `result.error` (never throws), setting `report.status` to `"failed"` (or `"cancelled"` when the signal aborted).
198
-
199
- ## Status — contracts now, transports next
200
-
201
- `4.6.0` introduces the package: the `VideoModelContract` / `RealtimeSession` contracts, the `ai.video()` verb (uniform envelope + per-second cost-truth, tested against `MockVideoModel`), and the `ai.realtime()` session primitive over a pluggable transport (tested against `MockRealtimeTransport`). The first **concrete provider transports** — an OpenAI Realtime **WebSocket** for `ai.realtime`, and **Sora / Veo** video adapters for `ai.video` — are the next implementation step; the seams are defined so they drop in without changing the verb surface.
202
-
203
- ## See also
204
-
205
- - [[generate-images]] — still-image OUTPUT (`ai.image`), the synchronous sibling `ai.video` mirrors.
206
- - [[generate-speech]] — one-shot text-to-speech / transcription (`ai.speech` / `ai.transcribe`); use `ai.realtime` instead when you need a live duplex conversation, not a single audio render.
207
- - `@warlock.js/ai/observe-ai-flows/SKILL.md` — the `observe` seam both verbs route their reports through.
208
- - `@warlock.js/ai/run-orchestrator/SKILL.md` — the other session-shaped primitive `ai.realtime` resembles.
9
+ ---
10
+ name: use-ai-live
11
+ description: 'Live & generative rich-media add-on for @warlock.js/ai. A side-effect import `import "@warlock.js/ai-live"` mounts two heavy modalities onto the shared `ai.*` facade: `ai.video({ model, prompt })` — text-to-video (Sora / Veo / Kling-class), the async submit→poll job hidden behind the same uniform never-throws `{ data, error, usage, report }` envelope as `ai.image`, with per-second cost-truth folded into `Usage.cost`; and `ai.realtime({ transport, model })` — a stateful duplex VOICE SESSION over a pluggable `RealtimeTransport` (sendAudio / sendText / events() / close()→RealtimeReport). Videos are a discriminated `GeneratedVideo = { type: "url" } | { type: "base64" }`; session output is a `RealtimeEvent` union (audio | transcript | tool-call | error | done). Ships `MockVideoModel` + `MockRealtimeTransport` for HTTP-free tests. Triggers: `ai.video`, `ai.realtime`, `VideoModelContract`, `GeneratedVideo`, `VideoModelPricing`, `RealtimeSession`, `RealtimeTransport`, `RealtimeEvent`, `RealtimeReport`, `MockVideoModel`, `MockRealtimeTransport`; ''generate a video'', ''text to video'', ''sora'', ''veo'', ''realtime voice'', ''duplex voice session'', ''live voice agent'', ''barge-in'', ''per-second video cost''; typical import `import "@warlock.js/ai-live"` + `import { ai } from "@warlock.js/ai"`. Skip: still-image OUTPUT — [[generate-images]]; text-to-speech / transcription (one-shot audio, not a session) — [[generate-speech]]; chat agents / tools / workflows — `@warlock.js/ai/run-ai-agent/SKILL.md`.'
12
+ ---
13
+
14
+ # Live rich-media — `ai.video` + `ai.realtime` (`@warlock.js/ai-live`)
15
+
16
+ `@warlock.js/ai` ships the synchronous modality verbs (`ai.agent`, `ai.image`, `ai.speech`, `ai.transcribe`). `ai-live` adds the two that need heavyweight, demand-gated machinery — a **long-running job** (video) and a **persistent network session** (realtime voice) — kept in their own package so the core stays dependency-light.
17
+
18
+ Both mount onto the same `ai.*` facade by a **side-effect import**:
19
+
20
+ ```ts
21
+ import "@warlock.js/ai-live"; // lights up ai.video + ai.realtime on the shared Ai facade
22
+ import { ai } from "@warlock.js/ai";
23
+ ```
24
+
25
+ The mount mirrors how `@warlock.js/ai-tools` mounts `ai.mcp` and `@warlock.js/ai-workspace` mounts `ai.workspace`: a `declare module "@warlock.js/ai"` interface-merge plus `ai.video = video; ai.realtime = realtime` at load. Forget the side-effect import and `ai.video` is a compile-time `undefined` — not a silent runtime miss.
26
+
27
+ ## `ai.video` — text-to-video, uniform envelope
28
+
29
+ Prompt in / one video out. The adapter hides the provider's async **submit→poll** lifecycle and resolves only when the clip is ready, so the verb returns the framework's uniform **never-throws** `{ data, error, usage, report }` — the exact shape `ai.image` returns, so video spend and traces fold into the same dashboards.
30
+
31
+ ```ts
32
+ import { ai } from "@warlock.js/ai";
33
+
34
+ const { data, error, usage, report } = await ai.video({
35
+ model: sora.video({ name: "sora-2", pricing: { perSecond: 0.1 } }), // VideoModelContract
36
+ prompt: "a timelapse of a city skyline at dusk, cinematic",
37
+ durationSeconds: 8,
38
+ aspectRatio: "16:9",
39
+ });
40
+
41
+ if (error) {
42
+ console.warn(error.code); // typed AIError — NEVER thrown
43
+ } else {
44
+ const clip = data.video; // GeneratedVideo (discriminated)
45
+ if (clip.type === "url") download(clip.url);
46
+ else save(Buffer.from(clip.base64, "base64"), clip.mediaType);
47
+ }
48
+ ```
49
+
50
+ ### Shape
51
+
52
+ ```ts
53
+ type VideoResult = ExecuteResult<VideoData> & { type: "video"; report: VideoReport };
54
+ type VideoData = { video: GeneratedVideo };
55
+
56
+ type GeneratedVideo =
57
+ | { type: "url"; url: string; mediaType?: string }
58
+ | { type: "base64"; base64: string; mediaType: string };
59
+
60
+ // VideoParams (provider-neutral) — the model is the only required field beyond prompt:
61
+ await ai.video({
62
+ model, // VideoModelContract from an adapter's video({ name })
63
+ prompt: "...",
64
+ durationSeconds: 8, // requested clip length
65
+ aspectRatio: "9:16",
66
+ resolution: "1080p", // hint
67
+ negativePrompt: "blurry, watermark",
68
+ signal, // AbortSignal → status "cancelled"
69
+ observe: collector, // route the report to an Observer (panoptic), like agents
70
+ sessionId: "campaign-42",
71
+ name: "hero-clip", // report node name (defaults to "video")
72
+ options: { seed: 7 }, // provider-specific escape hatch, forwarded verbatim
73
+ });
74
+ ```
75
+
76
+ `VideoModelContract` is the moving-image sibling of `ImageModelContract` — `{ name, provider, pricing?, generate(prompt, options) }` — produced by an adapter's `video()` factory. An adapter with no video API simply doesn't define `video()`.
77
+
78
+ ## Video cost-truth — per-second first
79
+
80
+ `VideoModelPricing` carries `{ perSecond?, input?, output? }`. Per-second wins when set (video is metered by clip length): `usage.cost = { input: 0, output: durationSeconds × perSecond }`, attributed to `cost.output`. A token-metered model falls back to the standard `computeCost` against reported tokens. No usable pricing → `usage.cost` stays `undefined` (honest "cost unknown", never a false zero). A pre-priced adapter response is honored, not overwritten. The final `durationSeconds` from the provider (not just the requested one) drives the math and lands on `report.durationSeconds`.
81
+
82
+ ## `ai.realtime` — a duplex voice SESSION (not a one-shot)
83
+
84
+ Unlike every other `ai.*` verb, `ai.realtime()` returns a **stateful, long-lived session** — its closest sibling is `ai.orchestrator`. You open it over a pluggable **`RealtimeTransport`** (the low-level connection to the provider's realtime endpoint), push microphone audio / text turns in, consume an async **event stream** out, and `close()` to end it and receive a `RealtimeReport` for the cost/observability surfaces.
85
+
86
+ ```ts
87
+ import { ai } from "@warlock.js/ai";
88
+
89
+ const session = await ai.realtime({
90
+ transport: openAiRealtime({ apiKey }), // RealtimeTransport (own adapter)
91
+ model: "gpt-realtime",
92
+ voice: "alloy",
93
+ instructions: "You are a friendly phone receptionist.",
94
+ sessionId: "call-8891",
95
+ });
96
+
97
+ session.sendAudio(micChunkBase64, "audio/pcm"); // push mic audio upstream
98
+ session.sendText("Please hold for one moment."); // or a text turn
99
+
100
+ for await (const event of session.events()) { // duplex output
101
+ switch (event.type) {
102
+ case "audio": speaker.write(event.base64); break;
103
+ case "transcript": if (event.final) log(event.role, event.text); break;
104
+ case "tool-call": await handleTool(event.name, event.input); break;
105
+ case "error": console.warn(event.error.code); break;
106
+ case "done": break;
107
+ }
108
+ }
109
+
110
+ const report = await session.close(); // RealtimeReport — idempotent
111
+ ```
112
+
113
+ ### Session contract & event union
114
+
115
+ ```ts
116
+ interface RealtimeSession {
117
+ sendAudio(base64: string, mediaType: string): void;
118
+ sendText(text: string): void;
119
+ events(): AsyncIterable<RealtimeEvent>;
120
+ close(): Promise<RealtimeReport>; // idempotent — second close() returns the first report
121
+ }
122
+
123
+ type RealtimeEvent =
124
+ | { type: "audio"; base64: string; mediaType: string }
125
+ | { type: "transcript"; role: "user" | "assistant"; text: string; final: boolean }
126
+ | { type: "tool-call"; id: string; name: string; input: unknown }
127
+ | { type: "error"; error: AIError } // typed, non-fatal
128
+ | { type: "done" }; // server ended the session
129
+
130
+ type RealtimeReport = {
131
+ runId: string; rootRunId: string; type: "realtime";
132
+ name: string; status: "completed" | "failed" | "cancelled";
133
+ startedAt: string; endedAt: string; duration: number; sessionId?: string;
134
+ };
135
+ ```
136
+
137
+ The transport seam is what lets `ai-live` ship the session surface without hard-wiring `ws` (it's an optional peer). A concrete transport implements just two interfaces: `RealtimeTransport.connect(config) → RealtimeConnection` (with `sendAudio` / `sendText` / `events()` / `close()`).
138
+
139
+ ## Pattern — a live phone receptionist over a channel
140
+
141
+ ```ts
142
+ import "@warlock.js/ai-live";
143
+ import { ai } from "@warlock.js/ai";
144
+
145
+ async function handleCall(caller: PhoneChannel, transport: RealtimeTransport) {
146
+ const session = await ai.realtime({
147
+ transport,
148
+ model: "gpt-realtime",
149
+ voice: "alloy",
150
+ instructions: "Greet the caller and route them to the right department.",
151
+ sessionId: caller.id,
152
+ });
153
+
154
+ // Pump caller audio → session (fire-and-forget).
155
+ caller.onAudio((chunk) => session.sendAudio(chunk, "audio/pcm"));
156
+
157
+ // Pump session output → caller, until the model signals done.
158
+ for await (const event of session.events()) {
159
+ if (event.type === "audio") caller.playAudio(event.base64);
160
+ if (event.type === "done") break;
161
+ }
162
+
163
+ const report = await session.close();
164
+ metrics.record({ runId: report.runId, seconds: report.duration / 1000 });
165
+ }
166
+ ```
167
+
168
+ ## Testing — mocks, no HTTP, no sockets
169
+
170
+ `@warlock.js/ai-live` exports deterministic doubles so both verbs test offline.
171
+
172
+ ```ts
173
+ import { ai } from "@warlock.js/ai";
174
+ import { MockVideoModel, MockRealtimeTransport } from "@warlock.js/ai-live";
175
+
176
+ // Video — script the returned clip / usage / duration; assert cost math + recorded calls.
177
+ const model = new MockVideoModel("mock-video", [{ durationSeconds: 8 }], { perSecond: 0.1 });
178
+ const { data, usage } = await ai.video({ model, prompt: "x", durationSeconds: 8 });
179
+ // data.video → { type: "url", url: "https://mock/video.mp4", ... }
180
+ // usage.cost.output === 0.8 (8 × 0.1)
181
+ // model.calls[0] → { prompt: "x", options: { durationSeconds: 8, ... } }
182
+
183
+ // Realtime — script the outbound event stream; assert what the session sent.
184
+ const transport = new MockRealtimeTransport([
185
+ { type: "transcript", role: "assistant", text: "Hi!", final: true },
186
+ { type: "done" },
187
+ ]);
188
+ const session = await ai.realtime({ transport, model: "mock-realtime" });
189
+ session.sendText("hello");
190
+ for await (const e of session.events()) { /* ... */ }
191
+ const report = await session.close();
192
+ // transport.lastConnection.sentText → ["hello"]
193
+ // transport.connectConfigs[0] → { model: "mock-realtime", ... }
194
+ // report.status === "completed"
195
+ ```
196
+
197
+ Feed `MockVideoModel` an `{ error }` response and `ai.video` lands it on `result.error` (never throws), setting `report.status` to `"failed"` (or `"cancelled"` when the signal aborted).
198
+
199
+ ## Status — contracts now, transports next
200
+
201
+ `4.6.0` introduces the package: the `VideoModelContract` / `RealtimeSession` contracts, the `ai.video()` verb (uniform envelope + per-second cost-truth, tested against `MockVideoModel`), and the `ai.realtime()` session primitive over a pluggable transport (tested against `MockRealtimeTransport`). The first **concrete provider transports** — an OpenAI Realtime **WebSocket** for `ai.realtime`, and **Sora / Veo** video adapters for `ai.video` — are the next implementation step; the seams are defined so they drop in without changing the verb surface.
202
+
203
+ ## See also
204
+
205
+ - [[generate-images]] — still-image OUTPUT (`ai.image`), the synchronous sibling `ai.video` mirrors.
206
+ - [[generate-speech]] — one-shot text-to-speech / transcription (`ai.speech` / `ai.transcribe`); use `ai.realtime` instead when you need a live duplex conversation, not a single audio render.
207
+ - `@warlock.js/ai/observe-ai-flows/SKILL.md` — the `observe` seam both verbs route their reports through.
208
+ - `@warlock.js/ai/run-orchestrator/SKILL.md` — the other session-shaped primitive `ai.realtime` resembles.
209
209
 
210
210
 
package/package.json CHANGED
@@ -10,17 +10,17 @@
10
10
  ],
11
11
  "license": "MIT",
12
12
  "dependencies": {
13
- "@warlock.js/logger": "5.24.0"
13
+ "@warlock.js/logger": "5.26.0"
14
14
  },
15
15
  "peerDependencies": {
16
- "@warlock.js/ai": "5.24.0"
16
+ "@warlock.js/ai": "5.26.0"
17
17
  },
18
18
  "peerDependenciesMeta": {
19
19
  "ws": {
20
20
  "optional": true
21
21
  }
22
22
  },
23
- "version": "5.24.0",
23
+ "version": "5.26.0",
24
24
  "main": "./cjs/index.cjs",
25
25
  "module": "./esm/index.mjs",
26
26
  "types": "./esm/index.d.mts",
@@ -1,200 +1,200 @@
1
- ---
2
- name: use-ai-live
3
- description: 'Live & generative rich-media add-on for @warlock.js/ai. A side-effect import `import "@warlock.js/ai-live"` mounts two heavy modalities onto the shared `ai.*` facade: `ai.video({ model, prompt })` — text-to-video (Sora / Veo / Kling-class), the async submit→poll job hidden behind the same uniform never-throws `{ data, error, usage, report }` envelope as `ai.image`, with per-second cost-truth folded into `Usage.cost`; and `ai.realtime({ transport, model })` — a stateful duplex VOICE SESSION over a pluggable `RealtimeTransport` (sendAudio / sendText / events() / close()→RealtimeReport). Videos are a discriminated `GeneratedVideo = { type: "url" } | { type: "base64" }`; session output is a `RealtimeEvent` union (audio | transcript | tool-call | error | done). Ships `MockVideoModel` + `MockRealtimeTransport` for HTTP-free tests. Triggers: `ai.video`, `ai.realtime`, `VideoModelContract`, `GeneratedVideo`, `VideoModelPricing`, `RealtimeSession`, `RealtimeTransport`, `RealtimeEvent`, `RealtimeReport`, `MockVideoModel`, `MockRealtimeTransport`; ''generate a video'', ''text to video'', ''sora'', ''veo'', ''realtime voice'', ''duplex voice session'', ''live voice agent'', ''barge-in'', ''per-second video cost''; typical import `import "@warlock.js/ai-live"` + `import { ai } from "@warlock.js/ai"`. Skip: still-image OUTPUT — [[generate-images]]; text-to-speech / transcription (one-shot audio, not a session) — [[generate-speech]]; chat agents / tools / workflows — `@warlock.js/ai/run-ai-agent/SKILL.md`.'
4
- ---
5
-
6
- # Live rich-media — `ai.video` + `ai.realtime` (`@warlock.js/ai-live`)
7
-
8
- `@warlock.js/ai` ships the synchronous modality verbs (`ai.agent`, `ai.image`, `ai.speech`, `ai.transcribe`). `ai-live` adds the two that need heavyweight, demand-gated machinery — a **long-running job** (video) and a **persistent network session** (realtime voice) — kept in their own package so the core stays dependency-light.
9
-
10
- Both mount onto the same `ai.*` facade by a **side-effect import**:
11
-
12
- ```ts
13
- import "@warlock.js/ai-live"; // lights up ai.video + ai.realtime on the shared Ai facade
14
- import { ai } from "@warlock.js/ai";
15
- ```
16
-
17
- The mount mirrors how `@warlock.js/ai-tools` mounts `ai.mcp` and `@warlock.js/ai-workspace` mounts `ai.workspace`: a `declare module "@warlock.js/ai"` interface-merge plus `ai.video = video; ai.realtime = realtime` at load. Forget the side-effect import and `ai.video` is a compile-time `undefined` — not a silent runtime miss.
18
-
19
- ## `ai.video` — text-to-video, uniform envelope
20
-
21
- Prompt in / one video out. The adapter hides the provider's async **submit→poll** lifecycle and resolves only when the clip is ready, so the verb returns the framework's uniform **never-throws** `{ data, error, usage, report }` — the exact shape `ai.image` returns, so video spend and traces fold into the same dashboards.
22
-
23
- ```ts
24
- import { ai } from "@warlock.js/ai";
25
-
26
- const { data, error, usage, report } = await ai.video({
27
- model: sora.video({ name: "sora-2", pricing: { perSecond: 0.1 } }), // VideoModelContract
28
- prompt: "a timelapse of a city skyline at dusk, cinematic",
29
- durationSeconds: 8,
30
- aspectRatio: "16:9",
31
- });
32
-
33
- if (error) {
34
- console.warn(error.code); // typed AIError — NEVER thrown
35
- } else {
36
- const clip = data.video; // GeneratedVideo (discriminated)
37
- if (clip.type === "url") download(clip.url);
38
- else save(Buffer.from(clip.base64, "base64"), clip.mediaType);
39
- }
40
- ```
41
-
42
- ### Shape
43
-
44
- ```ts
45
- type VideoResult = ExecuteResult<VideoData> & { type: "video"; report: VideoReport };
46
- type VideoData = { video: GeneratedVideo };
47
-
48
- type GeneratedVideo =
49
- | { type: "url"; url: string; mediaType?: string }
50
- | { type: "base64"; base64: string; mediaType: string };
51
-
52
- // VideoParams (provider-neutral) — the model is the only required field beyond prompt:
53
- await ai.video({
54
- model, // VideoModelContract from an adapter's video({ name })
55
- prompt: "...",
56
- durationSeconds: 8, // requested clip length
57
- aspectRatio: "9:16",
58
- resolution: "1080p", // hint
59
- negativePrompt: "blurry, watermark",
60
- signal, // AbortSignal → status "cancelled"
61
- observe: collector, // route the report to an Observer (panoptic), like agents
62
- sessionId: "campaign-42",
63
- name: "hero-clip", // report node name (defaults to "video")
64
- options: { seed: 7 }, // provider-specific escape hatch, forwarded verbatim
65
- });
66
- ```
67
-
68
- `VideoModelContract` is the moving-image sibling of `ImageModelContract` — `{ name, provider, pricing?, generate(prompt, options) }` — produced by an adapter's `video()` factory. An adapter with no video API simply doesn't define `video()`.
69
-
70
- ## Video cost-truth — per-second first
71
-
72
- `VideoModelPricing` carries `{ perSecond?, input?, output? }`. Per-second wins when set (video is metered by clip length): `usage.cost = { input: 0, output: durationSeconds × perSecond }`, attributed to `cost.output`. A token-metered model falls back to the standard `computeCost` against reported tokens. No usable pricing → `usage.cost` stays `undefined` (honest "cost unknown", never a false zero). A pre-priced adapter response is honored, not overwritten. The final `durationSeconds` from the provider (not just the requested one) drives the math and lands on `report.durationSeconds`.
73
-
74
- ## `ai.realtime` — a duplex voice SESSION (not a one-shot)
75
-
76
- Unlike every other `ai.*` verb, `ai.realtime()` returns a **stateful, long-lived session** — its closest sibling is `ai.orchestrator`. You open it over a pluggable **`RealtimeTransport`** (the low-level connection to the provider's realtime endpoint), push microphone audio / text turns in, consume an async **event stream** out, and `close()` to end it and receive a `RealtimeReport` for the cost/observability surfaces.
77
-
78
- ```ts
79
- import { ai } from "@warlock.js/ai";
80
-
81
- const session = await ai.realtime({
82
- transport: openAiRealtime({ apiKey }), // RealtimeTransport (own adapter)
83
- model: "gpt-realtime",
84
- voice: "alloy",
85
- instructions: "You are a friendly phone receptionist.",
86
- sessionId: "call-8891",
87
- });
88
-
89
- session.sendAudio(micChunkBase64, "audio/pcm"); // push mic audio upstream
90
- session.sendText("Please hold for one moment."); // or a text turn
91
-
92
- for await (const event of session.events()) { // duplex output
93
- switch (event.type) {
94
- case "audio": speaker.write(event.base64); break;
95
- case "transcript": if (event.final) log(event.role, event.text); break;
96
- case "tool-call": await handleTool(event.name, event.input); break;
97
- case "error": console.warn(event.error.code); break;
98
- case "done": break;
99
- }
100
- }
101
-
102
- const report = await session.close(); // RealtimeReport — idempotent
103
- ```
104
-
105
- ### Session contract & event union
106
-
107
- ```ts
108
- interface RealtimeSession {
109
- sendAudio(base64: string, mediaType: string): void;
110
- sendText(text: string): void;
111
- events(): AsyncIterable<RealtimeEvent>;
112
- close(): Promise<RealtimeReport>; // idempotent — second close() returns the first report
113
- }
114
-
115
- type RealtimeEvent =
116
- | { type: "audio"; base64: string; mediaType: string }
117
- | { type: "transcript"; role: "user" | "assistant"; text: string; final: boolean }
118
- | { type: "tool-call"; id: string; name: string; input: unknown }
119
- | { type: "error"; error: AIError } // typed, non-fatal
120
- | { type: "done" }; // server ended the session
121
-
122
- type RealtimeReport = {
123
- runId: string; rootRunId: string; type: "realtime";
124
- name: string; status: "completed" | "failed" | "cancelled";
125
- startedAt: string; endedAt: string; duration: number; sessionId?: string;
126
- };
127
- ```
128
-
129
- The transport seam is what lets `ai-live` ship the session surface without hard-wiring `ws` (it's an optional peer). A concrete transport implements just two interfaces: `RealtimeTransport.connect(config) → RealtimeConnection` (with `sendAudio` / `sendText` / `events()` / `close()`).
130
-
131
- ## Pattern — a live phone receptionist over a channel
132
-
133
- ```ts
134
- import "@warlock.js/ai-live";
135
- import { ai } from "@warlock.js/ai";
136
-
137
- async function handleCall(caller: PhoneChannel, transport: RealtimeTransport) {
138
- const session = await ai.realtime({
139
- transport,
140
- model: "gpt-realtime",
141
- voice: "alloy",
142
- instructions: "Greet the caller and route them to the right department.",
143
- sessionId: caller.id,
144
- });
145
-
146
- // Pump caller audio → session (fire-and-forget).
147
- caller.onAudio((chunk) => session.sendAudio(chunk, "audio/pcm"));
148
-
149
- // Pump session output → caller, until the model signals done.
150
- for await (const event of session.events()) {
151
- if (event.type === "audio") caller.playAudio(event.base64);
152
- if (event.type === "done") break;
153
- }
154
-
155
- const report = await session.close();
156
- metrics.record({ runId: report.runId, seconds: report.duration / 1000 });
157
- }
158
- ```
159
-
160
- ## Testing — mocks, no HTTP, no sockets
161
-
162
- `@warlock.js/ai-live` exports deterministic doubles so both verbs test offline.
163
-
164
- ```ts
165
- import { ai } from "@warlock.js/ai";
166
- import { MockVideoModel, MockRealtimeTransport } from "@warlock.js/ai-live";
167
-
168
- // Video — script the returned clip / usage / duration; assert cost math + recorded calls.
169
- const model = new MockVideoModel("mock-video", [{ durationSeconds: 8 }], { perSecond: 0.1 });
170
- const { data, usage } = await ai.video({ model, prompt: "x", durationSeconds: 8 });
171
- // data.video → { type: "url", url: "https://mock/video.mp4", ... }
172
- // usage.cost.output === 0.8 (8 × 0.1)
173
- // model.calls[0] → { prompt: "x", options: { durationSeconds: 8, ... } }
174
-
175
- // Realtime — script the outbound event stream; assert what the session sent.
176
- const transport = new MockRealtimeTransport([
177
- { type: "transcript", role: "assistant", text: "Hi!", final: true },
178
- { type: "done" },
179
- ]);
180
- const session = await ai.realtime({ transport, model: "mock-realtime" });
181
- session.sendText("hello");
182
- for await (const e of session.events()) { /* ... */ }
183
- const report = await session.close();
184
- // transport.lastConnection.sentText → ["hello"]
185
- // transport.connectConfigs[0] → { model: "mock-realtime", ... }
186
- // report.status === "completed"
187
- ```
188
-
189
- Feed `MockVideoModel` an `{ error }` response and `ai.video` lands it on `result.error` (never throws), setting `report.status` to `"failed"` (or `"cancelled"` when the signal aborted).
190
-
191
- ## Status — contracts now, transports next
192
-
193
- `4.6.0` introduces the package: the `VideoModelContract` / `RealtimeSession` contracts, the `ai.video()` verb (uniform envelope + per-second cost-truth, tested against `MockVideoModel`), and the `ai.realtime()` session primitive over a pluggable transport (tested against `MockRealtimeTransport`). The first **concrete provider transports** — an OpenAI Realtime **WebSocket** for `ai.realtime`, and **Sora / Veo** video adapters for `ai.video` — are the next implementation step; the seams are defined so they drop in without changing the verb surface.
194
-
195
- ## See also
196
-
197
- - [[generate-images]] — still-image OUTPUT (`ai.image`), the synchronous sibling `ai.video` mirrors.
198
- - [[generate-speech]] — one-shot text-to-speech / transcription (`ai.speech` / `ai.transcribe`); use `ai.realtime` instead when you need a live duplex conversation, not a single audio render.
199
- - `@warlock.js/ai/observe-ai-flows/SKILL.md` — the `observe` seam both verbs route their reports through.
200
- - `@warlock.js/ai/run-orchestrator/SKILL.md` — the other session-shaped primitive `ai.realtime` resembles.
1
+ ---
2
+ name: use-ai-live
3
+ description: 'Live & generative rich-media add-on for @warlock.js/ai. A side-effect import `import "@warlock.js/ai-live"` mounts two heavy modalities onto the shared `ai.*` facade: `ai.video({ model, prompt })` — text-to-video (Sora / Veo / Kling-class), the async submit→poll job hidden behind the same uniform never-throws `{ data, error, usage, report }` envelope as `ai.image`, with per-second cost-truth folded into `Usage.cost`; and `ai.realtime({ transport, model })` — a stateful duplex VOICE SESSION over a pluggable `RealtimeTransport` (sendAudio / sendText / events() / close()→RealtimeReport). Videos are a discriminated `GeneratedVideo = { type: "url" } | { type: "base64" }`; session output is a `RealtimeEvent` union (audio | transcript | tool-call | error | done). Ships `MockVideoModel` + `MockRealtimeTransport` for HTTP-free tests. Triggers: `ai.video`, `ai.realtime`, `VideoModelContract`, `GeneratedVideo`, `VideoModelPricing`, `RealtimeSession`, `RealtimeTransport`, `RealtimeEvent`, `RealtimeReport`, `MockVideoModel`, `MockRealtimeTransport`; ''generate a video'', ''text to video'', ''sora'', ''veo'', ''realtime voice'', ''duplex voice session'', ''live voice agent'', ''barge-in'', ''per-second video cost''; typical import `import "@warlock.js/ai-live"` + `import { ai } from "@warlock.js/ai"`. Skip: still-image OUTPUT — [[generate-images]]; text-to-speech / transcription (one-shot audio, not a session) — [[generate-speech]]; chat agents / tools / workflows — `@warlock.js/ai/run-ai-agent/SKILL.md`.'
4
+ ---
5
+
6
+ # Live rich-media — `ai.video` + `ai.realtime` (`@warlock.js/ai-live`)
7
+
8
+ `@warlock.js/ai` ships the synchronous modality verbs (`ai.agent`, `ai.image`, `ai.speech`, `ai.transcribe`). `ai-live` adds the two that need heavyweight, demand-gated machinery — a **long-running job** (video) and a **persistent network session** (realtime voice) — kept in their own package so the core stays dependency-light.
9
+
10
+ Both mount onto the same `ai.*` facade by a **side-effect import**:
11
+
12
+ ```ts
13
+ import "@warlock.js/ai-live"; // lights up ai.video + ai.realtime on the shared Ai facade
14
+ import { ai } from "@warlock.js/ai";
15
+ ```
16
+
17
+ The mount mirrors how `@warlock.js/ai-tools` mounts `ai.mcp` and `@warlock.js/ai-workspace` mounts `ai.workspace`: a `declare module "@warlock.js/ai"` interface-merge plus `ai.video = video; ai.realtime = realtime` at load. Forget the side-effect import and `ai.video` is a compile-time `undefined` — not a silent runtime miss.
18
+
19
+ ## `ai.video` — text-to-video, uniform envelope
20
+
21
+ Prompt in / one video out. The adapter hides the provider's async **submit→poll** lifecycle and resolves only when the clip is ready, so the verb returns the framework's uniform **never-throws** `{ data, error, usage, report }` — the exact shape `ai.image` returns, so video spend and traces fold into the same dashboards.
22
+
23
+ ```ts
24
+ import { ai } from "@warlock.js/ai";
25
+
26
+ const { data, error, usage, report } = await ai.video({
27
+ model: sora.video({ name: "sora-2", pricing: { perSecond: 0.1 } }), // VideoModelContract
28
+ prompt: "a timelapse of a city skyline at dusk, cinematic",
29
+ durationSeconds: 8,
30
+ aspectRatio: "16:9",
31
+ });
32
+
33
+ if (error) {
34
+ console.warn(error.code); // typed AIError — NEVER thrown
35
+ } else {
36
+ const clip = data.video; // GeneratedVideo (discriminated)
37
+ if (clip.type === "url") download(clip.url);
38
+ else save(Buffer.from(clip.base64, "base64"), clip.mediaType);
39
+ }
40
+ ```
41
+
42
+ ### Shape
43
+
44
+ ```ts
45
+ type VideoResult = ExecuteResult<VideoData> & { type: "video"; report: VideoReport };
46
+ type VideoData = { video: GeneratedVideo };
47
+
48
+ type GeneratedVideo =
49
+ | { type: "url"; url: string; mediaType?: string }
50
+ | { type: "base64"; base64: string; mediaType: string };
51
+
52
+ // VideoParams (provider-neutral) — the model is the only required field beyond prompt:
53
+ await ai.video({
54
+ model, // VideoModelContract from an adapter's video({ name })
55
+ prompt: "...",
56
+ durationSeconds: 8, // requested clip length
57
+ aspectRatio: "9:16",
58
+ resolution: "1080p", // hint
59
+ negativePrompt: "blurry, watermark",
60
+ signal, // AbortSignal → status "cancelled"
61
+ observe: collector, // route the report to an Observer (panoptic), like agents
62
+ sessionId: "campaign-42",
63
+ name: "hero-clip", // report node name (defaults to "video")
64
+ options: { seed: 7 }, // provider-specific escape hatch, forwarded verbatim
65
+ });
66
+ ```
67
+
68
+ `VideoModelContract` is the moving-image sibling of `ImageModelContract` — `{ name, provider, pricing?, generate(prompt, options) }` — produced by an adapter's `video()` factory. An adapter with no video API simply doesn't define `video()`.
69
+
70
+ ## Video cost-truth — per-second first
71
+
72
+ `VideoModelPricing` carries `{ perSecond?, input?, output? }`. Per-second wins when set (video is metered by clip length): `usage.cost = { input: 0, output: durationSeconds × perSecond }`, attributed to `cost.output`. A token-metered model falls back to the standard `computeCost` against reported tokens. No usable pricing → `usage.cost` stays `undefined` (honest "cost unknown", never a false zero). A pre-priced adapter response is honored, not overwritten. The final `durationSeconds` from the provider (not just the requested one) drives the math and lands on `report.durationSeconds`.
73
+
74
+ ## `ai.realtime` — a duplex voice SESSION (not a one-shot)
75
+
76
+ Unlike every other `ai.*` verb, `ai.realtime()` returns a **stateful, long-lived session** — its closest sibling is `ai.orchestrator`. You open it over a pluggable **`RealtimeTransport`** (the low-level connection to the provider's realtime endpoint), push microphone audio / text turns in, consume an async **event stream** out, and `close()` to end it and receive a `RealtimeReport` for the cost/observability surfaces.
77
+
78
+ ```ts
79
+ import { ai } from "@warlock.js/ai";
80
+
81
+ const session = await ai.realtime({
82
+ transport: openAiRealtime({ apiKey }), // RealtimeTransport (own adapter)
83
+ model: "gpt-realtime",
84
+ voice: "alloy",
85
+ instructions: "You are a friendly phone receptionist.",
86
+ sessionId: "call-8891",
87
+ });
88
+
89
+ session.sendAudio(micChunkBase64, "audio/pcm"); // push mic audio upstream
90
+ session.sendText("Please hold for one moment."); // or a text turn
91
+
92
+ for await (const event of session.events()) { // duplex output
93
+ switch (event.type) {
94
+ case "audio": speaker.write(event.base64); break;
95
+ case "transcript": if (event.final) log(event.role, event.text); break;
96
+ case "tool-call": await handleTool(event.name, event.input); break;
97
+ case "error": console.warn(event.error.code); break;
98
+ case "done": break;
99
+ }
100
+ }
101
+
102
+ const report = await session.close(); // RealtimeReport — idempotent
103
+ ```
104
+
105
+ ### Session contract & event union
106
+
107
+ ```ts
108
+ interface RealtimeSession {
109
+ sendAudio(base64: string, mediaType: string): void;
110
+ sendText(text: string): void;
111
+ events(): AsyncIterable<RealtimeEvent>;
112
+ close(): Promise<RealtimeReport>; // idempotent — second close() returns the first report
113
+ }
114
+
115
+ type RealtimeEvent =
116
+ | { type: "audio"; base64: string; mediaType: string }
117
+ | { type: "transcript"; role: "user" | "assistant"; text: string; final: boolean }
118
+ | { type: "tool-call"; id: string; name: string; input: unknown }
119
+ | { type: "error"; error: AIError } // typed, non-fatal
120
+ | { type: "done" }; // server ended the session
121
+
122
+ type RealtimeReport = {
123
+ runId: string; rootRunId: string; type: "realtime";
124
+ name: string; status: "completed" | "failed" | "cancelled";
125
+ startedAt: string; endedAt: string; duration: number; sessionId?: string;
126
+ };
127
+ ```
128
+
129
+ The transport seam is what lets `ai-live` ship the session surface without hard-wiring `ws` (it's an optional peer). A concrete transport implements just two interfaces: `RealtimeTransport.connect(config) → RealtimeConnection` (with `sendAudio` / `sendText` / `events()` / `close()`).
130
+
131
+ ## Pattern — a live phone receptionist over a channel
132
+
133
+ ```ts
134
+ import "@warlock.js/ai-live";
135
+ import { ai } from "@warlock.js/ai";
136
+
137
+ async function handleCall(caller: PhoneChannel, transport: RealtimeTransport) {
138
+ const session = await ai.realtime({
139
+ transport,
140
+ model: "gpt-realtime",
141
+ voice: "alloy",
142
+ instructions: "Greet the caller and route them to the right department.",
143
+ sessionId: caller.id,
144
+ });
145
+
146
+ // Pump caller audio → session (fire-and-forget).
147
+ caller.onAudio((chunk) => session.sendAudio(chunk, "audio/pcm"));
148
+
149
+ // Pump session output → caller, until the model signals done.
150
+ for await (const event of session.events()) {
151
+ if (event.type === "audio") caller.playAudio(event.base64);
152
+ if (event.type === "done") break;
153
+ }
154
+
155
+ const report = await session.close();
156
+ metrics.record({ runId: report.runId, seconds: report.duration / 1000 });
157
+ }
158
+ ```
159
+
160
+ ## Testing — mocks, no HTTP, no sockets
161
+
162
+ `@warlock.js/ai-live` exports deterministic doubles so both verbs test offline.
163
+
164
+ ```ts
165
+ import { ai } from "@warlock.js/ai";
166
+ import { MockVideoModel, MockRealtimeTransport } from "@warlock.js/ai-live";
167
+
168
+ // Video — script the returned clip / usage / duration; assert cost math + recorded calls.
169
+ const model = new MockVideoModel("mock-video", [{ durationSeconds: 8 }], { perSecond: 0.1 });
170
+ const { data, usage } = await ai.video({ model, prompt: "x", durationSeconds: 8 });
171
+ // data.video → { type: "url", url: "https://mock/video.mp4", ... }
172
+ // usage.cost.output === 0.8 (8 × 0.1)
173
+ // model.calls[0] → { prompt: "x", options: { durationSeconds: 8, ... } }
174
+
175
+ // Realtime — script the outbound event stream; assert what the session sent.
176
+ const transport = new MockRealtimeTransport([
177
+ { type: "transcript", role: "assistant", text: "Hi!", final: true },
178
+ { type: "done" },
179
+ ]);
180
+ const session = await ai.realtime({ transport, model: "mock-realtime" });
181
+ session.sendText("hello");
182
+ for await (const e of session.events()) { /* ... */ }
183
+ const report = await session.close();
184
+ // transport.lastConnection.sentText → ["hello"]
185
+ // transport.connectConfigs[0] → { model: "mock-realtime", ... }
186
+ // report.status === "completed"
187
+ ```
188
+
189
+ Feed `MockVideoModel` an `{ error }` response and `ai.video` lands it on `result.error` (never throws), setting `report.status` to `"failed"` (or `"cancelled"` when the signal aborted).
190
+
191
+ ## Status — contracts now, transports next
192
+
193
+ `4.6.0` introduces the package: the `VideoModelContract` / `RealtimeSession` contracts, the `ai.video()` verb (uniform envelope + per-second cost-truth, tested against `MockVideoModel`), and the `ai.realtime()` session primitive over a pluggable transport (tested against `MockRealtimeTransport`). The first **concrete provider transports** — an OpenAI Realtime **WebSocket** for `ai.realtime`, and **Sora / Veo** video adapters for `ai.video` — are the next implementation step; the seams are defined so they drop in without changing the verb surface.
194
+
195
+ ## See also
196
+
197
+ - [[generate-images]] — still-image OUTPUT (`ai.image`), the synchronous sibling `ai.video` mirrors.
198
+ - [[generate-speech]] — one-shot text-to-speech / transcription (`ai.speech` / `ai.transcribe`); use `ai.realtime` instead when you need a live duplex conversation, not a single audio render.
199
+ - `@warlock.js/ai/observe-ai-flows/SKILL.md` — the `observe` seam both verbs route their reports through.
200
+ - `@warlock.js/ai/run-orchestrator/SKILL.md` — the other session-shaped primitive `ai.realtime` resembles.