dsh-realtime-audio-ws 0.2.6 → 0.2.7

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.
@@ -402,8 +402,14 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
402
402
  const lines = [`<p data-dsh-strip-voice>${escapeHtml(summariseVoice(reply, state.audio))}</p>`];
403
403
  if (reply !== null)
404
404
  lines.push(`<p data-dsh-strip-route>${escapeHtml(summariseRoute(reply))}</p>`);
405
- if (state.notice !== '')
406
- lines.push(`<p data-dsh-strip-notice>${escapeHtml(state.notice)}</p>`);
405
+ if (state.notice !== '') {
406
+ const link = state.noticeLink ?? '';
407
+ const anchor = link === ''
408
+ ? ''
409
+ : ` <a data-dsh-strip-link href="${escapeHtml(link)}" target="_blank"`
410
+ + ' rel="noreferrer noopener">open ↗</a>';
411
+ lines.push(`<p data-dsh-strip-notice>${escapeHtml(state.notice)}${anchor}</p>`);
412
+ }
407
413
  lines.push(`<p data-dsh-strip-buttons>${buttons(state.audio)}</p>`);
408
414
  // Restart-bound settings are omitted rather than shown read-only: the gate says no control at all, and a
409
415
  // row that cannot be changed is a row a reader will try to change.
@@ -521,8 +527,10 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
521
527
  let reply = null;
522
528
  const notes = {};
523
529
  let notice = '';
530
+ /** The page the current notice points at, or empty. Set only where a refusal carried one. */
531
+ let noticeLink = '';
524
532
  const paint = () => {
525
- deps.root.innerHTML = renderStrip({ reply, notes, notice, audio: deps.audio() });
533
+ deps.root.innerHTML = renderStrip({ reply, notes, notice, noticeLink, audio: deps.audio() });
526
534
  };
