@urun-sh/sense 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,467 @@
1
+ /** Transport layer: one standing bidirectional event wire per session.
2
+ *
3
+ * Every transport carries the SAME OpenAI JSON events (media rides the event
4
+ * channel as `input_audio_buffer.append` / `conversation.item.create`), so the
5
+ * session loop is transport-agnostic:
6
+ *
7
+ * - `quic` — WebTransport: POST /v1/realtime/calls?transport=quic →
8
+ * {callId, url, token, certHashes}, then a WT session with a
9
+ * msgpack hello control stream and an NDJSON events stream.
10
+ * Native `globalThis.WebTransport` (browsers; Node only if the
11
+ * global exists).
12
+ * - `webrtc` — RTCPeerConnection: POST the SDP offer, events on the
13
+ * `oai-events` data channel (byte-for-byte the WS protocol).
14
+ * Browsers only; Node falls through to WS.
15
+ * - `ws` — the original websocket wire (Node `ws`, auth via header).
16
+ *
17
+ * `auto` tries QUIC (short dial: UDP may be blocked) → WebRTC → WS.
18
+ */
19
+ import { encode as msgpackEncode, decode as msgpackDecode } from '@msgpack/msgpack';
20
+ export const TRANSPORT_DIAL_TIMEOUT_MS = {
21
+ quic: 2_000, // UDP blocked ⇒ QUIC never completes; fail fast
22
+ webrtc: 10_000, // ICE + DTLS + datachannel open
23
+ };
24
+ /** A non-WS transport could not be established (fallback signal in auto). */
25
+ export class TransportDialError extends Error {
26
+ }
27
+ /** The WS dial was refused with 503/429 — worth a backoff redial (§7). */
28
+ export class DialRefused extends Error {
29
+ status;
30
+ constructor(status) {
31
+ super(`dial refused: ${status}`);
32
+ this.status = status;
33
+ }
34
+ }
35
+ function globalCtor(name) {
36
+ // Cross-runtime constructor probe: this package's tsconfig has no DOM/node
37
+ // lib, so globalThis is not typed for WebTransport/RTCPeerConnection.
38
+ const g = globalThis;
39
+ const value = g[name];
40
+ return typeof value === 'function' ? value : undefined;
41
+ }
42
+ // ---------------------------------------------------------------------------
43
+ // shared helpers
44
+ // ---------------------------------------------------------------------------
45
+ export async function withTimeout(p, ms, label) {
46
+ const { promise, reject } = Promise.withResolvers();
47
+ const timer = setTimeout(() => reject(new TransportDialError(`${label} timed out after ${ms}ms`)), ms);
48
+ try {
49
+ return await Promise.race([p, promise]);
50
+ }
51
+ finally {
52
+ clearTimeout(timer);
53
+ }
54
+ }
55
+ /** certHashes entries: the gateway serves base64 sha-256; tolerate lowercase hex. */
56
+ export function hashToBytes(h) {
57
+ if (h.length === 64 && /^[0-9a-fA-F]+$/.test(h)) {
58
+ const out = new Uint8Array(32);
59
+ for (let i = 0; i < 32; i++)
60
+ out[i] = parseInt(h.slice(i * 2, i * 2 + 2), 16);
61
+ return out;
62
+ }
63
+ let bin;
64
+ try {
65
+ bin = atob(h);
66
+ }
67
+ catch {
68
+ throw new TransportDialError(`certHashes entry is neither base64 nor hex sha-256: ${h}`);
69
+ }
70
+ if (bin.length !== 32)
71
+ throw new TransportDialError(`certHashes entry is neither base64 nor hex sha-256: ${h}`);
72
+ const out = new Uint8Array(32);
73
+ for (let i = 0; i < 32; i++)
74
+ out[i] = bin.charCodeAt(i);
75
+ return out;
76
+ }
77
+ /** Incremental NDJSON splitter: feed bytes, get complete lines. */
78
+ export class NdjsonSplitter {
79
+ buf = '';
80
+ push(text) {
81
+ this.buf += text;
82
+ const out = [];
83
+ for (;;) {
84
+ const i = this.buf.indexOf('\n');
85
+ if (i < 0)
86
+ break;
87
+ const line = this.buf.slice(0, i);
88
+ this.buf = this.buf.slice(i + 1);
89
+ if (line.trim())
90
+ out.push(line);
91
+ }
92
+ return out;
93
+ }
94
+ }
95
+ const WT_CONTROL_FRAME_MAX = 256 * 1024;
96
+ function encodeWtControlFrame(msg) {
97
+ const body = msgpackEncode(msg);
98
+ const framed = new Uint8Array(4 + body.byteLength);
99
+ new DataView(framed.buffer).setUint32(0, body.byteLength, true);
100
+ framed.set(body, 4);
101
+ return framed;
102
+ }
103
+ class WtControlDecoder {
104
+ buf = new Uint8Array(0);
105
+ push(bytes) {
106
+ const merged = new Uint8Array(this.buf.byteLength + bytes.byteLength);
107
+ merged.set(this.buf);
108
+ merged.set(bytes, this.buf.byteLength);
109
+ this.buf = merged;
110
+ const out = [];
111
+ while (this.buf.byteLength >= 4) {
112
+ const len = new DataView(this.buf.buffer, this.buf.byteOffset).getUint32(0, true);
113
+ if (len > WT_CONTROL_FRAME_MAX)
114
+ throw new TransportDialError('wt control frame too large');
115
+ if (this.buf.byteLength < 4 + len)
116
+ break;
117
+ const body = this.buf.subarray(4, 4 + len);
118
+ this.buf = this.buf.subarray(4 + len);
119
+ out.push(msgpackDecode(body));
120
+ }
121
+ return out;
122
+ }
123
+ }
124
+ // ---------------------------------------------------------------------------
125
+ // WebSocket
126
+ // ---------------------------------------------------------------------------
127
+ export class WsWire {
128
+ ws;
129
+ kind = 'ws';
130
+ messageCb = null;
131
+ closeCb = null;
132
+ constructor(ws) {
133
+ this.ws = ws;
134
+ ws.on?.('message', (data) => this.messageCb?.(String(data)));
135
+ ws.on?.('close', (code) => this.closeCb?.(code));
136
+ // EventEmitter: an unhandled 'error' throws (crashes the host process);
137
+ // 'close' follows and reports the death through closeCb.
138
+ ws.on?.('error', () => undefined);
139
+ }
140
+ sendJson(obj) {
141
+ this.ws.send(JSON.stringify(obj));
142
+ }
143
+ onMessage(cb) {
144
+ this.messageCb = cb;
145
+ }
146
+ onClose(cb) {
147
+ this.closeCb = cb;
148
+ }
149
+ close() {
150
+ const { promise, resolve } = Promise.withResolvers();
151
+ this.ws.once?.('close', () => resolve());
152
+ this.ws.close();
153
+ return promise;
154
+ }
155
+ }
156
+ /** Dial the WS wire. Node uses the `ws` package (Authorization header
157
+ * support); a browser global WebSocket cannot set headers, so browser WS
158
+ * fallback is only viable where the proxy accepts URL-carried auth.
159
+ *
160
+ * Dynamic import: `ws` is a Node-only package — a static import would break
161
+ * every browser bundle of this SDK (the platform-specific-module exception). */
162
+ export const dialWs = async (opts) => {
163
+ const Impl = await webSocketImpl();
164
+ const { promise, resolve, reject } = Promise.withResolvers();
165
+ const ws = new Impl(opts.wsUrl, { headers: { Authorization: `Bearer ${opts.apiKey}` } });
166
+ const fail = (err) => {
167
+ ws.removeAllListeners?.();
168
+ reject(err);
169
+ };
170
+ ws.once?.('unexpected-response', (_req, res) => {
171
+ const status = res.statusCode ?? 0;
172
+ fail(status === 503 || status === 429 ? new DialRefused(status) : new TransportDialError(`handshake refused: ${status}`));
173
+ });
174
+ ws.once?.('error', fail);
175
+ const onOpen = () => {
176
+ ws.off?.('error', fail);
177
+ resolve(new WsWire(ws));
178
+ };
179
+ if (typeof ws.once === 'function')
180
+ ws.once('open', onOpen);
181
+ else
182
+ ws.addEventListener?.('open', onOpen, { once: true });
183
+ return promise;
184
+ };
185
+ async function webSocketImpl() {
186
+ // Cross-runtime Node probe: globalThis.process is untyped (no node lib here).
187
+ const env = globalThis;
188
+ const isNode = typeof env.process?.versions?.node === 'string';
189
+ if (isNode) {
190
+ const { default: WS } = await import('ws');
191
+ return WS; // `ws`'s typed ctor accepts the structural WsLike surface
192
+ }
193
+ const ctor = globalCtor('WebSocket');
194
+ if (ctor === undefined)
195
+ throw new TransportDialError('no WebSocket implementation on this platform');
196
+ // Browser WebSocket cannot set the Authorization header ({headers} becomes
197
+ // the protocols arg → SyntaxError) and has no EventEmitter surface — dialWs
198
+ // needs both. Fail clearly instead of a fallback that can never work.
199
+ throw new TransportDialError('browser WebSocket fallback is unsupported (no header auth)');
200
+ }
201
+ // ---------------------------------------------------------------------------
202
+ // QUIC / WebTransport
203
+ // ---------------------------------------------------------------------------
204
+ export class QuicWire {
205
+ opts;
206
+ kind = 'quic';
207
+ messageCb = null;
208
+ closeCb = null;
209
+ writer = null;
210
+ splitter = new NdjsonSplitter();
211
+ wt = null;
212
+ closedSent = false;
213
+ static available() {
214
+ return globalCtor('WebTransport') !== undefined;
215
+ }
216
+ constructor(opts) {
217
+ this.opts = opts;
218
+ }
219
+ async connect() {
220
+ const WT = globalCtor('WebTransport');
221
+ if (WT === undefined)
222
+ throw new TransportDialError('no WebTransport implementation on this platform');
223
+ const res = await fetch(`${this.opts.apiBaseUrl.replace(/\/+$/, '')}/v1/realtime/calls?model=${encodeURIComponent(this.opts.model)}&transport=quic`, { method: 'POST', headers: { authorization: `Bearer ${this.opts.apiKey}` } });
224
+ if (res.status !== 201) {
225
+ throw new TransportDialError(`POST /v1/realtime/calls?transport=quic -> ${res.status}: ${(await res.text()).slice(0, 200)}`);
226
+ }
227
+ const answer = (await res.json());
228
+ const wt = new WT(answer.url, {
229
+ serverCertificateHashes: (answer.certHashes ?? []).map((h) => ({ algorithm: 'sha-256', value: hashToBytes(h) })),
230
+ });
231
+ this.wt = wt;
232
+ await wt.ready;
233
+ // canonical control dance: msgpack hello {token, sessionId} → hello-ack
234
+ const ctrl = await wt.createBidirectionalStream();
235
+ const ctrlWriter = ctrl.writable.getWriter();
236
+ await ctrlWriter.write(encodeWtControlFrame({ t: 'hello', token: answer.token, sessionId: answer.callId }));
237
+ const helloAcked = Promise.withResolvers();
238
+ const pumpControl = async () => {
239
+ const reader = ctrl.readable.getReader();
240
+ const decoder = new WtControlDecoder();
241
+ for (;;) {
242
+ const { value, done } = await reader.read();
243
+ if (done)
244
+ throw new TransportDialError('gateway closed the control stream before hello-ack');
245
+ for (const msg of decoder.push(value)) {
246
+ if (msg.t === 'hello-ack') {
247
+ helloAcked.resolve();
248
+ }
249
+ else if (msg.t === 'ping') {
250
+ // ponytail: u64 ping ids ride through as Numbers (exact < 2^53);
251
+ // byte-mirror the msgpack body if a gateway ever checks wider ids.
252
+ await ctrlWriter.write(encodeWtControlFrame({ t: 'pong', ts: msg.ts ?? 0, id: msg.id ?? 0 }));
253
+ }
254
+ }
255
+ }
256
+ };
257
+ pumpControl().catch((err) => {
258
+ helloAcked.reject(err);
259
+ this.signalClosed();
260
+ });
261
+ await helloAcked.promise;
262
+ // events stream: NDJSON, both directions, exact OpenAI JSON
263
+ const events = await wt.createBidirectionalStream();
264
+ this.writer = events.writable.getWriter();
265
+ const pumpEvents = async () => {
266
+ const reader = events.readable.getReader();
267
+ const decoder = new TextDecoder();
268
+ for (;;) {
269
+ const { value, done } = await reader.read();
270
+ if (done)
271
+ break;
272
+ for (const line of this.splitter.push(decoder.decode(value, { stream: true }))) {
273
+ this.messageCb?.(line);
274
+ }
275
+ }
276
+ };
277
+ pumpEvents()
278
+ .catch(() => undefined) // read side died — report closed below
279
+ .finally(() => this.signalClosed());
280
+ wt.closed.then(() => this.signalClosed()).catch(() => this.signalClosed());
281
+ }
282
+ signalClosed() {
283
+ if (this.closedSent)
284
+ return;
285
+ this.closedSent = true;
286
+ this.closeCb?.();
287
+ }
288
+ sendJson(obj) {
289
+ if (this.writer === null || this.closedSent)
290
+ throw new TransportDialError('quic wire closed');
291
+ // ponytail: no backpressure handling; the events channel is low-volume JSON.
292
+ this.writer.write(new TextEncoder().encode(JSON.stringify(obj) + '\n')).catch(() => this.signalClosed());
293
+ }
294
+ onMessage(cb) {
295
+ this.messageCb = cb;
296
+ }
297
+ onClose(cb) {
298
+ this.closeCb = cb;
299
+ }
300
+ async close() {
301
+ try {
302
+ this.wt?.close();
303
+ }
304
+ catch {
305
+ // already closed
306
+ }
307
+ this.signalClosed();
308
+ }
309
+ }
310
+ // ---------------------------------------------------------------------------
311
+ // WebRTC
312
+ // ---------------------------------------------------------------------------
313
+ export class RtcWire {
314
+ opts;
315
+ kind = 'webrtc';
316
+ messageCb = null;
317
+ closeCb = null;
318
+ pc = null;
319
+ dc = null;
320
+ static available() {
321
+ return globalCtor('RTCPeerConnection') !== undefined;
322
+ }
323
+ constructor(opts) {
324
+ this.opts = opts;
325
+ }
326
+ async connect() {
327
+ const RTCP = globalCtor('RTCPeerConnection');
328
+ if (RTCP === undefined)
329
+ throw new TransportDialError('no RTCPeerConnection on this platform');
330
+ // No STUN/TURN: the SFU is reached via host candidates on its announced ports.
331
+ const pc = new RTCP({ iceServers: [] });
332
+ this.pc = pc;
333
+ const dc = pc.createDataChannel('oai-events');
334
+ this.dc = dc;
335
+ // The SDK's media rides the event channel as JSON; the audio m-line exists
336
+ // so the SFU's sendrecv merge can answer. We listen only.
337
+ pc.addTransceiver('audio', { direction: 'recvonly' });
338
+ dc.onmessage = (ev) => {
339
+ const data = ev.data;
340
+ this.messageCb?.(typeof data === 'string' ? data : new TextDecoder().decode(data));
341
+ };
342
+ dc.onclose = () => this.closeCb?.();
343
+ pc.onconnectionstatechange = () => {
344
+ if (pc.connectionState === 'failed' || pc.connectionState === 'closed')
345
+ this.closeCb?.();
346
+ };
347
+ await pc.setLocalDescription(await pc.createOffer());
348
+ const res = await fetch(`${this.opts.apiBaseUrl.replace(/\/+$/, '')}/v1/realtime/calls?model=${encodeURIComponent(this.opts.model)}`, {
349
+ method: 'POST',
350
+ headers: { authorization: `Bearer ${this.opts.apiKey}`, 'content-type': 'application/sdp' },
351
+ body: pc.localDescription.sdp,
352
+ });
353
+ if (res.status !== 201) {
354
+ throw new TransportDialError(`POST /v1/realtime/calls -> ${res.status}: ${(await res.text()).slice(0, 200)}`);
355
+ }
356
+ await pc.setRemoteDescription({ type: 'answer', sdp: await res.text() });
357
+ await this.waitDcOpen();
358
+ }
359
+ async waitDcOpen() {
360
+ const { promise, resolve, reject } = Promise.withResolvers();
361
+ const dc = this.dc;
362
+ const pc = this.pc;
363
+ if (dc.readyState === 'open')
364
+ return;
365
+ const poll = setInterval(() => {
366
+ if (pc.connectionState === 'failed' || pc.iceConnectionState === 'failed') {
367
+ clearInterval(poll);
368
+ reject(new TransportDialError('peer connection failed before datachannel open'));
369
+ }
370
+ }, 100);
371
+ dc.onopen = () => {
372
+ clearInterval(poll);
373
+ resolve();
374
+ };
375
+ await promise;
376
+ }
377
+ sendJson(obj) {
378
+ if (this.dc === null || this.dc.readyState !== 'open')
379
+ throw new TransportDialError('webrtc wire closed');
380
+ this.dc.send(JSON.stringify(obj));
381
+ }
382
+ onMessage(cb) {
383
+ this.messageCb = cb;
384
+ }
385
+ onClose(cb) {
386
+ this.closeCb = cb;
387
+ }
388
+ async close() {
389
+ try {
390
+ this.dc?.close();
391
+ }
392
+ catch {
393
+ // already closed
394
+ }
395
+ try {
396
+ this.pc?.close();
397
+ }
398
+ catch {
399
+ // already closed
400
+ }
401
+ this.closeCb?.();
402
+ }
403
+ }
404
+ // ---------------------------------------------------------------------------
405
+ // selection
406
+ // ---------------------------------------------------------------------------
407
+ /** Dial order. Auto = quic → webrtc → ws, skipping platforms without the
408
+ * client stack. Explicit overrides dial exactly one. */
409
+ export function transportOrder(transport) {
410
+ if (transport !== 'auto')
411
+ return [transport];
412
+ const order = [];
413
+ if (QuicWire.available())
414
+ order.push('quic');
415
+ if (RtcWire.available())
416
+ order.push('webrtc');
417
+ order.push('ws');
418
+ return order;
419
+ }
420
+ export const defaultDialers = {
421
+ ws: dialWs,
422
+ quic: async (opts) => {
423
+ const wire = new QuicWire(opts);
424
+ try {
425
+ await wire.connect();
426
+ }
427
+ catch (err) {
428
+ await wire.close();
429
+ throw err;
430
+ }
431
+ return wire;
432
+ },
433
+ webrtc: async (opts) => {
434
+ const wire = new RtcWire(opts);
435
+ try {
436
+ await wire.connect();
437
+ }
438
+ catch (err) {
439
+ await wire.close();
440
+ throw err;
441
+ }
442
+ return wire;
443
+ },
444
+ };
445
+ export async function connectWire(opts, dialers = {}) {
446
+ const dial = (name) => dialers[name] ? dialers[name](opts) : defaultDialers[name](opts);
447
+ for (const name of transportOrder(opts.transport)) {
448
+ if (name === 'ws') {
449
+ console.info('[sense] transport=ws');
450
+ return await dial('ws');
451
+ }
452
+ const pending = dial(name);
453
+ try {
454
+ const wire = await withTimeout(pending, TRANSPORT_DIAL_TIMEOUT_MS[name], `transport=${name} dial`);
455
+ console.info(`[sense] transport=${name} live`);
456
+ return wire;
457
+ }
458
+ catch (err) {
459
+ // A late-resolving dial must not leak an orphaned live wire.
460
+ void pending.then((w) => w.close(), () => undefined);
461
+ if (opts.transport !== 'auto')
462
+ throw err;
463
+ console.info(`[sense] transport=${name} failed (${err instanceof Error ? err.message : String(err)}) — falling back`);
464
+ }
465
+ }
466
+ throw new TransportDialError('no usable transport (auto order exhausted)');
467
+ }
package/dist/wave.d.ts ADDED
@@ -0,0 +1,58 @@
1
+ /** Wave + results: the decoded answer set for one cadence tick (protocol §6). */
2
+ import type { Question } from "./question.js";
3
+ /** One question's answer in a wave. */
4
+ export declare class Result {
5
+ readonly id: string;
6
+ readonly kind: "noul" | "choice" | "score";
7
+ readonly waveId: number;
8
+ /** noul: boolean; choice: top option name; score: expected ordinal Σi·p. */
9
+ readonly value: boolean | string | number;
10
+ /** noul: P(true); choice: P(top); score: P(top level). */
11
+ readonly p: number;
12
+ readonly confidence: number;
13
+ /** Label-keyed probabilities (score: level names). */
14
+ readonly distribution: Record<string, number>;
15
+ /** argmax label ("true"/"false" for noul, option name, level name). */
16
+ readonly top: string;
17
+ constructor(init: {
18
+ id: string;
19
+ kind: "noul" | "choice" | "score";
20
+ waveId: number;
21
+ value: boolean | string | number;
22
+ p: number;
23
+ confidence: number;
24
+ distribution: Record<string, number>;
25
+ top: string;
26
+ });
27
+ pOption(name: string): number;
28
+ /** Only valid on score results: Σ p over levels >= the given level. */
29
+ pAtLeast(level: string): number;
30
+ }
31
+ /** Score result: ordinal distribution over ordered levels. */
32
+ export declare class Score extends Result {
33
+ readonly kind: "score";
34
+ readonly value: number;
35
+ readonly levels: string[];
36
+ /** argmax level name. */
37
+ readonly level: string;
38
+ constructor(init: {
39
+ id: string;
40
+ waveId: number;
41
+ probs: Record<string, number>;
42
+ levels: string[];
43
+ confidence: number;
44
+ });
45
+ pAtLeast(level: string): number;
46
+ }
47
+ /** One wave: exactly one answer per registered question (§6). */
48
+ export declare class Wave {
49
+ readonly waveId: number;
50
+ readonly tMs: number;
51
+ private readonly results;
52
+ constructor(waveId: number, tMs: number, results: Record<string, Result>);
53
+ result(qid: string): Result;
54
+ has(qid: string): boolean;
55
+ questionIds(): string[];
56
+ /** Decode one wire payload ({wave_id, t_ms, results}) against registered questions. */
57
+ static decode(questions: readonly Question[], payload: unknown): Wave;
58
+ }