@memberjunction/ai-inworld 0.0.1 → 5.42.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/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/inworldRealtime.d.ts +514 -0
- package/dist/inworldRealtime.d.ts.map +1 -0
- package/dist/inworldRealtime.js +849 -0
- package/dist/inworldRealtime.js.map +1 -0
- package/package.json +26 -7
- package/README.md +0 -45
|
@@ -0,0 +1,849 @@
|
|
|
1
|
+
// Inworld Realtime API — realtime (voice) driver.
|
|
2
|
+
//
|
|
3
|
+
// Inworld's Realtime API is a single-WebSocket, full-duplex speech-to-speech stack. A session is
|
|
4
|
+
// configured with a `session.update` frame (model selection, instructions, audio settings, STT/TTS
|
|
5
|
+
// config, tools, and turn-taking with semantic-VAD eagerness); components are swappable mid-session
|
|
6
|
+
// without reconnect. Input is streamed audio (the server runs STT with voice profiling); output is
|
|
7
|
+
// synthesized speech (Realtime TTS-2) that supports inline steering tags such as `[laugh]`. Inworld
|
|
8
|
+
// brokers many underlying LLMs, selected via `modelId` (e.g. `anthropic/claude-sonnet-4-6`).
|
|
9
|
+
//
|
|
10
|
+
// ── WIRE-FORMAT BINDING POINTS ──
|
|
11
|
+
// We cannot live-test against an Inworld endpoint, so every place where the EXACT wire framing of a
|
|
12
|
+
// message is not fully nailed down from public docs is implemented against the documented protocol
|
|
13
|
+
// SHAPE (session.update init / full-duplex base64 audio / fluent tool calling / semantic VAD) and
|
|
14
|
+
// isolated in a single clearly-named private helper carrying a `@remarks Wire-format binding point`
|
|
15
|
+
// JSDoc note. Each such helper is the one place to adjust when validating against a live endpoint.
|
|
16
|
+
// The helpers are:
|
|
17
|
+
// - {@link InworldRealtimeSession.buildSessionUpdateFrame} (session.update payload shape)
|
|
18
|
+
// - {@link InworldRealtimeSession.buildAudioAppendFrame} (input audio-append frame)
|
|
19
|
+
// - {@link InworldRealtimeSession.buildToolResultFrame} (tool-result frame + name/keys)
|
|
20
|
+
// - {@link InworldRealtimeSession.buildResponseCreateFrame} (instructed one-off response)
|
|
21
|
+
// - {@link InworldRealtimeSession.buildContextItemFrame} (non-interrupting context item)
|
|
22
|
+
// - {@link InworldRealtimeSession.classifyServerEvent} (inbound type → semantic kind)
|
|
23
|
+
var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
|
|
24
|
+
var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
|
|
25
|
+
if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
|
|
26
|
+
else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r;
|
|
27
|
+
return c > 3 && r && Object.defineProperty(target, key, r), r;
|
|
28
|
+
};
|
|
29
|
+
import { BaseRealtimeModel, } from '@memberjunction/ai';
|
|
30
|
+
import { RegisterClass } from '@memberjunction/global';
|
|
31
|
+
/** The Inworld Realtime API WebSocket endpoint. Auth rides as a `?token=` query parameter. */
|
|
32
|
+
export const INWORLD_REALTIME_WS_URL = 'wss://api.inworld.ai/v1/realtime';
|
|
33
|
+
/**
|
|
34
|
+
* Default underlying LLM Inworld brokers when {@link RealtimeSessionParams.Model} is empty. Inworld
|
|
35
|
+
* selects the reasoning model via `modelId` (e.g. `anthropic/claude-sonnet-4-6`); the STT engine and
|
|
36
|
+
* TTS voice are configured independently in the audio block.
|
|
37
|
+
*/
|
|
38
|
+
export const INWORLD_DEFAULT_MODEL_ID = 'anthropic/claude-sonnet-4-6';
|
|
39
|
+
/**
|
|
40
|
+
* Default semantic-VAD eagerness applied to turn-taking when the caller supplies none. Inworld's
|
|
41
|
+
* turn detection is semantic VAD with adjustable eagerness; `medium` balances responsiveness against
|
|
42
|
+
* premature turn-ends. Overridable via `Config.turn_detection.eagerness` (or the whole block).
|
|
43
|
+
*/
|
|
44
|
+
export const INWORLD_DEFAULT_VAD_EAGERNESS = 'medium';
|
|
45
|
+
/**
|
|
46
|
+
* Real-time, full-duplex driver for the **Inworld Realtime API**, implementing the Core
|
|
47
|
+
* {@link BaseRealtimeModel} primitive. Registers as `InworldRealtime` and is resolved for
|
|
48
|
+
* `MJ: AI Models` typed `Realtime` (API-key env alias: `AI_VENDOR_API_KEY__InworldRealtime`).
|
|
49
|
+
*
|
|
50
|
+
* **What the provider is:** a single-WebSocket speech-to-speech stack. Sessions initialize with a
|
|
51
|
+
* `session.update` frame carrying model selection, instructions, audio settings, STT/TTS config, and
|
|
52
|
+
* tools; components are swappable mid-session without reconnect. Input is streamed audio (server-side
|
|
53
|
+
* STT with voice profiling); output is synthesized speech (Realtime TTS-2) supporting inline steering
|
|
54
|
+
* tags like `[laugh]`. Turn-taking is semantic VAD with adjustable eagerness, handling barge-in.
|
|
55
|
+
*
|
|
56
|
+
* **Model resolution:** Inworld brokers hundreds of LLMs; the reasoning model is selected via
|
|
57
|
+
* `modelId` (e.g. `anthropic/claude-sonnet-4-6`) carried from {@link RealtimeSessionParams.Model}
|
|
58
|
+
* (falling back to {@link INWORLD_DEFAULT_MODEL_ID}). STT engine and TTS voice are configured
|
|
59
|
+
* independently via `Config`.
|
|
60
|
+
*
|
|
61
|
+
* **Topology:** server-bridged only ({@link StartSession}) — the driver opens the WebSocket, sends the
|
|
62
|
+
* session config, and resolves only once the provider confirms the config is applied (driver
|
|
63
|
+
* obligation #7). Inworld does not expose a documented ephemeral-token mint for browser-direct
|
|
64
|
+
* sessions, so {@link SupportsClientDirect} stays `false` (inherited).
|
|
65
|
+
*
|
|
66
|
+
* **Tool calling:** "fluent tool calling" — functions declared at startup (or added mid-session via
|
|
67
|
+
* {@link IRealtimeSession.RegisterTools}) and executed mid-conversation; results fed back via
|
|
68
|
+
* {@link IRealtimeSession.SendToolResult} complete the loop.
|
|
69
|
+
*/
|
|
70
|
+
let InworldRealtime = class InworldRealtime extends BaseRealtimeModel {
|
|
71
|
+
/**
|
|
72
|
+
* Opens a server-bridged session: connects the realtime WebSocket authenticated with the API
|
|
73
|
+
* key, sends the full session config as the FIRST frame (`session.update` — model, instructions,
|
|
74
|
+
* audio/STT/TTS, tools, semantic-VAD turn-taking), and resolves only once the provider's
|
|
75
|
+
* session-ready confirmation arrives (driver obligation #7 — "ready only after the config is
|
|
76
|
+
* applied"). Mic frames sent before that would be dropped by the provider, and `StartSession`
|
|
77
|
+
* not resolving until ready makes that unrepresentable for consumers.
|
|
78
|
+
*
|
|
79
|
+
* @param params Session configuration (model, system prompt, tools, initial context, config bag).
|
|
80
|
+
* @returns A promise resolving to the live {@link IRealtimeSession} handle, post-ready.
|
|
81
|
+
*/
|
|
82
|
+
async StartSession(params) {
|
|
83
|
+
const session = new InworldRealtimeSession(params);
|
|
84
|
+
const socket = await this.connectRealtimeSocket({
|
|
85
|
+
Url: this.buildConnectUrl(),
|
|
86
|
+
OnMessage: (event) => session.HandleServerEvent(event),
|
|
87
|
+
OnError: (message) => session.HandleTransportError(message),
|
|
88
|
+
OnClose: (code, reason) => session.HandleTransportClose(code, reason),
|
|
89
|
+
});
|
|
90
|
+
session.AttachSocket(socket);
|
|
91
|
+
session.SendSessionUpdate();
|
|
92
|
+
await session.WaitForReady();
|
|
93
|
+
return session;
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Builds the authenticated WebSocket URL. Auth rides as a `?token=` query parameter (the
|
|
97
|
+
* server-side API key for the server-bridged topology).
|
|
98
|
+
*
|
|
99
|
+
* @returns The full `wss://…?token=…` connect URL.
|
|
100
|
+
* @remarks Wire-format binding point — confirm the auth query-parameter name (`token`) against a
|
|
101
|
+
* live Inworld endpoint; some deployments authenticate via an `Authorization` header instead.
|
|
102
|
+
*/
|
|
103
|
+
buildConnectUrl() {
|
|
104
|
+
return `${INWORLD_REALTIME_WS_URL}?token=${encodeURIComponent(this.apiKey)}`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Transport seam for the realtime WebSocket. Production speaks the raw Inworld Realtime protocol
|
|
108
|
+
* over the platform-global `WebSocket` (browsers / Node 22+) and resolves once the socket is
|
|
109
|
+
* OPEN. Unit tests override this to return an in-memory fake — no network.
|
|
110
|
+
*
|
|
111
|
+
* @param args The authenticated URL plus inbound-message / error / close callbacks.
|
|
112
|
+
* @returns A promise resolving to the connected {@link InworldRealtimeSocket} once open.
|
|
113
|
+
*/
|
|
114
|
+
async connectRealtimeSocket(args) {
|
|
115
|
+
const WS = globalThis.WebSocket;
|
|
116
|
+
if (!WS) {
|
|
117
|
+
throw new Error('InworldRealtime.StartSession requires a global WebSocket (Node 22+ or a browser runtime).');
|
|
118
|
+
}
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const ws = new WS(args.Url);
|
|
121
|
+
let opened = false;
|
|
122
|
+
ws.onopen = () => {
|
|
123
|
+
opened = true;
|
|
124
|
+
resolve({ send: (data) => ws.send(data), close: () => ws.close() });
|
|
125
|
+
};
|
|
126
|
+
ws.onmessage = (event) => {
|
|
127
|
+
try {
|
|
128
|
+
args.OnMessage(JSON.parse(String(event.data)));
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
/* non-JSON frame — ignore */
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
ws.onerror = () => {
|
|
135
|
+
args.OnError('Inworld realtime websocket error');
|
|
136
|
+
if (!opened) {
|
|
137
|
+
reject(new Error('Inworld realtime websocket failed to open'));
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
ws.onclose = (event) => {
|
|
141
|
+
args.OnClose(event.code, event.reason);
|
|
142
|
+
if (!opened) {
|
|
143
|
+
reject(new Error('Inworld realtime websocket closed before opening'));
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Maps a Core {@link RealtimeToolDefinition} up to an Inworld realtime `function` tool schema —
|
|
150
|
+
* the shape Inworld's "fluent tool calling" `tools[]` slot accepts. Shared by the initial
|
|
151
|
+
* `session.update` and by mid-session {@link IRealtimeSession.RegisterTools} so both expose
|
|
152
|
+
* byte-for-byte identical tool schemas.
|
|
153
|
+
*
|
|
154
|
+
* @param tool The Core tool definition to map.
|
|
155
|
+
* @returns The Inworld realtime function-tool object.
|
|
156
|
+
*/
|
|
157
|
+
static MapToolToFunction(tool) {
|
|
158
|
+
return {
|
|
159
|
+
type: 'function',
|
|
160
|
+
name: tool.Name,
|
|
161
|
+
description: tool.Description,
|
|
162
|
+
// The Core ParametersSchema is a JSON-schema object — the same shape Inworld's
|
|
163
|
+
// `tools[].parameters` slot accepts.
|
|
164
|
+
parameters: tool.ParametersSchema,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Canonical, order-insensitive fingerprint of a tool set (same scheme as the OpenAI / Gemini /
|
|
169
|
+
* AssemblyAI realtime drivers) — used by {@link InworldRealtimeSession.RegisterTools} to no-op
|
|
170
|
+
* identical re-registrations per the contract's idempotency rule.
|
|
171
|
+
*
|
|
172
|
+
* @param tools The tool set to fingerprint.
|
|
173
|
+
* @returns A stable string fingerprint independent of tool ordering.
|
|
174
|
+
*/
|
|
175
|
+
static ToolSetFingerprint(tools) {
|
|
176
|
+
return JSON.stringify([...tools]
|
|
177
|
+
.sort((a, b) => a.Name.localeCompare(b.Name))
|
|
178
|
+
.map((t) => ({ Name: t.Name, Description: t.Description, ParametersSchema: t.ParametersSchema })));
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
InworldRealtime = __decorate([
|
|
182
|
+
RegisterClass(BaseRealtimeModel, 'InworldRealtime')
|
|
183
|
+
], InworldRealtime);
|
|
184
|
+
export { InworldRealtime };
|
|
185
|
+
/**
|
|
186
|
+
* Concrete {@link IRealtimeSession} backed by a raw Inworld Realtime WebSocket.
|
|
187
|
+
*
|
|
188
|
+
* Owns the inbound translation (Inworld wire events → Core events) and the outbound translation
|
|
189
|
+
* (Core calls → wire frames). Created by {@link InworldRealtime.StartSession}; never instantiated
|
|
190
|
+
* directly by consumers.
|
|
191
|
+
*
|
|
192
|
+
* Provider-behavior notes (the contract deltas a consumer should know):
|
|
193
|
+
* - **`session.update` is the universal config channel.** Model, instructions, audio/STT/TTS, tools,
|
|
194
|
+
* and turn-taking are all carried by `session.update` — at connect time AND mid-session. Components
|
|
195
|
+
* are swappable without reconnect, so {@link RegisterTools} and {@link SendContextNote} are native
|
|
196
|
+
* config writes (never interrupting generation).
|
|
197
|
+
* - **Semantic VAD owns turn detection / barge-in.** A raw "speech started" is NOT an interruption;
|
|
198
|
+
* the provider's true-barge-in signal (it tracks its own output emission) is surfaced only when it
|
|
199
|
+
* cuts off an ACTIVE response, gated on {@link responseActive} per the base contract.
|
|
200
|
+
* - **Output supports inline steering tags** (e.g. `[laugh]`) — passed through verbatim in
|
|
201
|
+
* instructions; the driver does not parse or strip them.
|
|
202
|
+
* - **Tool results must reach the model.** `SendToolResult` is sent immediately (the provider owns the
|
|
203
|
+
* spoken continuation) and marks a response active eagerly so a queued narration can't slip ahead.
|
|
204
|
+
* - **{@link RequestSpokenUpdate} queues behind an in-flight response** per the collision rule; a
|
|
205
|
+
* `tool.call` clears the busy flag WITHOUT draining the queue (deadlock guard, obligation #2).
|
|
206
|
+
*/
|
|
207
|
+
export class InworldRealtimeSession {
|
|
208
|
+
/**
|
|
209
|
+
* @param params The session parameters (model, system prompt, tools, initial context, config bag).
|
|
210
|
+
*/
|
|
211
|
+
constructor(params) {
|
|
212
|
+
this.socket = null;
|
|
213
|
+
this.outputHandler = null;
|
|
214
|
+
this.transcriptHandler = null;
|
|
215
|
+
this.toolCallHandler = null;
|
|
216
|
+
this.interruptionHandler = null;
|
|
217
|
+
this.usageHandler = null;
|
|
218
|
+
this.errorHandler = null;
|
|
219
|
+
this.closeHandler = null;
|
|
220
|
+
/** True once {@link Close} ran — an expected close must not surface as a fatal error. */
|
|
221
|
+
this.closedByConsumer = false;
|
|
222
|
+
this.resolveReady = null;
|
|
223
|
+
this.rejectReady = null;
|
|
224
|
+
this.readyReceived = false;
|
|
225
|
+
/** Accumulated {@link SendContextNote} texts, re-sent with the full prompt each time. */
|
|
226
|
+
this.contextNotes = [];
|
|
227
|
+
/**
|
|
228
|
+
* Whether a model response is currently in flight. Set when output audio / a response-started
|
|
229
|
+
* frame arrives (and eagerly when this session triggers its own response); cleared on a
|
|
230
|
+
* response-done frame; a tool-call clears it WITHOUT draining (deadlock guard). Consumed by
|
|
231
|
+
* {@link enqueueOrRun} so a native {@link RequestSpokenUpdate} never collides with an active
|
|
232
|
+
* response, and by {@link handleSpeechStarted} to gate true-barge-in.
|
|
233
|
+
*/
|
|
234
|
+
this.responseActive = false;
|
|
235
|
+
/** Sends deferred while a response is in flight; drained in order at the next boundary. */
|
|
236
|
+
this.queuedSends = [];
|
|
237
|
+
this.params = params;
|
|
238
|
+
this.basePrompt = InworldRealtimeSession.composeSystemPrompt(params.SystemPrompt, params.InitialContext);
|
|
239
|
+
this.currentToolsFingerprint = InworldRealtime.ToolSetFingerprint(params.Tools ?? []);
|
|
240
|
+
this.readyPromise = new Promise((resolve, reject) => {
|
|
241
|
+
this.resolveReady = resolve;
|
|
242
|
+
this.rejectReady = reject;
|
|
243
|
+
});
|
|
244
|
+
// The promise is always consumed by WaitForReady before any rejection can fire (StartSession
|
|
245
|
+
// awaits it immediately), but guard against unhandled-rejection noise if a transport error
|
|
246
|
+
// lands between construction and the await.
|
|
247
|
+
this.readyPromise.catch(() => undefined);
|
|
248
|
+
}
|
|
249
|
+
/** Binds the underlying socket. Called by the driver once the WebSocket is open. */
|
|
250
|
+
AttachSocket(socket) {
|
|
251
|
+
this.socket = socket;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Sends the initial `session.update` frame carrying the full server-authored session config
|
|
255
|
+
* (model, instructions, audio/STT/TTS, tools, semantic-VAD turn-taking). Always the FIRST client
|
|
256
|
+
* frame — the provider drops audio sent before the session is configured.
|
|
257
|
+
*/
|
|
258
|
+
SendSessionUpdate() {
|
|
259
|
+
this.sendFrame(this.buildSessionUpdateFrame(this.params.Tools ?? []));
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Resolves once the provider's session-ready confirmation arrives (its acknowledgment that the
|
|
263
|
+
* session config is applied); rejects if the transport dies first. Awaited by
|
|
264
|
+
* {@link InworldRealtime.StartSession} so the session is never handed to a consumer before it is
|
|
265
|
+
* actually configured (driver obligation #7).
|
|
266
|
+
*
|
|
267
|
+
* @returns A promise resolving on session-ready and rejecting on pre-ready transport death.
|
|
268
|
+
*/
|
|
269
|
+
WaitForReady() {
|
|
270
|
+
return this.readyPromise;
|
|
271
|
+
}
|
|
272
|
+
// ── IRealtimeSession outbound ──
|
|
273
|
+
/** @inheritdoc — streams one client media frame as a base64 input audio-append frame. */
|
|
274
|
+
SendInput(chunk) {
|
|
275
|
+
this.sendFrame(this.buildAudioAppendFrame(chunk));
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* @inheritdoc
|
|
279
|
+
*
|
|
280
|
+
* Inworld's `tools` are a MUTABLE `session.update` field (components are swappable mid-session
|
|
281
|
+
* without reconnect), so re-declaration is native: an identical set (order-insensitively) is a
|
|
282
|
+
* silent no-op per the contract's idempotency rule; a genuinely different set is applied to the
|
|
283
|
+
* live session immediately.
|
|
284
|
+
*
|
|
285
|
+
* @param tools The tools to expose to the model.
|
|
286
|
+
*/
|
|
287
|
+
async RegisterTools(tools) {
|
|
288
|
+
const fingerprint = InworldRealtime.ToolSetFingerprint(tools);
|
|
289
|
+
if (fingerprint === this.currentToolsFingerprint) {
|
|
290
|
+
return; // identical to the declared set — silent no-op (idempotency rule)
|
|
291
|
+
}
|
|
292
|
+
this.currentToolsFingerprint = fingerprint;
|
|
293
|
+
this.sendFrame(this.buildToolsUpdateFrame(tools));
|
|
294
|
+
}
|
|
295
|
+
/** @inheritdoc */
|
|
296
|
+
OnOutput(handler) {
|
|
297
|
+
this.outputHandler = handler;
|
|
298
|
+
}
|
|
299
|
+
/** @inheritdoc */
|
|
300
|
+
OnTranscript(handler) {
|
|
301
|
+
this.transcriptHandler = handler;
|
|
302
|
+
}
|
|
303
|
+
/** @inheritdoc */
|
|
304
|
+
OnToolCall(handler) {
|
|
305
|
+
this.toolCallHandler = handler;
|
|
306
|
+
}
|
|
307
|
+
/** @inheritdoc */
|
|
308
|
+
OnInterruption(handler) {
|
|
309
|
+
this.interruptionHandler = handler;
|
|
310
|
+
}
|
|
311
|
+
/** @inheritdoc */
|
|
312
|
+
OnUsage(handler) {
|
|
313
|
+
this.usageHandler = handler;
|
|
314
|
+
}
|
|
315
|
+
/** @inheritdoc */
|
|
316
|
+
OnError(handler) {
|
|
317
|
+
this.errorHandler = handler;
|
|
318
|
+
}
|
|
319
|
+
/** @inheritdoc */
|
|
320
|
+
OnClose(handler) {
|
|
321
|
+
this.closeHandler = handler;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* @inheritdoc
|
|
325
|
+
*
|
|
326
|
+
* Completes the tool-call loop: sends a tool-result frame correlated by `call_id`. Sent
|
|
327
|
+
* IMMEDIATELY (never queued) — the provider asked for it and owns the spoken continuation. The
|
|
328
|
+
* busy flag is set eagerly so a queued narration can't slip in before the spoken result (driver
|
|
329
|
+
* obligation #5 — the result must EVENTUALLY be voiced and never be dropped).
|
|
330
|
+
*
|
|
331
|
+
* @param callID The `CallID` from the originating {@link RealtimeToolCall}.
|
|
332
|
+
* @param output The tool's result as a JSON-stringified string.
|
|
333
|
+
*/
|
|
334
|
+
async SendToolResult(callID, output) {
|
|
335
|
+
this.sendFrame(this.buildToolResultFrame(callID, output));
|
|
336
|
+
this.responseActive = true; // the result's spoken continuation is imminent
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* @inheritdoc
|
|
340
|
+
*
|
|
341
|
+
* EMULATED via the mutable system prompt: Inworld has no purpose-built non-interrupting context
|
|
342
|
+
* channel, but `session.update` may rewrite instructions mid-session WITHOUT triggering
|
|
343
|
+
* generation. The note is appended under a "Background updates" heading and the full prompt is
|
|
344
|
+
* re-sent — a config write, so it never interrupts and is sent immediately even mid-response. The
|
|
345
|
+
* model sees the notes the next time it speaks.
|
|
346
|
+
*
|
|
347
|
+
* @param text The context note to append to the conversation.
|
|
348
|
+
*/
|
|
349
|
+
SendContextNote(text) {
|
|
350
|
+
this.contextNotes.push(text);
|
|
351
|
+
this.sendFrame(this.buildContextItemFrame(this.composePromptWithNotes()));
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* @inheritdoc
|
|
355
|
+
*
|
|
356
|
+
* Triggers ONE short spoken update via a response-create frame carrying per-response
|
|
357
|
+
* instructions. **Collision behavior: queue.** A response-create sent mid-response would collide
|
|
358
|
+
* with the in-flight generation (the provider rejects overlapping triggers), so the send is
|
|
359
|
+
* deferred until the active response completes and drained at the next boundary.
|
|
360
|
+
*
|
|
361
|
+
* @param instructions Instructions for the single spoken update.
|
|
362
|
+
*/
|
|
363
|
+
RequestSpokenUpdate(instructions) {
|
|
364
|
+
this.enqueueOrRun(() => {
|
|
365
|
+
this.responseActive = true; // the instructed response is now in flight
|
|
366
|
+
this.sendFrame(this.buildResponseCreateFrame(instructions));
|
|
367
|
+
});
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* @inheritdoc
|
|
371
|
+
*
|
|
372
|
+
* Closes the session: sends a session-close frame BEFORE closing the socket so the provider
|
|
373
|
+
* tears the session down promptly rather than holding it, then releases the socket and drops all
|
|
374
|
+
* handlers so no stale callback fires afterward.
|
|
375
|
+
*/
|
|
376
|
+
async Close() {
|
|
377
|
+
this.closedByConsumer = true;
|
|
378
|
+
this.failReadyWait('session closed by consumer before the session was ready');
|
|
379
|
+
if (this.socket) {
|
|
380
|
+
try {
|
|
381
|
+
this.sendFrame(this.buildSessionCloseFrame());
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
/* socket already dead — closing anyway */
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
this.socket?.close();
|
|
388
|
+
this.socket = null;
|
|
389
|
+
this.clearHandlers();
|
|
390
|
+
}
|
|
391
|
+
// ── Inbound event translation ──
|
|
392
|
+
/**
|
|
393
|
+
* Entry point for an inbound WebSocket frame. Classifies the raw frame to a semantic kind via
|
|
394
|
+
* {@link classifyServerEvent}, then routes to a focused per-concern handler so each translation
|
|
395
|
+
* unit stays small and testable.
|
|
396
|
+
*
|
|
397
|
+
* @param event The parsed Inworld server event.
|
|
398
|
+
*/
|
|
399
|
+
HandleServerEvent(event) {
|
|
400
|
+
switch (this.classifyServerEvent(event)) {
|
|
401
|
+
case 'ready':
|
|
402
|
+
return this.handleReady();
|
|
403
|
+
case 'config-applied':
|
|
404
|
+
return; // mid-session config-apply confirmation — nothing to surface
|
|
405
|
+
case 'output-audio':
|
|
406
|
+
return this.handleOutputAudio(event.audio);
|
|
407
|
+
case 'transcript-user-delta':
|
|
408
|
+
return this.emitTranscript('user', event.text, false);
|
|
409
|
+
case 'transcript-user-final':
|
|
410
|
+
return this.emitTranscript('user', event.text, true);
|
|
411
|
+
case 'transcript-assistant-delta':
|
|
412
|
+
return this.emitTranscript('assistant', event.text, false);
|
|
413
|
+
case 'transcript-assistant-final':
|
|
414
|
+
return this.emitTranscript('assistant', event.text, true);
|
|
415
|
+
case 'response-started':
|
|
416
|
+
this.responseActive = true;
|
|
417
|
+
return;
|
|
418
|
+
case 'response-done':
|
|
419
|
+
return this.completeResponse();
|
|
420
|
+
case 'tool-call':
|
|
421
|
+
return this.handleToolCall(event);
|
|
422
|
+
case 'speech-started':
|
|
423
|
+
return this.handleSpeechStarted();
|
|
424
|
+
case 'interruption':
|
|
425
|
+
return this.handleInterruption();
|
|
426
|
+
case 'usage':
|
|
427
|
+
return this.handleUsage(event);
|
|
428
|
+
case 'error':
|
|
429
|
+
return this.handleProviderError(event);
|
|
430
|
+
case 'ignore':
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
/** Resolves the ready wait so {@link InworldRealtime.StartSession} can hand back the session. */
|
|
435
|
+
handleReady() {
|
|
436
|
+
this.readyReceived = true;
|
|
437
|
+
this.resolveReady?.();
|
|
438
|
+
}
|
|
439
|
+
/** Decodes one base64 output-audio frame, marks a response active, and forwards the raw bytes. */
|
|
440
|
+
handleOutputAudio(audioBase64) {
|
|
441
|
+
if (!audioBase64) {
|
|
442
|
+
return;
|
|
443
|
+
}
|
|
444
|
+
this.responseActive = true;
|
|
445
|
+
this.outputHandler?.(InworldRealtimeSession.base64ToArrayBuffer(audioBase64));
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* Surfaces a `tool.call` to the consumer. The model has yielded the floor pending the result, so
|
|
449
|
+
* the busy flag is cleared (deadlock guard — driver obligation #2) WITHOUT draining the queue (a
|
|
450
|
+
* queued narration must not trigger a response between the tool call and its result; it drains at
|
|
451
|
+
* the next real response boundary). Inworld may emit `arguments` pre-parsed or as a JSON string;
|
|
452
|
+
* {@link normalizeToolArguments} normalizes both to the Core contract's JSON-string shape.
|
|
453
|
+
*
|
|
454
|
+
* @param event The inbound tool-call frame.
|
|
455
|
+
*/
|
|
456
|
+
handleToolCall(event) {
|
|
457
|
+
this.responseActive = false;
|
|
458
|
+
this.toolCallHandler?.({
|
|
459
|
+
CallID: event.call_id ?? '',
|
|
460
|
+
ToolName: event.name ?? '',
|
|
461
|
+
Arguments: InworldRealtimeSession.normalizeToolArguments(event.arguments),
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* A raw "speech started" frame. Per the base contract this is NOT itself an interruption — a user
|
|
466
|
+
* taking their normal turn while the model is idle must not be reported. It is surfaced as a
|
|
467
|
+
* true barge-in only when a model response is actually in flight (semantic VAD owns turn
|
|
468
|
+
* detection; {@link responseActive} is the server-bridged proxy for "model output in flight").
|
|
469
|
+
*/
|
|
470
|
+
handleSpeechStarted() {
|
|
471
|
+
if (!this.responseActive) {
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
this.handleInterruption();
|
|
475
|
+
}
|
|
476
|
+
/**
|
|
477
|
+
* Surfaces a TRUE barge-in: user speech that cut off active model output. Fires the interruption
|
|
478
|
+
* handler, then releases the floor and drains queued sends. (Reached either from an explicit
|
|
479
|
+
* provider interruption frame or from a `responseActive`-gated speech-started.)
|
|
480
|
+
*/
|
|
481
|
+
handleInterruption() {
|
|
482
|
+
this.interruptionHandler?.();
|
|
483
|
+
this.completeResponse();
|
|
484
|
+
}
|
|
485
|
+
/** Translates an incremental usage frame into a {@link RealtimeUsage} update. */
|
|
486
|
+
handleUsage(event) {
|
|
487
|
+
this.usageHandler?.({
|
|
488
|
+
InputTokens: event.input_tokens ?? 0,
|
|
489
|
+
OutputTokens: event.output_tokens ?? 0,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Classifies a provider error frame's fatality and forwards it. The provider's own `fatal` flag
|
|
494
|
+
* (when present) is authoritative — a fatal frame means credential/transport death (driver
|
|
495
|
+
* obligation #6) and the consumer should finalize; otherwise it is a recoverable error frame and
|
|
496
|
+
* the session stays open (`Fatal: false`).
|
|
497
|
+
*
|
|
498
|
+
* @param event The inbound error frame.
|
|
499
|
+
*/
|
|
500
|
+
handleProviderError(event) {
|
|
501
|
+
this.errorHandler?.({
|
|
502
|
+
Message: event.message ?? 'Inworld realtime session error',
|
|
503
|
+
Code: event.code,
|
|
504
|
+
Fatal: event.fatal === true,
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
// ── Transport lifecycle ──
|
|
508
|
+
/**
|
|
509
|
+
* Surfaces a WebSocket-level failure as a FATAL session error — the transport is gone, so the
|
|
510
|
+
* consumer should finalize cleanly instead of idling on a dead socket (driver obligation #6).
|
|
511
|
+
*
|
|
512
|
+
* @param message The transport error message.
|
|
513
|
+
*/
|
|
514
|
+
HandleTransportError(message) {
|
|
515
|
+
this.failReadyWait(message);
|
|
516
|
+
this.errorHandler?.({ Message: message, Fatal: true });
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Surfaces an UNEXPECTED socket close as a fatal error (expected closes — the consumer called
|
|
520
|
+
* {@link Close} — are silent). The provider hard-closes at token expiry and when it ends the
|
|
521
|
+
* session itself, so this is also how credential / session death reaches the consumer. The close
|
|
522
|
+
* handler fires after the error so consumers driving finalization from either signal converge.
|
|
523
|
+
*
|
|
524
|
+
* @param code Optional WebSocket close code.
|
|
525
|
+
* @param reason Optional WebSocket close reason.
|
|
526
|
+
*/
|
|
527
|
+
HandleTransportClose(code, reason) {
|
|
528
|
+
if (this.closedByConsumer) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const detail = [code != null ? `code ${code}` : null, reason || null].filter(Boolean).join(' — ');
|
|
532
|
+
const message = `Inworld realtime session closed unexpectedly${detail ? ` (${detail})` : ''}`;
|
|
533
|
+
this.failReadyWait(message);
|
|
534
|
+
this.errorHandler?.({ Message: message, Fatal: true });
|
|
535
|
+
this.closeHandler?.();
|
|
536
|
+
}
|
|
537
|
+
// ── Wire-format binding points (the one place to adjust per the live Inworld protocol) ──
|
|
538
|
+
/**
|
|
539
|
+
* Builds the initial `session.update` frame: model selection, instructions, audio settings, STT
|
|
540
|
+
* engine, TTS voice, tools, and semantic-VAD turn-taking with adjustable eagerness. Recognized
|
|
541
|
+
* `Config` keys pass through to their wire slots; the whole `Config` bag also spreads onto the
|
|
542
|
+
* session so a per-conversation override can replace any block.
|
|
543
|
+
*
|
|
544
|
+
* @param tools The tools to declare at connect time.
|
|
545
|
+
* @returns The `session.update` client frame.
|
|
546
|
+
* @remarks Wire-format binding point — the `session.update` envelope and the exact key names for
|
|
547
|
+
* `model` / `instructions` / `audio` / `voice` / `stt` / `turn_detection.eagerness` are mapped to
|
|
548
|
+
* Inworld's documented protocol shape; verify each key against a live Inworld endpoint.
|
|
549
|
+
*/
|
|
550
|
+
buildSessionUpdateFrame(tools) {
|
|
551
|
+
const config = this.params.Config ?? {};
|
|
552
|
+
const session = {
|
|
553
|
+
model: this.resolveModelId(),
|
|
554
|
+
instructions: this.composePromptWithNotes(),
|
|
555
|
+
// Inworld runs server-side STT (with voice profiling) on input and TTS-2 on output; the
|
|
556
|
+
// audio block configures both halves. Recognized Config keys map to their wire slots.
|
|
557
|
+
audio: this.buildAudioConfig(config),
|
|
558
|
+
turn_detection: this.buildTurnDetection(config),
|
|
559
|
+
};
|
|
560
|
+
if (tools.length > 0) {
|
|
561
|
+
session['tools'] = tools.map((tool) => InworldRealtime.MapToolToFunction(tool));
|
|
562
|
+
}
|
|
563
|
+
// Spread any caller-supplied raw overrides last so a per-conversation Config can replace a
|
|
564
|
+
// block above (e.g. a fully-specified `audio` object). Known shorthand keys consumed by
|
|
565
|
+
// buildAudioConfig/buildTurnDetection are stripped so they don't double-write.
|
|
566
|
+
this.applyRawConfigOverrides(session, config);
|
|
567
|
+
return { type: 'session.update', session };
|
|
568
|
+
}
|
|
569
|
+
/**
|
|
570
|
+
* Builds the audio block (input STT + output TTS voice). Recognized shorthand `Config` keys:
|
|
571
|
+
* `voice` → `output.voice`, `stt` → `input.model`, `language` → `input.language`.
|
|
572
|
+
*
|
|
573
|
+
* @param config The caller's config bag.
|
|
574
|
+
* @returns The wire `audio` config object.
|
|
575
|
+
* @remarks Wire-format binding point — the `audio.{input,output}` sub-shape is mapped to the
|
|
576
|
+
* documented STT-in / TTS-2-out description; verify exact key names against a live endpoint.
|
|
577
|
+
*/
|
|
578
|
+
buildAudioConfig(config) {
|
|
579
|
+
const input = {};
|
|
580
|
+
if (typeof config['stt'] === 'string') {
|
|
581
|
+
input['model'] = config['stt'];
|
|
582
|
+
}
|
|
583
|
+
if (typeof config['language'] === 'string') {
|
|
584
|
+
input['language'] = config['language'];
|
|
585
|
+
}
|
|
586
|
+
const output = {};
|
|
587
|
+
if (typeof config['voice'] === 'string') {
|
|
588
|
+
output['voice'] = config['voice'];
|
|
589
|
+
}
|
|
590
|
+
const audio = {};
|
|
591
|
+
if (Object.keys(input).length > 0) {
|
|
592
|
+
audio['input'] = input;
|
|
593
|
+
}
|
|
594
|
+
if (Object.keys(output).length > 0) {
|
|
595
|
+
audio['output'] = output;
|
|
596
|
+
}
|
|
597
|
+
return audio;
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Builds the semantic-VAD turn-detection block. If the caller supplies a full `turn_detection`
|
|
601
|
+
* object it is used verbatim; otherwise an `eagerness` shorthand (or the default) is applied.
|
|
602
|
+
*
|
|
603
|
+
* @param config The caller's config bag.
|
|
604
|
+
* @returns The wire `turn_detection` object.
|
|
605
|
+
* @remarks Wire-format binding point — `turn_detection.type = 'semantic_vad'` and the `eagerness`
|
|
606
|
+
* field are mapped to the documented "semantic VAD with adjustable eagerness"; verify exact key
|
|
607
|
+
* names / allowed values against a live endpoint.
|
|
608
|
+
*/
|
|
609
|
+
buildTurnDetection(config) {
|
|
610
|
+
const supplied = config['turn_detection'];
|
|
611
|
+
if (supplied !== null && typeof supplied === 'object' && !Array.isArray(supplied)) {
|
|
612
|
+
return supplied;
|
|
613
|
+
}
|
|
614
|
+
const eagerness = typeof config['eagerness'] === 'string' ? config['eagerness'] : INWORLD_DEFAULT_VAD_EAGERNESS;
|
|
615
|
+
return { type: 'semantic_vad', eagerness };
|
|
616
|
+
}
|
|
617
|
+
/**
|
|
618
|
+
* Spreads caller-supplied raw `Config` overrides onto the session object, skipping the shorthand
|
|
619
|
+
* keys already consumed by {@link buildAudioConfig} / {@link buildTurnDetection} so they don't
|
|
620
|
+
* double-write. Lets a per-conversation config replace a whole block (e.g. a fully-specified
|
|
621
|
+
* `audio` object) while shorthands stay convenient.
|
|
622
|
+
*
|
|
623
|
+
* @param session The session object being built (mutated in place).
|
|
624
|
+
* @param config The caller's config bag.
|
|
625
|
+
*/
|
|
626
|
+
applyRawConfigOverrides(session, config) {
|
|
627
|
+
const consumedShorthands = new Set(['voice', 'stt', 'language', 'eagerness']);
|
|
628
|
+
for (const [key, value] of Object.entries(config)) {
|
|
629
|
+
if (consumedShorthands.has(key)) {
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
session[key] = value;
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Builds an input audio-append frame from a raw media chunk (base64-encoded over the single WS,
|
|
637
|
+
* full-duplex).
|
|
638
|
+
*
|
|
639
|
+
* @param chunk The raw media frame.
|
|
640
|
+
* @returns The audio-append client frame.
|
|
641
|
+
* @remarks Wire-format binding point — the `input_audio.append` type and the `audio` base64 key
|
|
642
|
+
* are mapped to the documented full-duplex streamed-audio input; verify against a live endpoint.
|
|
643
|
+
*/
|
|
644
|
+
buildAudioAppendFrame(chunk) {
|
|
645
|
+
return { type: 'input_audio.append', audio: Buffer.from(new Uint8Array(chunk)).toString('base64') };
|
|
646
|
+
}
|
|
647
|
+
/**
|
|
648
|
+
* Builds a mid-session `session.update` frame that re-declares only the tools (components are
|
|
649
|
+
* swappable without reconnect).
|
|
650
|
+
*
|
|
651
|
+
* @param tools The new tool set to declare.
|
|
652
|
+
* @returns The tools-only `session.update` client frame.
|
|
653
|
+
* @remarks Wire-format binding point — re-declaring tools via a partial `session.update` follows
|
|
654
|
+
* the documented "components swappable mid-session" model; verify the partial-update semantics
|
|
655
|
+
* against a live endpoint.
|
|
656
|
+
*/
|
|
657
|
+
buildToolsUpdateFrame(tools) {
|
|
658
|
+
return {
|
|
659
|
+
type: 'session.update',
|
|
660
|
+
session: { tools: tools.map((tool) => InworldRealtime.MapToolToFunction(tool)) },
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
/**
|
|
664
|
+
* Builds a tool-result frame correlated by `call_id`. The Core contract's `output` is already a
|
|
665
|
+
* JSON string, which is passed through verbatim in the `result` slot.
|
|
666
|
+
*
|
|
667
|
+
* @param callID The originating call id.
|
|
668
|
+
* @param output The tool result as a JSON string.
|
|
669
|
+
* @returns The tool-result client frame.
|
|
670
|
+
* @remarks Wire-format binding point — the `tool.result` type and the `call_id` / `result` keys
|
|
671
|
+
* are mapped to the documented fluent-tool-calling result flow; verify against a live endpoint.
|
|
672
|
+
*/
|
|
673
|
+
buildToolResultFrame(callID, output) {
|
|
674
|
+
return { type: 'tool.result', call_id: callID, result: output };
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* Builds a response-create frame carrying one-off per-response instructions (the instructed
|
|
678
|
+
* spoken update). Output supports inline steering tags like `[laugh]`, which pass through in the
|
|
679
|
+
* instructions verbatim.
|
|
680
|
+
*
|
|
681
|
+
* @param instructions Instructions for the single spoken response.
|
|
682
|
+
* @returns The response-create client frame.
|
|
683
|
+
* @remarks Wire-format binding point — the `response.create` type and the `instructions` key are
|
|
684
|
+
* mapped to the documented instructed-response capability; verify against a live endpoint.
|
|
685
|
+
*/
|
|
686
|
+
buildResponseCreateFrame(instructions) {
|
|
687
|
+
return { type: 'response.create', instructions };
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Builds the non-interrupting context-item frame. Emulated via the mutable instructions: the full
|
|
691
|
+
* prompt (base + accumulated notes) is re-sent via `session.update`, which never triggers
|
|
692
|
+
* generation.
|
|
693
|
+
*
|
|
694
|
+
* @param fullPrompt The full instructions (base prompt + background-update notes).
|
|
695
|
+
* @returns The context-item client frame (a `session.update` that rewrites instructions).
|
|
696
|
+
* @remarks Wire-format binding point — using a partial `session.update` of `instructions` as the
|
|
697
|
+
* non-interrupting context channel follows the documented "components swappable mid-session"
|
|
698
|
+
* model; verify against a live endpoint.
|
|
699
|
+
*/
|
|
700
|
+
buildContextItemFrame(fullPrompt) {
|
|
701
|
+
return { type: 'session.update', session: { instructions: fullPrompt } };
|
|
702
|
+
}
|
|
703
|
+
/**
|
|
704
|
+
* Builds the session-close frame sent before the socket is torn down.
|
|
705
|
+
*
|
|
706
|
+
* @returns The session-close client frame.
|
|
707
|
+
* @remarks Wire-format binding point — the `session.close` type is mapped to a graceful teardown;
|
|
708
|
+
* verify against a live endpoint (some deployments rely on the socket close alone).
|
|
709
|
+
*/
|
|
710
|
+
buildSessionCloseFrame() {
|
|
711
|
+
return { type: 'session.close' };
|
|
712
|
+
}
|
|
713
|
+
/**
|
|
714
|
+
* Classifies a raw inbound frame to a semantic kind the dispatcher routes on. Centralizing the
|
|
715
|
+
* `type`-string mapping here keeps {@link HandleServerEvent} stable even if the precise wire
|
|
716
|
+
* discriminators differ from the assumed shape.
|
|
717
|
+
*
|
|
718
|
+
* @param event The parsed inbound frame.
|
|
719
|
+
* @returns The semantic kind for {@link HandleServerEvent}.
|
|
720
|
+
* @remarks Wire-format binding point — the inbound `type` strings (e.g. `session.updated`,
|
|
721
|
+
* `output_audio.delta`, `input_audio_transcription.delta`, `response.done`, `tool.call`,
|
|
722
|
+
* `input_audio.speech_started`, `interrupted`, `usage`, `error`) are mapped to the documented
|
|
723
|
+
* protocol shape; verify the exact discriminators against a live Inworld endpoint.
|
|
724
|
+
*/
|
|
725
|
+
classifyServerEvent(event) {
|
|
726
|
+
switch (event.type) {
|
|
727
|
+
case 'session.created':
|
|
728
|
+
case 'session.ready':
|
|
729
|
+
return 'ready';
|
|
730
|
+
case 'session.updated':
|
|
731
|
+
return 'config-applied';
|
|
732
|
+
case 'output_audio.delta':
|
|
733
|
+
return 'output-audio';
|
|
734
|
+
case 'input_audio_transcription.delta':
|
|
735
|
+
return 'transcript-user-delta';
|
|
736
|
+
case 'input_audio_transcription.completed':
|
|
737
|
+
return 'transcript-user-final';
|
|
738
|
+
case 'output_audio_transcript.delta':
|
|
739
|
+
return 'transcript-assistant-delta';
|
|
740
|
+
case 'output_audio_transcript.done':
|
|
741
|
+
return 'transcript-assistant-final';
|
|
742
|
+
case 'response.created':
|
|
743
|
+
return 'response-started';
|
|
744
|
+
case 'response.done':
|
|
745
|
+
return 'response-done';
|
|
746
|
+
case 'tool.call':
|
|
747
|
+
return 'tool-call';
|
|
748
|
+
case 'input_audio.speech_started':
|
|
749
|
+
return 'speech-started';
|
|
750
|
+
case 'interrupted':
|
|
751
|
+
return 'interruption';
|
|
752
|
+
case 'usage':
|
|
753
|
+
return 'usage';
|
|
754
|
+
case 'error':
|
|
755
|
+
return 'error';
|
|
756
|
+
default:
|
|
757
|
+
return 'ignore';
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// ── Shared helpers ──
|
|
761
|
+
/** Resolves the underlying LLM id Inworld brokers (param model, falling back to the default). */
|
|
762
|
+
resolveModelId() {
|
|
763
|
+
return this.params.Model && this.params.Model.length > 0 ? this.params.Model : INWORLD_DEFAULT_MODEL_ID;
|
|
764
|
+
}
|
|
765
|
+
/** Emits a transcript event (drops empty/whitespace-only text so no blank turns are persisted). */
|
|
766
|
+
emitTranscript(role, text, isFinal) {
|
|
767
|
+
if (!this.transcriptHandler || !text || text.trim().length === 0) {
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
this.transcriptHandler({ Role: role, Text: text, IsFinal: isFinal });
|
|
771
|
+
}
|
|
772
|
+
/** Response boundary: releases the busy flag and drains queued sends in order. */
|
|
773
|
+
completeResponse() {
|
|
774
|
+
this.responseActive = false;
|
|
775
|
+
while (!this.responseActive && this.queuedSends.length > 0) {
|
|
776
|
+
const send = this.queuedSends.shift();
|
|
777
|
+
send?.();
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
/** Runs a send immediately when idle; otherwise queues it for the next response boundary. */
|
|
781
|
+
enqueueOrRun(send) {
|
|
782
|
+
if (this.responseActive) {
|
|
783
|
+
this.queuedSends.push(send);
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
send();
|
|
787
|
+
}
|
|
788
|
+
/** The base prompt plus every accumulated context note under a "Background updates" heading. */
|
|
789
|
+
composePromptWithNotes() {
|
|
790
|
+
if (this.contextNotes.length === 0) {
|
|
791
|
+
return this.basePrompt;
|
|
792
|
+
}
|
|
793
|
+
return `${this.basePrompt}\n\n## Background updates\n${this.contextNotes.map((n) => `- ${n}`).join('\n')}`;
|
|
794
|
+
}
|
|
795
|
+
/** JSON-serializes and sends one client frame (throws if the socket was never attached). */
|
|
796
|
+
sendFrame(frame) {
|
|
797
|
+
if (!this.socket) {
|
|
798
|
+
throw new Error('Inworld realtime session is not open (no socket attached or it was closed).');
|
|
799
|
+
}
|
|
800
|
+
this.socket.send(JSON.stringify(frame));
|
|
801
|
+
}
|
|
802
|
+
/** Rejects a still-pending ready wait (transport death / consumer close during startup). */
|
|
803
|
+
failReadyWait(message) {
|
|
804
|
+
if (!this.readyReceived && this.rejectReady) {
|
|
805
|
+
const reject = this.rejectReady;
|
|
806
|
+
this.rejectReady = null;
|
|
807
|
+
this.resolveReady = null;
|
|
808
|
+
this.readyReceived = true; // nothing further can resolve/reject it
|
|
809
|
+
reject(new Error(message));
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
/** Drops all registered handlers + queued sends so a closed session can't fire stale callbacks. */
|
|
813
|
+
clearHandlers() {
|
|
814
|
+
this.outputHandler = null;
|
|
815
|
+
this.transcriptHandler = null;
|
|
816
|
+
this.toolCallHandler = null;
|
|
817
|
+
this.interruptionHandler = null;
|
|
818
|
+
this.usageHandler = null;
|
|
819
|
+
this.queuedSends = [];
|
|
820
|
+
this.responseActive = false;
|
|
821
|
+
}
|
|
822
|
+
/** Folds optional prior context into the system prompt (Inworld has no separate history channel). */
|
|
823
|
+
static composeSystemPrompt(systemPrompt, initialContext) {
|
|
824
|
+
const context = initialContext?.trim();
|
|
825
|
+
return context ? `${systemPrompt}\n\n## Prior context\n${context}` : systemPrompt;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Normalizes tool-call arguments to the Core contract's JSON-string shape. Inworld may emit them
|
|
829
|
+
* pre-parsed (object) or already as a JSON string; both are coerced to a JSON string so consumers
|
|
830
|
+
* always parse the same shape.
|
|
831
|
+
*/
|
|
832
|
+
static normalizeToolArguments(args) {
|
|
833
|
+
if (args == null) {
|
|
834
|
+
return '{}';
|
|
835
|
+
}
|
|
836
|
+
if (typeof args === 'string') {
|
|
837
|
+
return args;
|
|
838
|
+
}
|
|
839
|
+
return JSON.stringify(args);
|
|
840
|
+
}
|
|
841
|
+
/** Decodes a base64 audio payload into a freshly-allocated `ArrayBuffer`. */
|
|
842
|
+
static base64ToArrayBuffer(base64) {
|
|
843
|
+
const bytes = Buffer.from(base64, 'base64');
|
|
844
|
+
const out = new ArrayBuffer(bytes.byteLength);
|
|
845
|
+
new Uint8Array(out).set(bytes);
|
|
846
|
+
return out;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
//# sourceMappingURL=inworldRealtime.js.map
|