@pyai/sdk 0.2.2 → 0.2.3

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 CHANGED
@@ -420,6 +420,17 @@ export interface OmniServerFrame {
420
420
  event: string;
421
421
  [k: string]: unknown;
422
422
  }
423
+ /** Canonical, sanitized transcript delivered by the native Omni demux. */
424
+ export interface OmniTranscriptFrame extends OmniServerFrame {
425
+ event: "transcript";
426
+ role: "user" | "assistant";
427
+ text: string;
428
+ final: boolean;
429
+ mode: "delta" | "replace";
430
+ sequence?: number;
431
+ }
432
+ /** Normalize the live UTF-8 0x02 body and the documented direct-object legacy shape. */
433
+ export declare function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFrame | null;
423
434
  /** A binary agent-audio chunk delivered to {@link OmniConnectOptions.onAudio}. */
424
435
  export type OmniAudioChunk = ArrayBuffer | ArrayBufferView | Blob;
425
436
  export interface OmniToolDef {
@@ -539,6 +550,8 @@ export declare class OmniConnection {
539
550
  private closed;
540
551
  /** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
541
552
  private blobTail;
553
+ /** Serializes inbound Blob decoding so browser frames stay ordered. */
554
+ private inboundTail;
542
555
  constructor(url: string, subprotocol: string, opts: OmniConnectOptions);
543
556
  private handleMessage;
544
557
  private dispatchFrame;
package/dist/index.js CHANGED
@@ -272,6 +272,71 @@ export const OmniEvent = {
272
272
  /** Server fault frame. */
273
273
  Error: "error",
274
274
  };
275
+ const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
276
+ const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
277
+ function omniTranscriptRole(value) {
278
+ if (value === "user" || value === "caller" || value === "human")
279
+ return "user";
280
+ if (value === "assistant" || value === "agent")
281
+ return "assistant";
282
+ return null;
283
+ }
284
+ function omniTranscriptText(value) {
285
+ return typeof value === "string"
286
+ && value.length > 0
287
+ && value.length <= OMNI_TRANSCRIPT_MAX_CHARS
288
+ && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
289
+ ? value
290
+ : null;
291
+ }
292
+ /** Normalize the live UTF-8 0x02 body and the documented direct-object legacy shape. */
293
+ export function normalizeOmniTranscriptBody(bytes) {
294
+ if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES)
295
+ return null;
296
+ let decoded;
297
+ try {
298
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
299
+ }
300
+ catch {
301
+ return null;
302
+ }
303
+ if (!decoded.trimStart().startsWith("{")) {
304
+ const text = omniTranscriptText(decoded);
305
+ return text
306
+ ? { event: "transcript", role: "user", text, final: false, mode: "delta" }
307
+ : null;
308
+ }
309
+ let value;
310
+ try {
311
+ value = JSON.parse(decoded);
312
+ }
313
+ catch {
314
+ return null;
315
+ }
316
+ if (!value || typeof value !== "object" || Array.isArray(value))
317
+ return null;
318
+ const payload = value;
319
+ const role = omniTranscriptRole(payload.role ?? payload.speaker);
320
+ const mode = typeof payload.delta === "string" ? "delta" : "replace";
321
+ const text = omniTranscriptText(mode === "delta" ? payload.delta : typeof payload.text === "string" ? payload.text : payload.transcript);
322
+ if (!role || !text)
323
+ return null;
324
+ if (payload.final !== undefined && typeof payload.final !== "boolean")
325
+ return null;
326
+ if (payload.sequence !== undefined
327
+ && (typeof payload.sequence !== "number"
328
+ || !Number.isSafeInteger(payload.sequence)
329
+ || payload.sequence < 0))
330
+ return null;
331
+ return {
332
+ event: "transcript",
333
+ role,
334
+ text,
335
+ final: payload.final === true,
336
+ mode,
337
+ ...(payload.sequence === undefined ? {} : { sequence: payload.sequence }),
338
+ };
339
+ }
275
340
  /** Extract a byte view from a binary WS frame (Buffer / ArrayBuffer / typed
276
341
  * array). Returns null for a Blob or unknown (can't be read synchronously). */
277
342
  function omniToBytes(data) {
@@ -324,6 +389,8 @@ export class OmniConnection {
324
389
  closed = false;
325
390
  /** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
326
391
  blobTail = Promise.resolve();
392
+ /** Serializes inbound Blob decoding so browser frames stay ordered. */
393
+ inboundTail = Promise.resolve();
327
394
  constructor(url, subprotocol, opts) {
328
395
  this.opts = opts;
329
396
  const WS = opts.webSocket ?? globalThis.WebSocket;
@@ -342,22 +409,34 @@ export class OmniConnection {
342
409
  }
343
410
  opts.onOpen?.();
344
411
  };
345
- this.ws.onmessage = (ev) => this.handleMessage(ev.data);
412
+ this.ws.onmessage = (ev) => {
413
+ const reportDecodeError = (error) => {
414
+ opts.onError?.(error instanceof Error ? error : new Error("Could not decode Omni frame"));
415
+ };
416
+ if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
417
+ this.inboundTail = this.inboundTail.then(() => this.handleMessage(ev.data)).catch(reportDecodeError);
418
+ }
419
+ else {
420
+ void this.handleMessage(ev.data).catch(reportDecodeError);
421
+ }
422
+ };
346
423
  this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
347
424
  this.ws.onclose = (ev) => {
348
425
  this.closed = true;
349
426
  opts.onClose?.(ev.code, ev.reason);
350
427
  };
351
428
  }
352
- handleMessage(data) {
429
+ async handleMessage(data) {
353
430
  // Server → client binary frames are TYPE-TAGGED by their first byte:
354
- // 0x01 = agent audio (PCM16) · 0x02 = transcript JSON · 0x03 = control JSON.
431
+ // 0x01 = agent audio (PCM16) · 0x02 = transcript UTF-8 · 0x03 = control JSON.
355
432
  // (Treating every binary frame as audio, the old behavior, plays the
356
433
  // 0x03/0x02 frames as a glitch and drops every event/transcript.)
357
434
  if (typeof data !== "string") {
358
- const bytes = omniToBytes(data);
435
+ const bytes = typeof Blob !== "undefined" && data instanceof Blob
436
+ ? new Uint8Array(await data.arrayBuffer())
437
+ : omniToBytes(data);
359
438
  if (!bytes) {
360
- this.opts.onAudio?.(data); // Blob/unknown, best-effort
439
+ this.opts.onError?.(new Error("Unsupported Omni binary frame"));
361
440
  return;
362
441
  }
363
442
  const tag = bytes[0];
@@ -365,18 +444,26 @@ export class OmniConnection {
365
444
  this.opts.onAudio?.(bytes.slice(1)); // copy → aligned PCM16
366
445
  return;
367
446
  }
368
- if (tag === 0x02 || tag === 0x03) {
447
+ if (tag === 0x02) {
448
+ const transcript = normalizeOmniTranscriptBody(bytes.subarray(1));
449
+ if (transcript)
450
+ this.dispatchFrame(transcript);
451
+ else
452
+ this.opts.onError?.(new Error("Unparseable Omni transcript frame"));
453
+ return;
454
+ }
455
+ if (tag === 0x03) {
369
456
  try {
370
457
  const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1)));
371
- // The 0x02 tag is authoritative for transcript even if the JSON omits `event`.
372
- this.dispatchFrame(tag === 0x02 ? { ...parsed, event: "transcript" } : parsed);
458
+ this.dispatchFrame(parsed);
373
459
  }
374
460
  catch {
375
461
  this.opts.onError?.(new Error("Unparseable Omni binary frame"));
376
462
  }
377
463
  return;
378
464
  }
379
- this.opts.onAudio?.(data); // untagged, forward-compat as audio
465
+ const tagName = tag === undefined ? "empty" : `0x${tag.toString(16).padStart(2, "0")}`;
466
+ this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
380
467
  return;
381
468
  }
382
469
  // Text frame (e.g. a server-side broker relay).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pyai/sdk",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Official TypeScript/JavaScript SDK for PyAI, speech-to-text (Hear), text-to-speech (Speak), realtime voice agents (Omni), and call compliance (Trace).",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/index.ts CHANGED
@@ -586,6 +586,78 @@ export interface OmniServerFrame {
586
586
  [k: string]: unknown;
587
587
  }
588
588
 
589
+ /** Canonical, sanitized transcript delivered by the native Omni demux. */
590
+ export interface OmniTranscriptFrame extends OmniServerFrame {
591
+ event: "transcript";
592
+ role: "user" | "assistant";
593
+ text: string;
594
+ final: boolean;
595
+ mode: "delta" | "replace";
596
+ sequence?: number;
597
+ }
598
+
599
+ const OMNI_TRANSCRIPT_MAX_BYTES = 16_384;
600
+ const OMNI_TRANSCRIPT_MAX_CHARS = 4_000;
601
+
602
+ function omniTranscriptRole(value: unknown): "user" | "assistant" | null {
603
+ if (value === "user" || value === "caller" || value === "human") return "user";
604
+ if (value === "assistant" || value === "agent") return "assistant";
605
+ return null;
606
+ }
607
+
608
+ function omniTranscriptText(value: unknown): string | null {
609
+ return typeof value === "string"
610
+ && value.length > 0
611
+ && value.length <= OMNI_TRANSCRIPT_MAX_CHARS
612
+ && !/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/.test(value)
613
+ ? value
614
+ : null;
615
+ }
616
+
617
+ /** Normalize the live UTF-8 0x02 body and the documented direct-object legacy shape. */
618
+ export function normalizeOmniTranscriptBody(bytes: Uint8Array): OmniTranscriptFrame | null {
619
+ if (bytes.byteLength === 0 || bytes.byteLength > OMNI_TRANSCRIPT_MAX_BYTES) return null;
620
+ let decoded: string;
621
+ try {
622
+ decoded = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
623
+ } catch {
624
+ return null;
625
+ }
626
+ if (!decoded.trimStart().startsWith("{")) {
627
+ const text = omniTranscriptText(decoded);
628
+ return text
629
+ ? { event: "transcript", role: "user", text, final: false, mode: "delta" }
630
+ : null;
631
+ }
632
+ let value: unknown;
633
+ try {
634
+ value = JSON.parse(decoded);
635
+ } catch {
636
+ return null;
637
+ }
638
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
639
+ const payload = value as Record<string, unknown>;
640
+ const role = omniTranscriptRole(payload.role ?? payload.speaker);
641
+ const mode = typeof payload.delta === "string" ? "delta" : "replace";
642
+ const text = omniTranscriptText(
643
+ mode === "delta" ? payload.delta : typeof payload.text === "string" ? payload.text : payload.transcript,
644
+ );
645
+ if (!role || !text) return null;
646
+ if (payload.final !== undefined && typeof payload.final !== "boolean") return null;
647
+ if (payload.sequence !== undefined
648
+ && (typeof payload.sequence !== "number"
649
+ || !Number.isSafeInteger(payload.sequence)
650
+ || payload.sequence < 0)) return null;
651
+ return {
652
+ event: "transcript",
653
+ role,
654
+ text,
655
+ final: payload.final === true,
656
+ mode,
657
+ ...(payload.sequence === undefined ? {} : { sequence: payload.sequence as number }),
658
+ };
659
+ }
660
+
589
661
  /** A binary agent-audio chunk delivered to {@link OmniConnectOptions.onAudio}. */
590
662
  export type OmniAudioChunk = ArrayBuffer | ArrayBufferView | Blob;
591
663
 
@@ -741,6 +813,8 @@ export class OmniConnection {
741
813
  private closed = false;
742
814
  /** Serializes async Blob reads in {@link sendAudio} so frames stay ordered. */
743
815
  private blobTail: Promise<void> = Promise.resolve();
816
+ /** Serializes inbound Blob decoding so browser frames stay ordered. */
817
+ private inboundTail: Promise<void> = Promise.resolve();
744
818
 
745
819
  constructor(url: string, subprotocol: string, opts: OmniConnectOptions) {
746
820
  this.opts = opts;
@@ -761,7 +835,16 @@ export class OmniConnection {
761
835
  }
762
836
  opts.onOpen?.();
763
837
  };
764
- this.ws.onmessage = (ev) => this.handleMessage(ev.data);
838
+ this.ws.onmessage = (ev) => {
839
+ const reportDecodeError = (error: unknown) => {
840
+ opts.onError?.(error instanceof Error ? error : new Error("Could not decode Omni frame"));
841
+ };
842
+ if (typeof Blob !== "undefined" && ev.data instanceof Blob) {
843
+ this.inboundTail = this.inboundTail.then(() => this.handleMessage(ev.data)).catch(reportDecodeError);
844
+ } else {
845
+ void this.handleMessage(ev.data).catch(reportDecodeError);
846
+ }
847
+ };
765
848
  this.ws.onerror = (ev) => opts.onError?.(ev instanceof Error ? ev : new Error("WebSocket error"));
766
849
  this.ws.onclose = (ev) => {
767
850
  this.closed = true;
@@ -769,15 +852,17 @@ export class OmniConnection {
769
852
  };
770
853
  }
771
854
 
772
- private handleMessage(data: unknown): void {
855
+ private async handleMessage(data: unknown): Promise<void> {
773
856
  // Server → client binary frames are TYPE-TAGGED by their first byte:
774
- // 0x01 = agent audio (PCM16) · 0x02 = transcript JSON · 0x03 = control JSON.
857
+ // 0x01 = agent audio (PCM16) · 0x02 = transcript UTF-8 · 0x03 = control JSON.
775
858
  // (Treating every binary frame as audio, the old behavior, plays the
776
859
  // 0x03/0x02 frames as a glitch and drops every event/transcript.)
777
860
  if (typeof data !== "string") {
778
- const bytes = omniToBytes(data);
861
+ const bytes = typeof Blob !== "undefined" && data instanceof Blob
862
+ ? new Uint8Array(await data.arrayBuffer())
863
+ : omniToBytes(data);
779
864
  if (!bytes) {
780
- this.opts.onAudio?.(data as OmniAudioChunk); // Blob/unknown, best-effort
865
+ this.opts.onError?.(new Error("Unsupported Omni binary frame"));
781
866
  return;
782
867
  }
783
868
  const tag = bytes[0];
@@ -785,17 +870,23 @@ export class OmniConnection {
785
870
  this.opts.onAudio?.(bytes.slice(1) as OmniAudioChunk); // copy → aligned PCM16
786
871
  return;
787
872
  }
788
- if (tag === 0x02 || tag === 0x03) {
873
+ if (tag === 0x02) {
874
+ const transcript = normalizeOmniTranscriptBody(bytes.subarray(1));
875
+ if (transcript) this.dispatchFrame(transcript);
876
+ else this.opts.onError?.(new Error("Unparseable Omni transcript frame"));
877
+ return;
878
+ }
879
+ if (tag === 0x03) {
789
880
  try {
790
881
  const parsed = JSON.parse(new TextDecoder().decode(bytes.subarray(1))) as OmniServerFrame;
791
- // The 0x02 tag is authoritative for transcript even if the JSON omits `event`.
792
- this.dispatchFrame(tag === 0x02 ? { ...parsed, event: "transcript" } : parsed);
882
+ this.dispatchFrame(parsed);
793
883
  } catch {
794
884
  this.opts.onError?.(new Error("Unparseable Omni binary frame"));
795
885
  }
796
886
  return;
797
887
  }
798
- this.opts.onAudio?.(data as OmniAudioChunk); // untagged, forward-compat as audio
888
+ const tagName = tag === undefined ? "empty" : `0x${tag.toString(16).padStart(2, "0")}`;
889
+ this.opts.onError?.(new Error(`Ignored unknown Omni binary frame tag ${tagName}`));
799
890
  return;
800
891
  }
801
892
  // Text frame (e.g. a server-side broker relay).