@urun-sh/sense 0.0.0-stage → 0.1.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/LICENSE +201 -0
- package/README.md +38 -2
- package/dist/context.d.ts +44 -0
- package/dist/context.js +110 -0
- package/dist/errors.d.ts +6 -0
- package/dist/errors.js +9 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +9 -0
- package/dist/predicate.d.ts +81 -0
- package/dist/predicate.js +132 -0
- package/dist/question.d.ts +43 -0
- package/dist/question.js +75 -0
- package/dist/sense.d.ts +54 -0
- package/dist/sense.js +109 -0
- package/dist/session.d.ts +151 -0
- package/dist/session.js +472 -0
- package/dist/transport.d.ts +106 -0
- package/dist/transport.js +467 -0
- package/dist/wave.d.ts +58 -0
- package/dist/wave.js +209 -0
- package/examples/assets/mic-question-48k-mono.wav +0 -0
- package/examples/cheese_demo.html +290 -0
- package/examples/cheese_demo.test.ts +191 -0
- package/examples/cheese_demo.ts +250 -0
- package/examples/voice_agent.ts +166 -0
- package/package.json +55 -4
package/dist/session.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/** Session: inputs, the §1–§7 wire loop over `wire`, and wave assembly. */
|
|
2
|
+
import { connectWire, DialRefused } from "./transport.js";
|
|
3
|
+
import { Question, instructionsEnvelope } from "./question.js";
|
|
4
|
+
import { Wave } from "./wave.js";
|
|
5
|
+
import { Context } from "./context.js";
|
|
6
|
+
import { ProtocolError } from "./errors.js";
|
|
7
|
+
const MAX_FRAME_BYTES = 256_000;
|
|
8
|
+
const TURN_TOKEN_BUDGET = 250_000;
|
|
9
|
+
const MAX_BACKOFF_MS = 30_000;
|
|
10
|
+
const MAX_DIALS = 600;
|
|
11
|
+
/** Parse a server event object into the SDK's session-event shape. */
|
|
12
|
+
export function sessionEvent(raw) {
|
|
13
|
+
const obj = (typeof raw === "object" && raw !== null ? raw : {});
|
|
14
|
+
const err = (obj.error ?? null);
|
|
15
|
+
return {
|
|
16
|
+
type: typeof obj.type === "string" ? obj.type : "unknown",
|
|
17
|
+
code: typeof err?.code === "string" ? err.code : typeof obj.code === "string" ? obj.code : undefined,
|
|
18
|
+
message: typeof err?.message === "string" ? err.message : undefined,
|
|
19
|
+
raw,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
function wireDefaults(opts) {
|
|
23
|
+
return { transport: opts.transport ?? "auto", wireFactory: opts.wireFactory ?? connectWire };
|
|
24
|
+
}
|
|
25
|
+
export class Session {
|
|
26
|
+
static DEFAULT_BASE_URL = "wss://inference.urun.sh";
|
|
27
|
+
static DEFAULT_MODEL = "sense-latest:bf16";
|
|
28
|
+
apiKey;
|
|
29
|
+
baseUrl;
|
|
30
|
+
model;
|
|
31
|
+
hz;
|
|
32
|
+
transport;
|
|
33
|
+
context = new Context(this);
|
|
34
|
+
audio;
|
|
35
|
+
video;
|
|
36
|
+
text;
|
|
37
|
+
stats = { waves: 0, turns: 0, errors: 0, reconnects: 0, decisions: 0, protocolErrors: 0 };
|
|
38
|
+
isClosed = false;
|
|
39
|
+
wire = null;
|
|
40
|
+
wireFactory;
|
|
41
|
+
waveSeq = 0;
|
|
42
|
+
sourceQueues = new Map();
|
|
43
|
+
rrCursor = 0;
|
|
44
|
+
oneShotText = [];
|
|
45
|
+
transcript = "";
|
|
46
|
+
waveQueue = [];
|
|
47
|
+
waveWaiters = [];
|
|
48
|
+
accumulator = new Map();
|
|
49
|
+
accTMs = 0;
|
|
50
|
+
turnOpen = false;
|
|
51
|
+
audioPending = false;
|
|
52
|
+
inputSinceTurn = 0;
|
|
53
|
+
registry = [];
|
|
54
|
+
eventListeners = [];
|
|
55
|
+
constructor(opts = {}) {
|
|
56
|
+
this.apiKey = opts.apiKey ?? process.env.SENSE_API_KEY ?? "";
|
|
57
|
+
this.baseUrl = opts.baseUrl ?? process.env.SENSE_BASE_URL ?? Session.DEFAULT_BASE_URL;
|
|
58
|
+
this.model = opts.model ?? Session.DEFAULT_MODEL;
|
|
59
|
+
this.hz = opts.hz ?? 25;
|
|
60
|
+
({ transport: this.transport, wireFactory: this.wireFactory } = wireDefaults(opts));
|
|
61
|
+
this.audio = new AudioInput(this);
|
|
62
|
+
this.video = new VideoInput(this);
|
|
63
|
+
this.text = new TextInput(this);
|
|
64
|
+
for (const q of opts.questions ?? [])
|
|
65
|
+
this.addQuestionRaw(q);
|
|
66
|
+
}
|
|
67
|
+
get questions() {
|
|
68
|
+
return this.registry.slice();
|
|
69
|
+
}
|
|
70
|
+
get closed() {
|
|
71
|
+
return this.isClosed;
|
|
72
|
+
}
|
|
73
|
+
/** Register (or replace) a question; takes effect at the next turn open. */
|
|
74
|
+
addQuestion(id, kind, opts = {}) {
|
|
75
|
+
return this.addQuestionRaw(new Question({ id, kind, ...opts }));
|
|
76
|
+
}
|
|
77
|
+
addQuestionRaw(q) {
|
|
78
|
+
const i = this.registry.findIndex((x) => x.id === q.id);
|
|
79
|
+
if (i >= 0)
|
|
80
|
+
this.registry[i] = q;
|
|
81
|
+
else
|
|
82
|
+
this.registry.push(q);
|
|
83
|
+
return q;
|
|
84
|
+
}
|
|
85
|
+
realtimeUrl() {
|
|
86
|
+
return `${this.baseUrl.replace(/\/+$/, "")}/v1/realtime?model=${encodeURIComponent(this.model)}`;
|
|
87
|
+
}
|
|
88
|
+
// ---- inputs -------------------------------------------------------------
|
|
89
|
+
/** @internal */
|
|
90
|
+
emitAudio(b64, source) {
|
|
91
|
+
if (source !== undefined && source !== "default") {
|
|
92
|
+
let queue = this.sourceQueues.get(source);
|
|
93
|
+
if (!queue) {
|
|
94
|
+
queue = [];
|
|
95
|
+
this.sourceQueues.set(source, queue);
|
|
96
|
+
}
|
|
97
|
+
queue.push(b64);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
this.inputSinceTurn++;
|
|
101
|
+
this.audioPending = true;
|
|
102
|
+
this.sendJson({ type: "input_audio_buffer.append", audio: b64 });
|
|
103
|
+
this.drainSourceQueues();
|
|
104
|
+
}
|
|
105
|
+
/** ponytail: round-robin one queued non-default chunk per default push (PR-2
|
|
106
|
+
* degradation); replace with labeled lanes when PR-2 lands. */
|
|
107
|
+
drainSourceQueues() {
|
|
108
|
+
const sources = [...this.sourceQueues.keys()];
|
|
109
|
+
const source = sources[this.rrCursor++ % Math.max(sources.length, 1)];
|
|
110
|
+
if (source === undefined)
|
|
111
|
+
return;
|
|
112
|
+
const b64 = this.sourceQueues.get(source)?.shift();
|
|
113
|
+
if (b64 !== undefined)
|
|
114
|
+
this.sendJson({ type: "input_audio_buffer.append", audio: b64, source });
|
|
115
|
+
}
|
|
116
|
+
/** @internal */
|
|
117
|
+
emitVideo(jpeg) {
|
|
118
|
+
if (jpeg.byteLength > MAX_FRAME_BYTES) {
|
|
119
|
+
throw new ProtocolError("frame_too_large", `frame ${jpeg.byteLength}B > ${MAX_FRAME_BYTES}B`);
|
|
120
|
+
}
|
|
121
|
+
this.inputSinceTurn++;
|
|
122
|
+
this.sendJson({
|
|
123
|
+
type: "conversation.item.create",
|
|
124
|
+
item: {
|
|
125
|
+
type: "message",
|
|
126
|
+
role: "user",
|
|
127
|
+
content: [{ type: "input_image", image_url: `data:image/jpeg;base64,${toB64(jpeg)}` }],
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
/** @internal */
|
|
132
|
+
emitText(text, mode) {
|
|
133
|
+
this.inputSinceTurn++;
|
|
134
|
+
if (mode === "event") {
|
|
135
|
+
this.oneShotText.push(text);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (mode === "replace")
|
|
139
|
+
this.transcript = text;
|
|
140
|
+
else
|
|
141
|
+
this.transcript += (this.transcript ? "\n" : "") + text;
|
|
142
|
+
}
|
|
143
|
+
// ---- wire ---------------------------------------------------------------
|
|
144
|
+
sendJson(obj) {
|
|
145
|
+
this.wire?.sendJson(obj);
|
|
146
|
+
}
|
|
147
|
+
sendSessionUpdate() {
|
|
148
|
+
this.sendJson({
|
|
149
|
+
type: "session.update",
|
|
150
|
+
session: {
|
|
151
|
+
model: this.model,
|
|
152
|
+
modalities: ["text"],
|
|
153
|
+
instructions: JSON.stringify(instructionsEnvelope(this.registry, this.hz)),
|
|
154
|
+
},
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
openTurn() {
|
|
158
|
+
const text = [this.transcript, ...this.oneShotText].filter(Boolean).join("\n");
|
|
159
|
+
this.oneShotText = [];
|
|
160
|
+
if (text) {
|
|
161
|
+
this.sendJson({
|
|
162
|
+
type: "conversation.item.create",
|
|
163
|
+
item: { type: "message", role: "user", content: [{ type: "input_text", text }] },
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
// qvs semantics (live-verified 2026-11-03): commit must follow BUFFERED
|
|
167
|
+
// audio — an empty commit is refused (empty_buffer) and the turn then
|
|
168
|
+
// opens with no audio bound, so later appends never reach the model.
|
|
169
|
+
// Appends sent before the commit are held server-side (GA semantics).
|
|
170
|
+
if (this.audioPending) {
|
|
171
|
+
this.sendJson({ type: "input_audio_buffer.commit" });
|
|
172
|
+
this.audioPending = false;
|
|
173
|
+
}
|
|
174
|
+
this.sendJson({
|
|
175
|
+
type: "response.create",
|
|
176
|
+
response: {
|
|
177
|
+
instructions: JSON.stringify(instructionsEnvelope(this.registry, this.hz)),
|
|
178
|
+
max_output_tokens: TURN_TOKEN_BUDGET,
|
|
179
|
+
},
|
|
180
|
+
});
|
|
181
|
+
this.accumulator.clear();
|
|
182
|
+
this.turnOpen = true;
|
|
183
|
+
this.inputSinceTurn = 0;
|
|
184
|
+
this.stats.turns++;
|
|
185
|
+
}
|
|
186
|
+
/** Barge-in: stop the active turn's cadence. */
|
|
187
|
+
bargeIn() {
|
|
188
|
+
this.sendJson({ type: "response.cancel" });
|
|
189
|
+
this.turnOpen = false;
|
|
190
|
+
}
|
|
191
|
+
/** Connect with the §7 dial policy (503/429 → backoff and redial). The
|
|
192
|
+
* transport selection (auto: quic → webrtc → ws) happens in the wire
|
|
193
|
+
* factory; a refused dial restarts the whole order. */
|
|
194
|
+
async connect() {
|
|
195
|
+
if (this.isClosed)
|
|
196
|
+
throw new ProtocolError("protocol", "session is closed");
|
|
197
|
+
let delay = 1_000;
|
|
198
|
+
for (let attempt = 1;; attempt++) {
|
|
199
|
+
try {
|
|
200
|
+
const wire = await this.wireFactory({
|
|
201
|
+
transport: this.transport,
|
|
202
|
+
apiBaseUrl: this.baseUrl.replace(/^wss:/, "https:").replace(/^ws:/, "http:").replace(/\/+$/, ""),
|
|
203
|
+
apiKey: this.apiKey,
|
|
204
|
+
model: this.model,
|
|
205
|
+
wsUrl: this.realtimeUrl(),
|
|
206
|
+
});
|
|
207
|
+
this.wire = wire;
|
|
208
|
+
wire.onMessage((raw) => this.onMessage(raw));
|
|
209
|
+
wire.onClose((code) => this.onClose(code ?? 0));
|
|
210
|
+
this.sendSessionUpdate();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
if (attempt >= MAX_DIALS || !(err instanceof DialRefused))
|
|
215
|
+
throw err;
|
|
216
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
217
|
+
delay = Math.min(delay * 2, MAX_BACKOFF_MS);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
onClose(code) {
|
|
222
|
+
this.wire = null;
|
|
223
|
+
this.turnOpen = false;
|
|
224
|
+
if (code === 1011) {
|
|
225
|
+
// session_gone: discard local state, reconnect fresh (§7).
|
|
226
|
+
this.registry = [];
|
|
227
|
+
this.transcript = "";
|
|
228
|
+
this.stats.reconnects++;
|
|
229
|
+
void this.connect();
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
// any other wire death ends the streams instead of hanging the run loop
|
|
233
|
+
for (const w of this.waveWaiters.splice(0))
|
|
234
|
+
w(null);
|
|
235
|
+
}
|
|
236
|
+
onMessage(raw) {
|
|
237
|
+
let msg;
|
|
238
|
+
try {
|
|
239
|
+
msg = JSON.parse(raw);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const type = typeof msg.type === "string" ? msg.type : "";
|
|
245
|
+
if (type === "response.output_text.delta" || type === "response.text.delta") {
|
|
246
|
+
this.onDeltaMessage(msg);
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (type === "response.completed" || type === "response.done" || type === "response.failed") {
|
|
250
|
+
this.onTurnCompleted();
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
if (type === "error") {
|
|
254
|
+
// A handled error (benign code, or commit sent) stops here; a mid-turn
|
|
255
|
+
// failure falls through so listeners still see the event.
|
|
256
|
+
if (this.onErrorMessage(msg))
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
this.eventListeners.forEach((h) => h(sessionEvent(msg)));
|
|
260
|
+
}
|
|
261
|
+
onDeltaMessage(msg) {
|
|
262
|
+
for (const line of String(msg.delta ?? "").split("\n"))
|
|
263
|
+
this.onDecisionLine(line);
|
|
264
|
+
}
|
|
265
|
+
onTurnCompleted() {
|
|
266
|
+
this.flushAccumulator();
|
|
267
|
+
this.turnOpen = false;
|
|
268
|
+
}
|
|
269
|
+
/** Returns true when the error was handled locally and needs no listener dispatch. */
|
|
270
|
+
onErrorMessage(msg) {
|
|
271
|
+
this.stats.errors++;
|
|
272
|
+
const code = msg.error?.code;
|
|
273
|
+
if (code === "empty_buffer" || code === "audio_input_transcript_unavailable")
|
|
274
|
+
return true; // benign (§5.1, §7)
|
|
275
|
+
if (code === "buffer_full") {
|
|
276
|
+
this.sendJson({ type: "input_audio_buffer.commit" });
|
|
277
|
+
return true;
|
|
278
|
+
}
|
|
279
|
+
// mid-turn failure: end the turn; the run loop re-opens.
|
|
280
|
+
console.error(`[sense] lane error: ${JSON.stringify(msg.error)}`);
|
|
281
|
+
this.turnOpen = false;
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
onEvent(handler) {
|
|
285
|
+
this.eventListeners.push(handler);
|
|
286
|
+
return {
|
|
287
|
+
cancel: () => {
|
|
288
|
+
this.eventListeners = this.eventListeners.filter((h) => h !== handler);
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/** §6.3 client-side wave assembly: one decision line per question per wave;
|
|
293
|
+
* a repeat qid closes the previous wave. */
|
|
294
|
+
onDecisionLine(line) {
|
|
295
|
+
const trimmed = line.trim();
|
|
296
|
+
if (!trimmed.startsWith("{") || !trimmed.includes('"sense.decision"'))
|
|
297
|
+
return;
|
|
298
|
+
let ev;
|
|
299
|
+
try {
|
|
300
|
+
ev = JSON.parse(trimmed);
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const qid = String(ev.id ?? ev.question ?? "");
|
|
306
|
+
if (!qid)
|
|
307
|
+
return;
|
|
308
|
+
if (this.accumulator.has(qid))
|
|
309
|
+
this.flushAccumulator();
|
|
310
|
+
this.accumulator.set(qid, ev);
|
|
311
|
+
if (typeof ev.t_ms === "number")
|
|
312
|
+
this.accTMs = ev.t_ms;
|
|
313
|
+
this.stats.decisions++;
|
|
314
|
+
}
|
|
315
|
+
flushAccumulator() {
|
|
316
|
+
if (this.accumulator.size === 0)
|
|
317
|
+
return;
|
|
318
|
+
const payload = {
|
|
319
|
+
wave_id: this.waveSeq,
|
|
320
|
+
t_ms: this.accTMs,
|
|
321
|
+
results: Object.fromEntries(this.accumulator),
|
|
322
|
+
};
|
|
323
|
+
this.accumulator.clear();
|
|
324
|
+
try {
|
|
325
|
+
const wave = Wave.decode(this.registry, payload);
|
|
326
|
+
this.waveSeq++;
|
|
327
|
+
this.stats.waves++;
|
|
328
|
+
this.deliver(wave);
|
|
329
|
+
}
|
|
330
|
+
catch (err) {
|
|
331
|
+
if (err instanceof ProtocolError) {
|
|
332
|
+
// Owner ruling: a wave that does not answer every registered
|
|
333
|
+
// question is a PROTOCOL ERROR — surface it loudly, count it, and
|
|
334
|
+
// drop the partial group; never treat it as false/hold, never let
|
|
335
|
+
// one cut wave kill the session.
|
|
336
|
+
this.stats.protocolErrors++;
|
|
337
|
+
console.error(`[sense] PROTOCOL ERROR: ${err.message}`);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
throw err;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
deliver(wave) {
|
|
344
|
+
this.context.ingest(wave);
|
|
345
|
+
this.waveQueue.push(wave);
|
|
346
|
+
while (this.waveWaiters.length && this.waveQueue.length) {
|
|
347
|
+
this.waveWaiters.shift()(this.waveQueue.shift());
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
nextWave() {
|
|
351
|
+
const queued = this.waveQueue.shift();
|
|
352
|
+
if (queued)
|
|
353
|
+
return Promise.resolve(queued);
|
|
354
|
+
if (this.isClosed)
|
|
355
|
+
return Promise.resolve(null);
|
|
356
|
+
const { promise, resolve } = Promise.withResolvers();
|
|
357
|
+
this.waveWaiters.push(resolve);
|
|
358
|
+
return promise;
|
|
359
|
+
}
|
|
360
|
+
// ---- public streaming surface -------------------------------------------
|
|
361
|
+
/** Async-iterator of waves: push any given inputs, then run the turn loop. */
|
|
362
|
+
async *run(inputs) {
|
|
363
|
+
if (!this.wire)
|
|
364
|
+
await this.connect();
|
|
365
|
+
if (inputs) {
|
|
366
|
+
for await (const frame of inputs)
|
|
367
|
+
this.applyFrame(frame);
|
|
368
|
+
}
|
|
369
|
+
for (;;) {
|
|
370
|
+
// The turn opens on FIRST input (qvs semantics): an empty commit is
|
|
371
|
+
// refused and leaves the turn audio-less forever.
|
|
372
|
+
if (!this.turnOpen && !this.isClosed && this.inputSinceTurn > 0)
|
|
373
|
+
this.openTurn();
|
|
374
|
+
const wave = await this.nextWave();
|
|
375
|
+
if (!wave)
|
|
376
|
+
return;
|
|
377
|
+
yield wave;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
applyFrame(frame) {
|
|
381
|
+
if ("audio" in frame)
|
|
382
|
+
this.emitAudio(toB64(frame.audio), frame.source);
|
|
383
|
+
else if ("video" in frame)
|
|
384
|
+
this.emitVideo(frame.video instanceof Uint8Array ? frame.video : new Uint8Array(frame.video));
|
|
385
|
+
else if ("text" in frame)
|
|
386
|
+
this.emitText(frame.text, frame.mode ?? "append");
|
|
387
|
+
}
|
|
388
|
+
/** Latest result for a question, or null before the first wave. */
|
|
389
|
+
latest(question) {
|
|
390
|
+
return this.context.get(question);
|
|
391
|
+
}
|
|
392
|
+
/** Iterate waves live; coalesce drops earlier waves in runs of identical
|
|
393
|
+
* argmax values (latest wins). */
|
|
394
|
+
async *waves(opts = {}) {
|
|
395
|
+
let pending = null;
|
|
396
|
+
for (;;) {
|
|
397
|
+
const wave = await this.nextWave();
|
|
398
|
+
if (!wave)
|
|
399
|
+
break;
|
|
400
|
+
if (!opts.coalesce) {
|
|
401
|
+
yield wave;
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
if (pending && coalesceSignature(pending) === coalesceSignature(wave))
|
|
405
|
+
pending = wave;
|
|
406
|
+
else {
|
|
407
|
+
if (pending)
|
|
408
|
+
yield pending;
|
|
409
|
+
pending = wave;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
if (pending)
|
|
413
|
+
yield pending;
|
|
414
|
+
}
|
|
415
|
+
when(pred, handler) {
|
|
416
|
+
return this.context.when(pred, handler);
|
|
417
|
+
}
|
|
418
|
+
/** Observe waves live; `coalesce` delivers the FIRST wave of a run of
|
|
419
|
+
* consecutive same-argmax waves — later waves update `latest()` without
|
|
420
|
+
* another callback. */
|
|
421
|
+
onWave(handler, opts = {}) {
|
|
422
|
+
return this.context.onWave(handler, opts);
|
|
423
|
+
}
|
|
424
|
+
async close() {
|
|
425
|
+
this.isClosed = true;
|
|
426
|
+
for (const w of this.waveWaiters.splice(0))
|
|
427
|
+
w(null);
|
|
428
|
+
const wire = this.wire;
|
|
429
|
+
this.wire = null;
|
|
430
|
+
if (wire)
|
|
431
|
+
await wire.close();
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function toB64(data) {
|
|
435
|
+
const bytes = data instanceof Uint8Array ? data : new Uint8Array(data);
|
|
436
|
+
let bin = "";
|
|
437
|
+
for (let i = 0; i < bytes.length; i += 0x8000) {
|
|
438
|
+
bin += String.fromCharCode(...bytes.subarray(i, i + 0x8000));
|
|
439
|
+
}
|
|
440
|
+
return btoa(bin);
|
|
441
|
+
}
|
|
442
|
+
function coalesceSignature(w) {
|
|
443
|
+
return w.questionIds().map((qid) => `${qid}=${w.result(qid).top}`).join(";");
|
|
444
|
+
}
|
|
445
|
+
export class AudioInput {
|
|
446
|
+
session;
|
|
447
|
+
constructor(session) {
|
|
448
|
+
this.session = session;
|
|
449
|
+
}
|
|
450
|
+
/** Push PCM16 24 kHz mono (encoded base64 on the wire). */
|
|
451
|
+
push(pcm, source) {
|
|
452
|
+
this.session.emitAudio(toB64(pcm), source);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
export class VideoInput {
|
|
456
|
+
session;
|
|
457
|
+
constructor(session) {
|
|
458
|
+
this.session = session;
|
|
459
|
+
}
|
|
460
|
+
push(jpeg) {
|
|
461
|
+
this.session.emitVideo(jpeg instanceof Uint8Array ? jpeg : new Uint8Array(jpeg));
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
export class TextInput {
|
|
465
|
+
session;
|
|
466
|
+
constructor(session) {
|
|
467
|
+
this.session = session;
|
|
468
|
+
}
|
|
469
|
+
push(text, mode = "append") {
|
|
470
|
+
this.session.emitText(text, mode);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export type Transport = 'auto' | 'quic' | 'webrtc' | 'ws';
|
|
2
|
+
export declare const TRANSPORT_DIAL_TIMEOUT_MS: Record<'quic' | 'webrtc', number>;
|
|
3
|
+
/** A non-WS transport could not be established (fallback signal in auto). */
|
|
4
|
+
export declare class TransportDialError extends Error {
|
|
5
|
+
}
|
|
6
|
+
/** The WS dial was refused with 503/429 — worth a backoff redial (§7). */
|
|
7
|
+
export declare class DialRefused extends Error {
|
|
8
|
+
readonly status: number;
|
|
9
|
+
constructor(status: number);
|
|
10
|
+
}
|
|
11
|
+
/** Node `ws` emitter surface (plus the browser-WebSocket addEventListener). */
|
|
12
|
+
interface WsLike {
|
|
13
|
+
send(data: string): void;
|
|
14
|
+
close(): void;
|
|
15
|
+
addEventListener?(type: string, cb: () => void, opts?: {
|
|
16
|
+
once?: boolean;
|
|
17
|
+
}): void;
|
|
18
|
+
on?(event: string, cb: (...args: never[]) => void): unknown;
|
|
19
|
+
once?(event: string, cb: (...args: never[]) => void): unknown;
|
|
20
|
+
off?(event: string, cb: (...args: never[]) => void): unknown;
|
|
21
|
+
removeAllListeners?(): unknown;
|
|
22
|
+
}
|
|
23
|
+
export interface Wire {
|
|
24
|
+
readonly kind: Transport;
|
|
25
|
+
/** Fire-and-forget send of one JSON event. Throws once the wire is closed. */
|
|
26
|
+
sendJson(obj: unknown): void;
|
|
27
|
+
onMessage(cb: (raw: string) => void): void;
|
|
28
|
+
onClose(cb: (code?: number) => void): void;
|
|
29
|
+
close(): Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
export interface WireOptions {
|
|
32
|
+
transport: Transport;
|
|
33
|
+
/** https:// base of the inference proxy (POST /v1/realtime/calls). */
|
|
34
|
+
apiBaseUrl: string;
|
|
35
|
+
apiKey: string;
|
|
36
|
+
model: string;
|
|
37
|
+
/** wss://…/v1/realtime?model=… (the WS dial URL). */
|
|
38
|
+
wsUrl: string;
|
|
39
|
+
}
|
|
40
|
+
export type WireDialer = (opts: WireOptions) => Promise<Wire>;
|
|
41
|
+
export declare function withTimeout<T>(p: Promise<T>, ms: number, label: string): Promise<T>;
|
|
42
|
+
/** certHashes entries: the gateway serves base64 sha-256; tolerate lowercase hex. */
|
|
43
|
+
export declare function hashToBytes(h: string): Uint8Array;
|
|
44
|
+
/** Incremental NDJSON splitter: feed bytes, get complete lines. */
|
|
45
|
+
export declare class NdjsonSplitter {
|
|
46
|
+
private buf;
|
|
47
|
+
push(text: string): string[];
|
|
48
|
+
}
|
|
49
|
+
export declare class WsWire implements Wire {
|
|
50
|
+
private ws;
|
|
51
|
+
readonly kind: Transport;
|
|
52
|
+
private messageCb;
|
|
53
|
+
private closeCb;
|
|
54
|
+
constructor(ws: WsLike);
|
|
55
|
+
sendJson(obj: unknown): void;
|
|
56
|
+
onMessage(cb: (raw: string) => void): void;
|
|
57
|
+
onClose(cb: (code?: number) => void): void;
|
|
58
|
+
close(): Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
/** Dial the WS wire. Node uses the `ws` package (Authorization header
|
|
61
|
+
* support); a browser global WebSocket cannot set headers, so browser WS
|
|
62
|
+
* fallback is only viable where the proxy accepts URL-carried auth.
|
|
63
|
+
*
|
|
64
|
+
* Dynamic import: `ws` is a Node-only package — a static import would break
|
|
65
|
+
* every browser bundle of this SDK (the platform-specific-module exception). */
|
|
66
|
+
export declare const dialWs: WireDialer;
|
|
67
|
+
export declare class QuicWire implements Wire {
|
|
68
|
+
private opts;
|
|
69
|
+
readonly kind: Transport;
|
|
70
|
+
private messageCb;
|
|
71
|
+
private closeCb;
|
|
72
|
+
private writer;
|
|
73
|
+
private splitter;
|
|
74
|
+
private wt;
|
|
75
|
+
private closedSent;
|
|
76
|
+
static available(): boolean;
|
|
77
|
+
constructor(opts: WireOptions);
|
|
78
|
+
connect(): Promise<void>;
|
|
79
|
+
private signalClosed;
|
|
80
|
+
sendJson(obj: unknown): void;
|
|
81
|
+
onMessage(cb: (raw: string) => void): void;
|
|
82
|
+
onClose(cb: (code?: number) => void): void;
|
|
83
|
+
close(): Promise<void>;
|
|
84
|
+
}
|
|
85
|
+
export declare class RtcWire implements Wire {
|
|
86
|
+
private opts;
|
|
87
|
+
readonly kind: Transport;
|
|
88
|
+
private messageCb;
|
|
89
|
+
private closeCb;
|
|
90
|
+
private pc;
|
|
91
|
+
private dc;
|
|
92
|
+
static available(): boolean;
|
|
93
|
+
constructor(opts: WireOptions);
|
|
94
|
+
connect(): Promise<void>;
|
|
95
|
+
private waitDcOpen;
|
|
96
|
+
sendJson(obj: unknown): void;
|
|
97
|
+
onMessage(cb: (raw: string) => void): void;
|
|
98
|
+
onClose(cb: (code?: number) => void): void;
|
|
99
|
+
close(): Promise<void>;
|
|
100
|
+
}
|
|
101
|
+
/** Dial order. Auto = quic → webrtc → ws, skipping platforms without the
|
|
102
|
+
* client stack. Explicit overrides dial exactly one. */
|
|
103
|
+
export declare function transportOrder(transport: Transport): Array<'quic' | 'webrtc' | 'ws'>;
|
|
104
|
+
export declare const defaultDialers: Record<'quic' | 'webrtc' | 'ws', WireDialer>;
|
|
105
|
+
export declare function connectWire(opts: WireOptions, dialers?: Partial<Record<'quic' | 'webrtc' | 'ws', WireDialer>>): Promise<Wire>;
|
|
106
|
+
export {};
|