527
535
  const refresh = async () => {
528
536
  try {
@@ -546,8 +554,19 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
546
554
  try {
547
555
  const answer = await deps.request(frame);
548
556
  // A refusal carries the reason written to be relayed verbatim, so it is relayed rather than
549
- // translated — including for a field frozen by its class.
550
- line = answer.ok ? done : `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`;
557
+ // translated — including for a field frozen by its class. A **session** refusal nests its own classified
558
+ // code, remedy and page and leaves the control-level `reason` unset, so reading only that answered the
559
+ // failure a user is most likely to meet with "refused: no reason given" — a panel saying nothing at the
560
+ // exact moment it knows what to do.
561
+ const refusal = answer.refusal;
562
+ if (!answer.ok && refusal !== undefined) {
563
+ noticeLink = refusal.link ?? '';
564
+ line = `${refusal.code ?? refusal.class ?? 'refused'}: ${refusal.remedy ?? 'no reason given'}`;
565
+ }
566
+ else {
567
+ noticeLink = '';
568
+ line = answer.ok ? done : `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`;
569
+ }
551
570
  }
552
571
  catch (error) {
553
572
  line = error instanceof Error ? error.message : String(error);
@@ -568,6 +587,7 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
568
587
  // Cleared per action: a notice from the last one is about the last one, and leaving it up would make
569
588
  // a successful reconnect read as a failed one.
570
589
  notice = '';
590
+ noticeLink = '';
571
591
  switch (action) {
572
592
  case 'refresh':
573
593
  await refresh();
@@ -598,7 +618,7 @@ window.__ModuleLoader__.load({ id: "dsh-realtime-audio-ws",
598
618
  void act();
599
619
  };
600
620
  deps.root.addEventListener('click', onClick);
601
- return { refresh, state: () => ({ reply, notes, notice, audio: deps.audio() }) };
621
+ return { refresh, state: () => ({ reply, notes, notice, noticeLink, audio: deps.audio() }) };
602
622
  }
603
623
  /**
604
624
  * The client plugin. Publishes the client and the strip on a global, and releases them with the plugin.
@@ -398,8 +398,14 @@ function renderStrip(state) {
398
398
  const lines = [`<p data-dsh-strip-voice>${escapeHtml(summariseVoice(reply, state.audio))}</p>`];
399
399
  if (reply !== null)
400
400
  lines.push(`<p data-dsh-strip-route>${escapeHtml(summariseRoute(reply))}</p>`);
401
- if (state.notice !== '')
402
- lines.push(`<p data-dsh-strip-notice>${escapeHtml(state.notice)}</p>`);
401
+ if (state.notice !== '') {
402
+ const link = state.noticeLink ?? '';
403
+ const anchor = link === ''
404
+ ? ''
405
+ : ` <a data-dsh-strip-link href="${escapeHtml(link)}" target="_blank"`
406
+ + ' rel="noreferrer noopener">open ↗</a>';
407
+ lines.push(`<p data-dsh-strip-notice>${escapeHtml(state.notice)}${anchor}</p>`);
408
+ }
403
409
  lines.push(`<p data-dsh-strip-buttons>${buttons(state.audio)}</p>`);
404
410
  // Restart-bound settings are omitted rather than shown read-only: the gate says no control at all, and a
405
411
  // row that cannot be changed is a row a reader will try to change.
@@ -517,8 +523,10 @@ function mountStrip(deps) {
517
523
  let reply = null;
518
524
  const notes = {};
519
525
  let notice = '';
526
+ /** The page the current notice points at, or empty. Set only where a refusal carried one. */
527
+ let noticeLink = '';
520
528
  const paint = () => {
521
- deps.root.innerHTML = renderStrip({ reply, notes, notice, audio: deps.audio() });
529
+ deps.root.innerHTML = renderStrip({ reply, notes, notice, noticeLink, audio: deps.audio() });
522
530
  };
523
531
  const refresh = async () => {
524
532
  try {
@@ -542,8 +550,19 @@ function mountStrip(deps) {
542
550
  try {
543
551
  const answer = await deps.request(frame);
544
552
  // A refusal carries the reason written to be relayed verbatim, so it is relayed rather than
545
- // translated — including for a field frozen by its class.
546
- line = answer.ok ? done : `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`;
553
+ // translated — including for a field frozen by its class. A **session** refusal nests its own classified
554
+ // code, remedy and page and leaves the control-level `reason` unset, so reading only that answered the
555
+ // failure a user is most likely to meet with "refused: no reason given" — a panel saying nothing at the
556
+ // exact moment it knows what to do.
557
+ const refusal = answer.refusal;
558
+ if (!answer.ok && refusal !== undefined) {
559
+ noticeLink = refusal.link ?? '';
560
+ line = `${refusal.code ?? refusal.class ?? 'refused'}: ${refusal.remedy ?? 'no reason given'}`;
561
+ }
562
+ else {
563
+ noticeLink = '';
564
+ line = answer.ok ? done : `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`;
565
+ }
547
566
  }
548
567
  catch (error) {
549
568
  line = error instanceof Error ? error.message : String(error);
@@ -564,6 +583,7 @@ function mountStrip(deps) {
564
583
  // Cleared per action: a notice from the last one is about the last one, and leaving it up would make
565
584
  // a successful reconnect read as a failed one.
566
585
  notice = '';
586
+ noticeLink = '';
567
587
  switch (action) {
568
588
  case 'refresh':
569
589
  await refresh();
@@ -594,7 +614,7 @@ function mountStrip(deps) {
594
614
  void act();
595
615
  };
596
616
  deps.root.addEventListener('click', onClick);
597
- return { refresh, state: () => ({ reply, notes, notice, audio: deps.audio() }) };
617
+ return { refresh, state: () => ({ reply, notes, notice, noticeLink, audio: deps.audio() }) };
598
618
  }
599
619
  /**
600
620
  * The client plugin. Publishes the client and the strip on a global, and releases them with the plugin.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-realtime-audio-ws",
3
- "version": "0.2.6",
3
+ "version": "0.2.7",
4
4
  "description": "The host end of the client half's transport: a WebSocket upgrade route that bridges microphone audio in and agent speech out.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -52,8 +52,8 @@
52
52
  },
53
53
  "dependencies": {
54
54
  "ws": "^8.22.0",
55
- "dsh-realtime-agent": "^0.2.10",
56
- "dsh-realtime": "^0.2.6"
55
+ "dsh-realtime": "^0.2.7",
56
+ "dsh-realtime-agent": "^0.2.11"
57
57
  },
58
58
  "peerDependencies": {
59
59
  "@deepseek-ai/cordis": "^4.0.3",
@@ -142,6 +142,19 @@ export interface ControlReplyLike {
142
142
  readonly verb?: string
143
143
  readonly code?: string
144
144
  readonly reason?: string
145
+ /**
146
+ * A *session* refusal, nested inside the reply.
147
+ *
148
+ * A control-level refusal sets `code` and `reason`; a session-level one carries its own classified code, the
149
+ * remedy written to be relayed, and the page the remedy is carried out on. Reading only the top level made
150
+ * the panel answer the failure a user is most likely to meet with "refused: no reason given".
151
+ */
152
+ readonly refusal?: {
153
+ readonly code?: string
154
+ readonly class?: string
155
+ readonly remedy?: string
156
+ readonly link?: string
157
+ }
145
158
  readonly key?: string
146
159
  readonly value?: unknown
147
160
  readonly voice?: { readonly open: boolean; readonly provider?: string; readonly model?: string; readonly sessionId?: string } | null
@@ -519,6 +532,15 @@ export interface StripState {
519
532
  readonly notes: Readonly<Record<string, string>>
520
533
  /** Anything with no field to belong to — a transport failure, or what a start or stop produced. */
521
534
  readonly notice: string
535
+ /**
536
+ * Where a notice points, when it points anywhere.
537
+ *
538
+ * A remedy that says "add credit" without saying where is half an instruction, and the URL lives in the
539
+ * provider's own message — which this design never carries. So it travels as its own field and is rendered
540
+ * as an anchor: the one `href` this panel emits, and it comes from the provider table, never from text a
541
+ * provider sent. Optional, because most notices point nowhere.
542
+ */
543
+ readonly noticeLink?: string
522
544
  /** What the microphone is doing. */
523
545
  readonly audio: ClientAudioState
524
546
  }
@@ -587,7 +609,14 @@ export function renderStrip(state: StripState): string {
587
609
  const reply = state.reply
588
610
  const lines = [`<p data-dsh-strip-voice>${escapeHtml(summariseVoice(reply, state.audio))}</p>`]
589
611
  if (reply !== null) lines.push(`<p data-dsh-strip-route>${escapeHtml(summariseRoute(reply))}</p>`)
590
- if (state.notice !== '') lines.push(`<p data-dsh-strip-notice>${escapeHtml(state.notice)}</p>`)
612
+ if (state.notice !== '') {
613
+ const link = state.noticeLink ?? ''
614
+ const anchor = link === ''
615
+ ? ''
616
+ : ` <a data-dsh-strip-link href="${escapeHtml(link)}" target="_blank"`
617
+ + ' rel="noreferrer noopener">open ↗</a>'
618
+ lines.push(`<p data-dsh-strip-notice>${escapeHtml(state.notice)}${anchor}</p>`)
619
+ }
591
620
  lines.push(`<p data-dsh-strip-buttons>${buttons(state.audio)}</p>`)
592
621
  // Restart-bound settings are omitted rather than shown read-only: the gate says no control at all, and a
593
622
  // row that cannot be changed is a row a reader will try to change.
@@ -711,9 +740,11 @@ export function mountStrip(deps: StripDeps): Strip {
711
740
  let reply: ControlReplyLike | null = null
712
741
  const notes: Record<string, string> = {}
713
742
  let notice = ''
743
+ /** The page the current notice points at, or empty. Set only where a refusal carried one. */
744
+ let noticeLink = ''
714
745
 
715
746
  const paint = (): void => {
716
- deps.root.innerHTML = renderStrip({ reply, notes, notice, audio: deps.audio() })
747
+ deps.root.innerHTML = renderStrip({ reply, notes, notice, noticeLink, audio: deps.audio() })
717
748
  }
718
749
 
719
750
  const refresh = async (): Promise<void> => {
@@ -738,8 +769,18 @@ export function mountStrip(deps: StripDeps): Strip {
738
769
  try {
739
770
  const answer = await deps.request(frame)
740
771
  // A refusal carries the reason written to be relayed verbatim, so it is relayed rather than
741
- // translated — including for a field frozen by its class.
742
- line = answer.ok ? done : `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`
772
+ // translated — including for a field frozen by its class. A **session** refusal nests its own classified
773
+ // code, remedy and page and leaves the control-level `reason` unset, so reading only that answered the
774
+ // failure a user is most likely to meet with "refused: no reason given" — a panel saying nothing at the
775
+ // exact moment it knows what to do.
776
+ const refusal = answer.refusal
777
+ if (!answer.ok && refusal !== undefined) {
778
+ noticeLink = refusal.link ?? ''
779
+ line = `${refusal.code ?? refusal.class ?? 'refused'}: ${refusal.remedy ?? 'no reason given'}`
780
+ } else {
781
+ noticeLink = ''
782
+ line = answer.ok ? done : `${answer.code ?? 'refused'}: ${answer.reason ?? 'no reason given'}`
783
+ }
743
784
  } catch (error) {
744
785
  line = error instanceof Error ? error.message : String(error)
745
786
  }
@@ -757,6 +798,7 @@ export function mountStrip(deps: StripDeps): Strip {
757
798
  // Cleared per action: a notice from the last one is about the last one, and leaving it up would make
758
799
  // a successful reconnect read as a failed one.
759
800
  notice = ''
801
+ noticeLink = ''
760
802
  switch (action) {
761
803
  case 'refresh': await refresh(); return
762
804
  case 'disconnect': deps.disconnect(); await refresh(); return
@@ -777,7 +819,7 @@ export function mountStrip(deps: StripDeps): Strip {
777
819
 
778
820
  deps.root.addEventListener('click', onClick)
779
821
 
780
- return { refresh, state: () => ({ reply, notes, notice, audio: deps.audio() }) }
822
+ return { refresh, state: () => ({ reply, notes, notice, noticeLink, audio: deps.audio() }) }
781
823
  }
782
824
 
783
825
  /**