@runtypelabs/voice 0.2.3 → 0.3.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/persona.cjs CHANGED
@@ -20,6 +20,7 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
20
20
  // src/persona.ts
21
21
  var persona_exports = {};
22
22
  __export(persona_exports, {
23
+ bindVoiceArtifactsToPersona: () => bindVoiceArtifactsToPersona,
23
24
  createPersonaVoiceProvider: () => createPersonaVoiceProvider
24
25
  });
25
26
  module.exports = __toCommonJS(persona_exports);
@@ -169,9 +170,58 @@ function initialSnapshot() {
169
170
  error: null,
170
171
  errorDetails: void 0,
171
172
  interruptionMode: "none",
172
- canCancel: false
173
+ canCancel: false,
174
+ artifacts: [],
175
+ session: null
173
176
  };
174
177
  }
178
+ function readString(value) {
179
+ return typeof value === "string" && value ? value : void 0;
180
+ }
181
+ function readArtifactFile(value) {
182
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
183
+ const file = value;
184
+ const path = readString(file.path);
185
+ const mimeType = readString(file.mimeType);
186
+ if (!path || !mimeType) return void 0;
187
+ const language = readString(file.language);
188
+ return { path, mimeType, ...language ? { language } : {} };
189
+ }
190
+ function applyArtifactFrame(existing, frame, identity) {
191
+ if (frame.type === "artifact_start") {
192
+ const title = readString(frame.title);
193
+ const component = readString(frame.component);
194
+ const file = readArtifactFile(frame.file);
195
+ return {
196
+ ...identity,
197
+ artifactType: frame.artifactType === "component" ? "component" : "markdown",
198
+ ...title ? { title } : {},
199
+ ...file ? { file } : {},
200
+ ...component ? { component } : {},
201
+ content: "",
202
+ status: "streaming"
203
+ };
204
+ }
205
+ if (!existing) return void 0;
206
+ if (frame.type === "artifact_delta") {
207
+ const delta = typeof frame.delta === "string" ? frame.delta : "";
208
+ return delta ? { ...existing, content: existing.content + delta } : void 0;
209
+ }
210
+ if (frame.type === "artifact_update") {
211
+ const component = readString(frame.component) ?? existing.component;
212
+ const props = frame.props && typeof frame.props === "object" && !Array.isArray(frame.props) ? frame.props : existing.props;
213
+ return {
214
+ ...existing,
215
+ ...component ? { component } : {},
216
+ ...props ? { props } : {}
217
+ };
218
+ }
219
+ if (frame.type === "artifact_complete") return { ...existing, status: "complete" };
220
+ return void 0;
221
+ }
222
+ function readTurnId(msg) {
223
+ return typeof msg.turnId === "string" && msg.turnId ? msg.turnId : null;
224
+ }
175
225
  var VoiceClient = class {
176
226
  constructor(options) {
177
227
  this.options = options;
@@ -192,6 +242,16 @@ var VoiceClient = class {
192
242
  stoppedResponse = false;
193
243
  responseEnded = true;
194
244
  hasPendingAudio = false;
245
+ /**
246
+ * The assistant entry built from clause captions for the turn being spoken. The
247
+ * authoritative transcript replaces it; a stop commits it, because it captions
248
+ * audio the caller already heard.
249
+ */
250
+ pendingAssistant = null;
251
+ /** Turn whose clause captions were already committed by a stop, so its authoritative text is redundant. */
252
+ committedAssistantTurnId = null;
253
+ activeTurnId = null;
254
+ suppressedArtifactTurnId = null;
195
255
  getSnapshot = () => this.snapshot;
196
256
  subscribe = (listener) => {
197
257
  this.listeners.add(listener);
@@ -213,7 +273,7 @@ var VoiceClient = class {
213
273
  if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
214
274
  this.cleanup();
215
275
  const generation = this.generation;
216
- this.update({ ...initialSnapshot(), status: "connecting" });
276
+ this.update({ ...initialSnapshot(), session: this.snapshot.session, status: "connecting" });
217
277
  try {
218
278
  const token = tokenOverride ?? (typeof this.options.clientToken === "function" ? await this.options.clientToken() : this.options.clientToken);
219
279
  if (generation !== this.generation) return;
@@ -228,6 +288,20 @@ var VoiceClient = class {
228
288
  url.pathname = `${basePath}/ws/agents/${encodeURIComponent(this.options.agentId)}/voice`;
229
289
  url.search = "";
230
290
  url.searchParams.set("voiceProtocol", "runtype-browser-v1");
291
+ const capabilities = this.options.artifacts ? "partial_transcript,artifacts" : "partial_transcript";
292
+ url.searchParams.set("clientCapabilities", capabilities);
293
+ const sessionId = this.options.sessionId ?? this.snapshot.session?.sessionId;
294
+ if (sessionId) url.searchParams.set("sessionId", sessionId);
295
+ const visitorToken = typeof this.options.visitorToken === "function" ? await this.options.visitorToken() : this.options.visitorToken;
296
+ if (generation !== this.generation) return;
297
+ if (this.options.visitorToken !== void 0 && !visitorToken) {
298
+ throw new Error(
299
+ "Voice visitor credential unavailable. Initialize the client session first."
300
+ );
301
+ }
302
+ if (visitorToken && !/^cvt_[A-Za-z0-9_-]+$/.test(visitorToken)) {
303
+ throw new Error("Invalid voice visitor credential.");
304
+ }
231
305
  url.hash = "";
232
306
  const stream = await navigator.mediaDevices.getUserMedia({
233
307
  audio: { sampleRate: CAPTURE_SAMPLE_RATE, channelCount: 1, echoCancellation: true }
@@ -251,7 +325,11 @@ var VoiceClient = class {
251
325
  return;
252
326
  }
253
327
  this.player = player;
254
- const socket = new WebSocket(url.toString(), ["runtype.bearer", token]);
328
+ const socket = new WebSocket(url.toString(), [
329
+ "runtype.bearer",
330
+ token,
331
+ ...visitorToken ? [visitorToken] : []
332
+ ]);
255
333
  socket.binaryType = "arraybuffer";
256
334
  this.socket = socket;
257
335
  socket.onopen = () => {
@@ -282,6 +360,11 @@ var VoiceClient = class {
282
360
  this.cleanup();
283
361
  this.update({ status: "idle", interimTranscript: null, audioLevel: 0, isMuted: false });
284
362
  };
363
+ /** Forget the remembered conversation so the next startCall opens a new one. No-op during a call. */
364
+ resetSession = () => {
365
+ if (this.snapshot.status !== "idle" && this.snapshot.status !== "error") return;
366
+ this.update({ session: null });
367
+ };
285
368
  toggleMute = () => {
286
369
  this.update({ isMuted: !this.snapshot.isMuted, audioLevel: 0 });
287
370
  };
@@ -296,9 +379,11 @@ var VoiceClient = class {
296
379
  this.clearPlayback();
297
380
  if (this.snapshot.interruptionMode === "none") {
298
381
  this.stoppedResponse = !this.responseEnded;
382
+ this.commitClauseCaptions();
299
383
  this.setStatus("listening");
300
384
  return;
301
385
  }
386
+ this.suppressedArtifactTurnId = this.activeTurnId;
302
387
  this.awaitingClear = true;
303
388
  this.setStatus("listening");
304
389
  this.socket.send(JSON.stringify({ type: "cancel" }));
@@ -340,6 +425,82 @@ var VoiceClient = class {
340
425
  this.awaitingClear = false;
341
426
  this.stoppedResponse = false;
342
427
  this.responseEnded = true;
428
+ this.commitClauseCaptions();
429
+ this.committedAssistantTurnId = null;
430
+ this.activeTurnId = null;
431
+ this.suppressedArtifactTurnId = null;
432
+ const settledArtifacts = this.snapshot.artifacts.filter(
433
+ (artifact) => artifact.status === "complete"
434
+ );
435
+ if (settledArtifacts.length !== this.snapshot.artifacts.length)
436
+ this.update({ artifacts: settledArtifacts });
437
+ }
438
+ /**
439
+ * Folds an `artifact` message into the snapshot. Frames of a turn the caller
440
+ * cancelled are dropped; a playback-only stop leaves the turn running, so its
441
+ * artifacts keep arriving and are kept.
442
+ */
443
+ applyArtifactMessage(msg) {
444
+ if (this.awaitingClear) return;
445
+ const frame = msg.event;
446
+ if (!frame || typeof frame !== "object" || Array.isArray(frame)) return;
447
+ const event = frame;
448
+ const id = readString(event.id);
449
+ if (!id) return;
450
+ const turnId = readTurnId(msg);
451
+ if (turnId !== null && turnId === this.suppressedArtifactTurnId) return;
452
+ if (turnId !== null) this.activeTurnId = turnId;
453
+ const artifacts = this.snapshot.artifacts;
454
+ const index = artifacts.findIndex((artifact) => artifact.id === id);
455
+ const executionId = readString(msg.executionId);
456
+ const next = applyArtifactFrame(index === -1 ? void 0 : artifacts[index], event, {
457
+ id,
458
+ turnId,
459
+ ...executionId ? { executionId } : {}
460
+ });
461
+ if (!next) return;
462
+ this.update({
463
+ artifacts: index === -1 ? [...artifacts, next] : [...artifacts.slice(0, index), next, ...artifacts.slice(index + 1)]
464
+ });
465
+ }
466
+ /** Settles the spoken clauses in place and ends the turn's claim on them. */
467
+ commitClauseCaptions() {
468
+ this.committedAssistantTurnId = this.pendingAssistant?.turnId ?? null;
469
+ const settled = this.pendingAssistant !== null;
470
+ this.pendingAssistant = null;
471
+ if (!settled) return;
472
+ const transcript = this.snapshot.transcript;
473
+ const last = transcript[transcript.length - 1];
474
+ if (last?.role !== "assistant" || last.partial !== true) return;
475
+ const { partial: _partial, ...committed } = last;
476
+ this.update({ transcript: [...transcript.slice(0, -1), committed] });
477
+ }
478
+ /** Extends the turn's spoken-so-far assistant entry, or opens one for a new turn. */
479
+ appendClauseCaption(text, turnId) {
480
+ const transcript = this.snapshot.transcript;
481
+ const last = transcript[transcript.length - 1];
482
+ if (this.pendingAssistant?.turnId === turnId && last?.role === "assistant") {
483
+ return {
484
+ interimTranscript: null,
485
+ transcript: [...transcript.slice(0, -1), { ...last, content: last.content + text }],
486
+ status: "speaking"
487
+ };
488
+ }
489
+ this.pendingAssistant = { turnId };
490
+ return {
491
+ interimTranscript: null,
492
+ transcript: [
493
+ ...transcript,
494
+ {
495
+ role: "assistant",
496
+ content: text,
497
+ timestamp: Date.now(),
498
+ partial: true,
499
+ ...turnId ? { turnId } : {}
500
+ }
501
+ ],
502
+ status: "speaking"
503
+ };
343
504
  }
344
505
  clearPlayback() {
345
506
  this.playbackRevision += 1;
@@ -374,32 +535,61 @@ var VoiceClient = class {
374
535
  return;
375
536
  }
376
537
  switch (msg.type) {
377
- case "session_config":
538
+ case "session_config": {
378
539
  if (msg.interruptionMode === "none" || msg.interruptionMode === "cancel" || msg.interruptionMode === "barge-in") {
379
540
  this.update({ interruptionMode: msg.interruptionMode });
380
541
  }
542
+ const sessionId = readString(msg.sessionId);
543
+ const conversationId = readString(msg.conversationId);
544
+ if (sessionId && conversationId) {
545
+ const session = { sessionId, conversationId };
546
+ this.update({ session });
547
+ this.options.onSession?.(session);
548
+ }
549
+ break;
550
+ }
551
+ case "artifact":
552
+ this.applyArtifactMessage(msg);
381
553
  break;
382
554
  case "transcript_interim":
383
555
  this.update({ interimTranscript: typeof msg.text === "string" ? msg.text || null : null });
384
556
  break;
385
- case "transcript_final":
557
+ case "transcript_partial": {
558
+ if (msg.role !== "assistant" || typeof msg.text !== "string" || !msg.text) break;
559
+ this.activeTurnId = readTurnId(msg) ?? this.activeTurnId;
560
+ if (this.awaitingClear || this.stoppedResponse) break;
561
+ this.responseEnded = false;
562
+ this.update(this.appendClauseCaption(msg.text, readTurnId(msg)));
563
+ break;
564
+ }
565
+ case "transcript_final": {
386
566
  if (typeof msg.text !== "string" || msg.role !== "user" && msg.role !== "assistant") break;
387
- if (msg.role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
567
+ const role = msg.role;
568
+ const text = msg.text;
569
+ if (role === "assistant" && (this.awaitingClear || this.stoppedResponse)) break;
570
+ const turnId = readTurnId(msg);
571
+ if (turnId !== null) this.activeTurnId = turnId;
572
+ if (role === "assistant" && turnId !== null && turnId === this.committedAssistantTurnId)
573
+ break;
388
574
  this.responseEnded = false;
575
+ const transcript = this.snapshot.transcript;
576
+ const last = transcript[transcript.length - 1];
577
+ const supersedes = role === "assistant" && this.pendingAssistant !== null && last?.role === "assistant" && (turnId === null || this.pendingAssistant.turnId === null || this.pendingAssistant.turnId === turnId);
578
+ this.pendingAssistant = null;
579
+ const entry = {
580
+ role,
581
+ content: text,
582
+ // INVARIANT: reuse the caption's timestamp so a keyed list does not remount the bubble.
583
+ timestamp: supersedes && last ? last.timestamp : Date.now(),
584
+ ...turnId ? { turnId } : {}
585
+ };
389
586
  this.update({
390
587
  interimTranscript: null,
391
- transcript: [
392
- ...this.snapshot.transcript,
393
- {
394
- role: msg.role,
395
- content: msg.text,
396
- timestamp: Date.now(),
397
- ...typeof msg.turnId === "string" && msg.turnId ? { turnId: msg.turnId } : {}
398
- }
399
- ],
400
- status: this.awaitingClear ? this.snapshot.status : msg.role === "user" ? "thinking" : "speaking"
588
+ transcript: supersedes ? [...transcript.slice(0, -1), entry] : [...transcript, entry],
589
+ status: this.awaitingClear ? this.snapshot.status : role === "user" ? "thinking" : "speaking"
401
590
  });
402
591
  break;
592
+ }
403
593
  case "audio_end": {
404
594
  this.responseEnded = true;
405
595
  if (this.stoppedResponse) {
@@ -416,10 +606,12 @@ var VoiceClient = class {
416
606
  break;
417
607
  }
418
608
  case "audio_clear":
609
+ this.suppressedArtifactTurnId = this.activeTurnId ?? this.suppressedArtifactTurnId;
419
610
  this.clearPlayback();
420
611
  this.awaitingClear = false;
421
612
  this.stoppedResponse = false;
422
613
  this.responseEnded = true;
614
+ this.commitClauseCaptions();
423
615
  this.setStatus("listening");
424
616
  break;
425
617
  case "metrics": {
@@ -432,8 +624,11 @@ var VoiceClient = class {
432
624
  // @snake-case-ok: Existing voice wire contract.
433
625
  firstAudioMs: number(msg.first_audio_ms),
434
626
  // @snake-case-ok: Existing voice wire contract.
435
- totalMs: number(msg.total_ms)
627
+ totalMs: number(msg.total_ms),
628
+ // @snake-case-ok: Existing voice wire contract.
629
+ firstSynthesisMs: number(msg.first_synthesis_ms),
436
630
  // @snake-case-ok: Existing voice wire contract.
631
+ incremental: msg.incremental === true
437
632
  }
438
633
  });
439
634
  break;
@@ -495,6 +690,7 @@ function createPersonaVoiceProvider(options) {
495
690
  const transcriptCallbacks = /* @__PURE__ */ new Set();
496
691
  const metricsCallbacks = /* @__PURE__ */ new Set();
497
692
  const levelCallbacks = /* @__PURE__ */ new Set();
693
+ const artifactCallbacks = /* @__PURE__ */ new Set();
498
694
  let previous = client.getSnapshot();
499
695
  let unsubscribe = null;
500
696
  const onSnapshot = () => {
@@ -518,12 +714,18 @@ function createPersonaVoiceProvider(options) {
518
714
  for (const callback of transcriptCallbacks)
519
715
  callback("user", snapshot.interimTranscript, false);
520
716
  }
521
- for (const entry of snapshot.transcript.slice(before.transcript.length)) {
717
+ snapshot.artifacts.forEach((artifact, index) => {
718
+ if (artifact === before.artifacts[index]) return;
719
+ for (const callback of artifactCallbacks) callback(artifact);
720
+ });
721
+ snapshot.transcript.forEach((entry, index) => {
722
+ if (entry === before.transcript[index]) return;
723
+ const isFinal = entry.partial !== true;
522
724
  for (const callback of transcriptCallbacks) {
523
- if (entry.turnId) callback(entry.role, entry.content, true, { turnId: entry.turnId });
524
- else callback(entry.role, entry.content, true);
725
+ if (entry.turnId) callback(entry.role, entry.content, isFinal, { turnId: entry.turnId });
726
+ else callback(entry.role, entry.content, isFinal);
525
727
  }
526
- }
728
+ });
527
729
  };
528
730
  const subscribe = () => {
529
731
  if (unsubscribe) return;
@@ -544,6 +746,7 @@ function createPersonaVoiceProvider(options) {
544
746
  transcriptCallbacks.clear();
545
747
  metricsCallbacks.clear();
546
748
  levelCallbacks.clear();
749
+ artifactCallbacks.clear();
547
750
  },
548
751
  startListening: async () => {
549
752
  subscribe();
@@ -570,6 +773,9 @@ function createPersonaVoiceProvider(options) {
570
773
  onLevel: (callback) => {
571
774
  levelCallbacks.add(callback);
572
775
  },
776
+ onArtifact: (callback) => {
777
+ artifactCallbacks.add(callback);
778
+ },
573
779
  getInterruptionMode: () => client.getSnapshot().interruptionMode,
574
780
  isBargeInActive: () => !["idle", "error"].includes(client.getSnapshot().status),
575
781
  deactivateBargeIn: async () => {
@@ -578,3 +784,48 @@ function createPersonaVoiceProvider(options) {
578
784
  stopPlayback: client.stopPlayback
579
785
  };
580
786
  }
787
+ function toManualUpsert(artifact) {
788
+ const title = artifact.title ?? artifact.file?.path;
789
+ const transcript = artifact.status === "complete";
790
+ if (artifact.artifactType === "component") {
791
+ if (!artifact.component) return null;
792
+ return {
793
+ id: artifact.id,
794
+ artifactType: "component",
795
+ component: artifact.component,
796
+ ...title ? { title } : {},
797
+ ...artifact.props ? { props: artifact.props } : {},
798
+ transcript
799
+ };
800
+ }
801
+ return {
802
+ id: artifact.id,
803
+ artifactType: "markdown",
804
+ content: artifact.content,
805
+ ...title ? { title } : {},
806
+ ...artifact.file ? {
807
+ file: {
808
+ path: artifact.file.path,
809
+ mimeType: artifact.file.mimeType,
810
+ ...artifact.file.language ? { language: artifact.file.language } : {}
811
+ }
812
+ } : {},
813
+ transcript
814
+ };
815
+ }
816
+ function bindVoiceArtifactsToPersona(provider, widget, options) {
817
+ let released = false;
818
+ let shown = false;
819
+ provider.onArtifact((artifact) => {
820
+ if (released) return;
821
+ const manual = toManualUpsert(artifact);
822
+ if (!manual) return;
823
+ widget.upsertArtifact(manual);
824
+ if (shown || options?.showOnFirst === false) return;
825
+ shown = true;
826
+ widget.showArtifacts();
827
+ });
828
+ return () => {
829
+ released = true;
830
+ };
831
+ }
@@ -1,7 +1,26 @@
1
- import { VoiceProvider } from '@runtypelabs/persona';
2
- import { V as VoiceClientOptions } from './types-9SfA7oHc.cjs';
1
+ import { AgentWidgetInitHandle, VoiceProvider } from '@runtypelabs/persona';
2
+ import { b as VoiceArtifact, V as VoiceClientOptions } from './types-DaXyPeiV.cjs';
3
3
 
4
+ type ArtifactCallback = (artifact: VoiceArtifact) => void;
5
+ /**
6
+ * Persona 4.22 has no artifact hook on `VoiceProvider`, so the adapter adds its
7
+ * own `onArtifact`. Delivery to the widget is the host's, through the widget
8
+ * handle's `upsertArtifact` (see `bindVoiceArtifactsToPersona`).
9
+ */
10
+ type RuntypePersonaVoiceProvider = VoiceProvider & {
11
+ onArtifact(callback: ArtifactCallback): void;
12
+ };
13
+ /** The part of Persona's widget handle an artifact binding needs. */
14
+ type PersonaArtifactHost = Pick<AgentWidgetInitHandle, 'upsertArtifact' | 'showArtifacts'>;
4
15
  /** Adapt the shared client to Persona 4.22's custom voice provider API. */
5
- declare function createPersonaVoiceProvider(options: VoiceClientOptions): VoiceProvider;
16
+ declare function createPersonaVoiceProvider(options: VoiceClientOptions): RuntypePersonaVoiceProvider;
17
+ /**
18
+ * Streams a provider's artifacts into an already-initialized Persona widget. The
19
+ * artifact id is the upsert id, so each frame updates the same record in place,
20
+ * and the pane opens once on the first artifact. Returns a release function.
21
+ */
22
+ declare function bindVoiceArtifactsToPersona(provider: RuntypePersonaVoiceProvider, widget: PersonaArtifactHost, options?: {
23
+ showOnFirst?: boolean;
24
+ }): () => void;
6
25
 
7
- export { createPersonaVoiceProvider };
26
+ export { type PersonaArtifactHost, type RuntypePersonaVoiceProvider, bindVoiceArtifactsToPersona, createPersonaVoiceProvider };
package/dist/persona.d.ts CHANGED
@@ -1,7 +1,26 @@
1
- import { VoiceProvider } from '@runtypelabs/persona';
2
- import { V as VoiceClientOptions } from './types-9SfA7oHc.js';
1
+ import { AgentWidgetInitHandle, VoiceProvider } from '@runtypelabs/persona';
2
+ import { b as VoiceArtifact, V as VoiceClientOptions } from './types-DaXyPeiV.js';
3
3
 
4
+ type ArtifactCallback = (artifact: VoiceArtifact) => void;
5
+ /**
6
+ * Persona 4.22 has no artifact hook on `VoiceProvider`, so the adapter adds its
7
+ * own `onArtifact`. Delivery to the widget is the host's, through the widget
8
+ * handle's `upsertArtifact` (see `bindVoiceArtifactsToPersona`).
9
+ */
10
+ type RuntypePersonaVoiceProvider = VoiceProvider & {
11
+ onArtifact(callback: ArtifactCallback): void;
12
+ };
13
+ /** The part of Persona's widget handle an artifact binding needs. */
14
+ type PersonaArtifactHost = Pick<AgentWidgetInitHandle, 'upsertArtifact' | 'showArtifacts'>;
4
15
  /** Adapt the shared client to Persona 4.22's custom voice provider API. */
5
- declare function createPersonaVoiceProvider(options: VoiceClientOptions): VoiceProvider;
16
+ declare function createPersonaVoiceProvider(options: VoiceClientOptions): RuntypePersonaVoiceProvider;
17
+ /**
18
+ * Streams a provider's artifacts into an already-initialized Persona widget. The
19
+ * artifact id is the upsert id, so each frame updates the same record in place,
20
+ * and the pane opens once on the first artifact. Returns a release function.
21
+ */
22
+ declare function bindVoiceArtifactsToPersona(provider: RuntypePersonaVoiceProvider, widget: PersonaArtifactHost, options?: {
23
+ showOnFirst?: boolean;
24
+ }): () => void;
6
25
 
7
- export { createPersonaVoiceProvider };
26
+ export { type PersonaArtifactHost, type RuntypePersonaVoiceProvider, bindVoiceArtifactsToPersona, createPersonaVoiceProvider };