@nac3/forge-cli 0.2.0-alpha.3 → 0.2.0-alpha.30

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.
Files changed (162) hide show
  1. package/dist/bin/yf.d.ts.map +1 -1
  2. package/dist/bin/yf.js +27 -0
  3. package/dist/bin/yf.js.map +1 -1
  4. package/dist/chat/claude.d.ts +22 -15
  5. package/dist/chat/claude.d.ts.map +1 -1
  6. package/dist/chat/claude.js +75 -22
  7. package/dist/chat/claude.js.map +1 -1
  8. package/dist/chat/panel.d.ts.map +1 -1
  9. package/dist/chat/panel.js +692 -17
  10. package/dist/chat/panel.js.map +1 -1
  11. package/dist/chat/server.js +630 -32
  12. package/dist/chat/server.js.map +1 -1
  13. package/dist/chat/spec_extract.d.ts.map +1 -1
  14. package/dist/chat/spec_extract.js +39 -0
  15. package/dist/chat/spec_extract.js.map +1 -1
  16. package/dist/chat/tools/audit_consumers.d.ts +66 -0
  17. package/dist/chat/tools/audit_consumers.d.ts.map +1 -0
  18. package/dist/chat/tools/audit_consumers.js +231 -0
  19. package/dist/chat/tools/audit_consumers.js.map +1 -0
  20. package/dist/chat/tools/git.js +4 -4
  21. package/dist/chat/tools/github.js +3 -3
  22. package/dist/chat/tools/lifecycle.js +3 -3
  23. package/dist/chat/tools/manual.js +1 -1
  24. package/dist/chat/tools/reader.js +8 -8
  25. package/dist/chat/tools/workflow.d.ts +45 -0
  26. package/dist/chat/tools/workflow.d.ts.map +1 -0
  27. package/dist/chat/tools/workflow.js +404 -0
  28. package/dist/chat/tools/workflow.js.map +1 -0
  29. package/dist/chat/tools.d.ts.map +1 -1
  30. package/dist/chat/tools.js +23 -4
  31. package/dist/chat/tools.js.map +1 -1
  32. package/dist/commands/approve.d.ts +32 -0
  33. package/dist/commands/approve.d.ts.map +1 -0
  34. package/dist/commands/approve.js +198 -0
  35. package/dist/commands/approve.js.map +1 -0
  36. package/dist/commands/block.d.ts +28 -0
  37. package/dist/commands/block.d.ts.map +1 -0
  38. package/dist/commands/block.js +189 -0
  39. package/dist/commands/block.js.map +1 -0
  40. package/dist/commands/bootstrap.d.ts +35 -0
  41. package/dist/commands/bootstrap.d.ts.map +1 -0
  42. package/dist/commands/bootstrap.js +205 -0
  43. package/dist/commands/bootstrap.js.map +1 -0
  44. package/dist/commands/chat.d.ts +3 -0
  45. package/dist/commands/chat.d.ts.map +1 -1
  46. package/dist/commands/chat.js +46 -1
  47. package/dist/commands/chat.js.map +1 -1
  48. package/dist/commands/clarify.d.ts +30 -0
  49. package/dist/commands/clarify.d.ts.map +1 -0
  50. package/dist/commands/clarify.js +671 -0
  51. package/dist/commands/clarify.js.map +1 -0
  52. package/dist/commands/discover.d.ts +30 -0
  53. package/dist/commands/discover.d.ts.map +1 -0
  54. package/dist/commands/discover.js +178 -0
  55. package/dist/commands/discover.js.map +1 -0
  56. package/dist/commands/doctor.js +94 -42
  57. package/dist/commands/doctor.js.map +1 -1
  58. package/dist/commands/keys_setup.d.ts +53 -0
  59. package/dist/commands/keys_setup.d.ts.map +1 -0
  60. package/dist/commands/keys_setup.js +487 -0
  61. package/dist/commands/keys_setup.js.map +1 -0
  62. package/dist/commands/legacy-audit.d.ts +34 -0
  63. package/dist/commands/legacy-audit.d.ts.map +1 -0
  64. package/dist/commands/legacy-audit.js +270 -0
  65. package/dist/commands/legacy-audit.js.map +1 -0
  66. package/dist/commands/license.d.ts.map +1 -1
  67. package/dist/commands/license.js +41 -0
  68. package/dist/commands/license.js.map +1 -1
  69. package/dist/commands/operate.d.ts +22 -0
  70. package/dist/commands/operate.d.ts.map +1 -0
  71. package/dist/commands/operate.js +523 -0
  72. package/dist/commands/operate.js.map +1 -0
  73. package/dist/commands/spec.d.ts +38 -0
  74. package/dist/commands/spec.d.ts.map +1 -0
  75. package/dist/commands/spec.js +256 -0
  76. package/dist/commands/spec.js.map +1 -0
  77. package/dist/commands/support.d.ts +22 -0
  78. package/dist/commands/support.d.ts.map +1 -0
  79. package/dist/commands/support.js +143 -0
  80. package/dist/commands/support.js.map +1 -0
  81. package/dist/commands/triage.d.ts +34 -0
  82. package/dist/commands/triage.d.ts.map +1 -0
  83. package/dist/commands/triage.js +228 -0
  84. package/dist/commands/triage.js.map +1 -0
  85. package/dist/commands/vault-inventory.d.ts +30 -0
  86. package/dist/commands/vault-inventory.d.ts.map +1 -0
  87. package/dist/commands/vault-inventory.js +214 -0
  88. package/dist/commands/vault-inventory.js.map +1 -0
  89. package/dist/commands/vault.d.ts.map +1 -1
  90. package/dist/commands/vault.js +5 -0
  91. package/dist/commands/vault.js.map +1 -1
  92. package/dist/commands/voice.js +1 -1
  93. package/dist/commands/voice.js.map +1 -1
  94. package/dist/commands/workflow-coverage.d.ts +30 -0
  95. package/dist/commands/workflow-coverage.d.ts.map +1 -0
  96. package/dist/commands/workflow-coverage.js +138 -0
  97. package/dist/commands/workflow-coverage.js.map +1 -0
  98. package/dist/core/keys_envelope.d.ts +13 -0
  99. package/dist/core/keys_envelope.d.ts.map +1 -1
  100. package/dist/core/keys_envelope.js.map +1 -1
  101. package/dist/deploy/adapter.d.ts +93 -0
  102. package/dist/deploy/adapter.d.ts.map +1 -0
  103. package/dist/deploy/adapter.js +42 -0
  104. package/dist/deploy/adapter.js.map +1 -0
  105. package/dist/deploy/aws_adapter.d.ts +28 -0
  106. package/dist/deploy/aws_adapter.d.ts.map +1 -0
  107. package/dist/deploy/aws_adapter.js +98 -0
  108. package/dist/deploy/aws_adapter.js.map +1 -0
  109. package/dist/deploy/cloudflare.d.ts +24 -0
  110. package/dist/deploy/cloudflare.d.ts.map +1 -0
  111. package/dist/deploy/cloudflare.js +169 -0
  112. package/dist/deploy/cloudflare.js.map +1 -0
  113. package/dist/license/hito4_client.d.ts +17 -1
  114. package/dist/license/hito4_client.d.ts.map +1 -1
  115. package/dist/license/hito4_client.js +71 -10
  116. package/dist/license/hito4_client.js.map +1 -1
  117. package/dist/license/index.d.ts.map +1 -1
  118. package/dist/license/index.js +7 -0
  119. package/dist/license/index.js.map +1 -1
  120. package/dist/license/sync.d.ts +54 -0
  121. package/dist/license/sync.d.ts.map +1 -0
  122. package/dist/license/sync.js +131 -0
  123. package/dist/license/sync.js.map +1 -0
  124. package/dist/support/reports.d.ts +31 -0
  125. package/dist/support/reports.d.ts.map +1 -0
  126. package/dist/support/reports.js +162 -0
  127. package/dist/support/reports.js.map +1 -0
  128. package/dist/telemetry/usage.d.ts +67 -0
  129. package/dist/telemetry/usage.d.ts.map +1 -0
  130. package/dist/telemetry/usage.js +208 -0
  131. package/dist/telemetry/usage.js.map +1 -0
  132. package/dist/version.d.ts +1 -1
  133. package/dist/version.d.ts.map +1 -1
  134. package/dist/version.js +1 -1
  135. package/dist/version.js.map +1 -1
  136. package/dist/voice/intents.d.ts +1 -1
  137. package/dist/voice/intents.js +0 -0
  138. package/dist/voice/providers/google.d.ts +9 -0
  139. package/dist/voice/providers/google.d.ts.map +1 -1
  140. package/dist/voice/providers/google.js +204 -28
  141. package/dist/voice/providers/google.js.map +1 -1
  142. package/dist/voice/router.d.ts +10 -0
  143. package/dist/voice/router.d.ts.map +1 -1
  144. package/dist/voice/router.js +39 -20
  145. package/dist/voice/router.js.map +1 -1
  146. package/dist/voice/types.d.ts +5 -2
  147. package/dist/voice/types.d.ts.map +1 -1
  148. package/dist/voice/types.js.map +1 -1
  149. package/dist/workflow/state.d.ts +190 -0
  150. package/dist/workflow/state.d.ts.map +1 -0
  151. package/dist/workflow/state.js +119 -0
  152. package/dist/workflow/state.js.map +1 -0
  153. package/package.json +13 -15
  154. package/templates/nextjs-app/README.md +48 -0
  155. package/templates/nextjs-app/next.config.js +8 -0
  156. package/templates/nextjs-app/package.json +33 -0
  157. package/templates/nextjs-app/src/app/globals.css +43 -0
  158. package/templates/nextjs-app/src/app/layout.tsx +29 -0
  159. package/templates/nextjs-app/src/app/page.tsx +63 -0
  160. package/templates/nextjs-app/src/nac/manifest.ts +36 -0
  161. package/templates/nextjs-app/tsconfig.json +21 -0
  162. package/templates/nextjs-app/yujin.forge.json +11 -0
@@ -237,6 +237,27 @@ body {
237
237
  }
238
238
  .header .actions button:hover { background: rgba(255,255,255,0.2); }
239
239
 
240
+ /* alpha.29 -- settings dropdown */
241
+ .settings-panel {
242
+ position: absolute; top: 56px; right: 16px; z-index: 50;
243
+ background: var(--bg-1); border: 1px solid rgba(255,255,255,0.15);
244
+ border-radius: 6px; padding: 10px; min-width: 280px;
245
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
246
+ display: flex; flex-direction: column; gap: 8px;
247
+ }
248
+ .settings-panel.hidden { display: none; }
249
+ .settings-panel .set-row {
250
+ display: flex; justify-content: space-between; align-items: center;
251
+ gap: 12px; font-size: 12px;
252
+ }
253
+ .settings-panel .set-lbl { color: var(--ink-2); font-weight: 500; }
254
+ .settings-panel .set-btn {
255
+ background: var(--bg-2); color: var(--ink); border: 1px solid rgba(255,255,255,0.15);
256
+ padding: 4px 8px; border-radius: 4px; font-size: 11px; cursor: pointer;
257
+ }
258
+ .settings-panel .set-btn:hover { background: rgba(255,255,255,0.1); }
259
+ .settings-panel .set-status { font-size: 11px; color: var(--ink-2); }
260
+
240
261
  /* ---- chat stream ---- */
241
262
  .stream { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
242
263
  .empty { color: var(--ink-muted); text-align: center; padding: 32px 16px; }
@@ -453,12 +474,29 @@ body {
453
474
  <div class="t"><span class="name">Yujin Forge</span><span class="proj" id="proj-full" data-nac-id="yujin.panel.project-full"></span></div>
454
475
  </div>
455
476
  <div class="actions">
477
+ <button class="settings-btn" data-settings-toggle aria-label="ajustes" title="Ajustes (modo voz, STT, TTS, claves)">⚙</button>
456
478
  <button class="lang-btn" data-lang-open aria-label="${escapeHtml(tr.languageButton)}" title="${escapeHtml(tr.languageButton)}">🌐</button>
457
479
  <button class="keys-btn" data-vault-open aria-label="${escapeHtml(tr.keysButton)}" title="${escapeHtml(tr.keysButton)}">${escapeHtml(tr.keysButton)}</button>
458
480
  <button id="full-mini" data-nac-id="yujin.panel.full-to-mini" aria-label="${escapeHtml(tr.minimiseButton)}" title="${escapeHtml(tr.minimiseButton)}">▭</button>
459
481
  <button id="full-close" data-nac-id="yujin.panel.full-close" aria-label="${escapeHtml(tr.closeButton)}" title="${escapeHtml(tr.closeButton)}">✕</button>
460
482
  </div>
461
483
  </div>
484
+ <!-- alpha.29: settings dropdown in topbar (voice mode, STT
485
+ provider, TTS voice, key status). Toggle via the cog. -->
486
+ <div id="yf-settings" class="hidden settings-panel">
487
+ <div class="set-row"><span class="set-lbl">Modo voz</span>
488
+ <button type="button" data-voice-mode-btn class="set-btn">modo mic</button>
489
+ </div>
490
+ <div class="set-row"><span class="set-lbl">STT</span>
491
+ <button type="button" data-stt-provider-btn class="set-btn">STT navegador</button>
492
+ </div>
493
+ <div class="set-row"><span class="set-lbl">Voz TTS</span>
494
+ <button type="button" data-tts-voice-btn class="set-btn">es-ES-Neural2-A (Lucia)</button>
495
+ </div>
496
+ <div class="set-row"><span class="set-lbl">Claves</span>
497
+ <span class="set-status" id="set-keys-status">cargando...</span>
498
+ </div>
499
+ </div>
462
500
  <div class="body">
463
501
  <div class="chat-col">
464
502
  <div id="stream-full" data-nac-id="yujin.chat.stream-full" class="stream"></div>
@@ -557,6 +595,19 @@ function renderMd(src) {
557
595
  out = out.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
558
596
  out = out.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
559
597
  out = out.replace(/\\n/g, '<br>');
598
+ /* Layer A.2 -- linkify file paths so the minority who wants to
599
+ * read code can click straight into their editor. Matches:
600
+ * src/foo/bar.ts
601
+ * tests/foo/bar.test.ts
602
+ * docs/SPEC.md
603
+ * packages/*\/src/*.ts
604
+ * Optionally with :line:col suffix. The result is a
605
+ * data-fpath link that the bound click handler resolves via
606
+ * /api/forge/open-in-editor. */
607
+ out = out.replace(
608
+ /(?<![\\w/])((?:src|tests|docs|packages|apps|scripts)\\/[\\w./-]+(?:\\.[\\w]+)(?::\\d+(?::\\d+)?)?)/g,
609
+ '<a class="fpath" data-fpath="$1" href="#" title="Abrir en mi editor">$1</a>',
610
+ );
560
611
  return out;
561
612
  }
562
613
 
@@ -577,6 +628,37 @@ function renderStream() {
577
628
  else div.textContent = m.content;
578
629
  target.appendChild(div);
579
630
  }
631
+ /* Layer A.2 -- bind click handlers on .fpath links to call
632
+ the open-in-editor endpoint + post a trust event. Idempotent
633
+ re-binding is safe because we replace innerHTML each turn. */
634
+ target.querySelectorAll('a.fpath').forEach(function (a) {
635
+ a.addEventListener('click', async function (ev) {
636
+ ev.preventDefault();
637
+ const p = a.getAttribute('data-fpath');
638
+ if (!p) return;
639
+ /* Strip trailing :line:col -- the editor opens at the file. */
640
+ const cleanPath = p.replace(/:\\d+(?::\\d+)?$/, '');
641
+ try {
642
+ const r = await fetch('/api/forge/open-in-editor', {
643
+ method: 'POST',
644
+ headers: { 'content-type': 'application/json' },
645
+ body: JSON.stringify({ path: cleanPath }),
646
+ });
647
+ const body = await r.json();
648
+ if (!body.ok && body.path) {
649
+ navigator.clipboard.writeText(body.path).catch(function () {});
650
+ alert('No editor en PATH. Copie el path: ' + body.path);
651
+ }
652
+ fetch('/api/forge/trust-event', {
653
+ method: 'POST',
654
+ headers: { 'content-type': 'application/json' },
655
+ body: JSON.stringify({ kind: 'editor_open' }),
656
+ }).catch(function () {});
657
+ } catch (e) {
658
+ /* network down -- ignore, the link is best-effort */
659
+ }
660
+ });
661
+ });
580
662
  }
581
663
  if (state.busy) {
582
664
  const d = document.createElement('div');
@@ -920,10 +1002,142 @@ async function vaultTest(slot, button) {
920
1002
  }
921
1003
 
922
1004
  /* ---- Voice integration (V1.6) ---- */
923
- const TTS_TOGGLE_KEY = 'yf-tts-replies-on';
1005
+ const TTS_TOGGLE_KEY = 'yf-tts-replies-on';
1006
+ const VOICE_MODE_KEY = 'yf-voice-mode'; /* 2026-05-31 -- 'mic' | 'auricular' */
924
1007
  let mediaRecorder = null;
925
1008
  let recordChunks = [];
926
1009
  let recordingPanelButton = null;
1010
+ /* 2026-05-31 half-duplex: when the user's last turn came through
1011
+ the mic (not the keyboard), auto-reopen the mic after TTS
1012
+ finishes -- so a conversation flows without push-to-talk every
1013
+ turn. If they typed, do NOT auto-open. Only applies in
1014
+ 'auricular' mode -- in 'mic' (push-to-talk) the user stays
1015
+ in control. */
1016
+ let _lastInputWasVoice = false;
1017
+ let _lastVoiceButton = null;
1018
+
1019
+ /* STT provider selector (alpha.28+). Default 'browser' = Web
1020
+ * Speech API (instant, free, native es-AR). 'google' = our
1021
+ * existing MediaRecorder + Google Cloud STT REST path. 'whisper'
1022
+ * = server routes to Whisper API.
1023
+ * Per Pablo (2026-05-31): "el default queda el del navegador,
1024
+ * pero quiero poder elegir Google u otro". */
1025
+ const STT_PROVIDER_KEY = 'yf-stt-provider';
1026
+ function sttProvider() {
1027
+ try {
1028
+ const v = localStorage.getItem(STT_PROVIDER_KEY);
1029
+ if (v === 'google' || v === 'whisper' || v === 'browser') return v;
1030
+ } catch (_) {}
1031
+ return 'browser';
1032
+ }
1033
+ function setSttProvider(p) {
1034
+ try { localStorage.setItem(STT_PROVIDER_KEY, p); } catch (_) {}
1035
+ updateSttProviderButtons();
1036
+ }
1037
+ function updateSttProviderButtons() {
1038
+ const p = sttProvider();
1039
+ const label = p === 'browser' ? 'STT navegador'
1040
+ : p === 'google' ? 'STT Google'
1041
+ : 'STT Whisper';
1042
+ document.querySelectorAll('[data-stt-provider-btn]').forEach((b) => {
1043
+ b.textContent = label;
1044
+ b.title = 'Click para alternar entre navegador (rapido + es-AR nativo + gratis), '
1045
+ + 'Google Cloud STT (server-side, BYOK) o Whisper (server-side, BYOK).';
1046
+ });
1047
+ }
1048
+ function cycleSttProvider() {
1049
+ const order = ['browser', 'google', 'whisper'];
1050
+ const i = order.indexOf(sttProvider());
1051
+ setSttProvider(order[(i + 1) % order.length]);
1052
+ }
1053
+
1054
+ /* alpha.29 -- TTS voice selection. Pablo: voz default debe ser
1055
+ * la que articule mejor las "s". es-ES (Spain) Neural2 voices
1056
+ * pronunciate finales claros vs es-US (Latin) que se las traga.
1057
+ * Also shows the active persona name in the settings panel. */
1058
+ const TTS_VOICE_KEY = 'yf-tts-voice';
1059
+ const TTS_VOICES = [
1060
+ { id: 'es-ES-Neural2-A', persona: 'Lucia (ES)', gender: 'F', lang: 'es-ES' },
1061
+ { id: 'es-ES-Neural2-B', persona: 'Javier (ES)', gender: 'M', lang: 'es-ES' },
1062
+ { id: 'es-ES-Neural2-C', persona: 'Marta (ES)', gender: 'F', lang: 'es-ES' },
1063
+ { id: 'es-ES-Neural2-D', persona: 'Carlos (ES)', gender: 'M', lang: 'es-ES' },
1064
+ { id: 'es-US-Neural2-A', persona: 'Sofia (LATAM)', gender: 'F', lang: 'es-US' },
1065
+ { id: 'es-US-Neural2-B', persona: 'Mateo (LATAM)', gender: 'M', lang: 'es-US' },
1066
+ { id: 'en-US-Neural2-C', persona: 'Emma (EN)', gender: 'F', lang: 'en-US' },
1067
+ ];
1068
+ function ttsVoice() {
1069
+ try {
1070
+ const v = localStorage.getItem(TTS_VOICE_KEY);
1071
+ if (v && TTS_VOICES.find((x) => x.id === v)) return v;
1072
+ } catch (_) {}
1073
+ return 'es-ES-Neural2-A'; /* default: Lucia ES -- articula las "s" */
1074
+ }
1075
+ function setTtsVoice(id) {
1076
+ try { localStorage.setItem(TTS_VOICE_KEY, id); } catch (_) {}
1077
+ updateTtsVoiceButtons();
1078
+ }
1079
+ function cycleTtsVoice() {
1080
+ const cur = ttsVoice();
1081
+ const i = TTS_VOICES.findIndex((v) => v.id === cur);
1082
+ const nxt = TTS_VOICES[(i + 1) % TTS_VOICES.length];
1083
+ setTtsVoice(nxt.id);
1084
+ }
1085
+ function updateTtsVoiceButtons() {
1086
+ const cur = ttsVoice();
1087
+ const meta = TTS_VOICES.find((v) => v.id === cur) || TTS_VOICES[0];
1088
+ document.querySelectorAll('[data-tts-voice-btn]').forEach((b) => {
1089
+ b.textContent = meta.id + ' (' + meta.persona + ')';
1090
+ b.title = 'Click para cambiar de voz. Persona actual: ' + meta.persona
1091
+ + ' (' + meta.gender + ') -- lengua ' + meta.lang;
1092
+ });
1093
+ }
1094
+
1095
+ /* Keys status fetcher: GETs /api/forge/keys-status (new endpoint)
1096
+ * and renders a short summary in the settings dropdown. */
1097
+ async function refreshKeysStatus() {
1098
+ const el = document.getElementById('set-keys-status');
1099
+ if (!el) return;
1100
+ el.textContent = 'cargando...';
1101
+ try {
1102
+ const r = await fetch('/api/forge/keys-status');
1103
+ const data = await r.json();
1104
+ if (!r.ok || !data.ok) {
1105
+ el.textContent = 'error: ' + (data.error || r.status);
1106
+ return;
1107
+ }
1108
+ const k = data.keys || {};
1109
+ const parts = [];
1110
+ parts.push((k.anthropic ? '✓' : '✗') + ' brain');
1111
+ parts.push((k.google_stt ? '✓' : '✗') + ' stt');
1112
+ parts.push((k.google_tts || k.elevenlabs ? '✓' : '✗') + ' tts');
1113
+ parts.push((k.license_paid ? '✓' : '✗') + ' license');
1114
+ el.textContent = parts.join(' · ');
1115
+ } catch (err) {
1116
+ el.textContent = 'error fetching: ' + (err && err.message ? err.message : 'unknown');
1117
+ }
1118
+ }
1119
+
1120
+ function voiceMode() {
1121
+ /* Default 'mic' = push-to-talk (preserves pre-2026-05-31 UX). */
1122
+ try {
1123
+ const v = localStorage.getItem(VOICE_MODE_KEY);
1124
+ return v === 'auricular' ? 'auricular' : 'mic';
1125
+ } catch (_) { return 'mic'; }
1126
+ }
1127
+ function setVoiceMode(mode) {
1128
+ try { localStorage.setItem(VOICE_MODE_KEY, mode === 'auricular' ? 'auricular' : 'mic'); } catch (_) {}
1129
+ updateVoiceModeButtons();
1130
+ }
1131
+ function updateVoiceModeButtons() {
1132
+ const mode = voiceMode();
1133
+ document.querySelectorAll('[data-voice-mode-btn]').forEach((b) => {
1134
+ b.setAttribute('aria-pressed', String(mode === 'auricular'));
1135
+ b.textContent = mode === 'auricular' ? 'modo auricular' : 'modo mic';
1136
+ b.title = mode === 'auricular'
1137
+ ? 'Manos libres -- el mic se reabre solo despues de que Yujin habla. Click para cambiar a push-to-talk.'
1138
+ : 'Push-to-talk -- apretas para hablar, soltas. Click para activar manos libres (auricular).';
1139
+ });
1140
+ }
927
1141
 
928
1142
  function ttsRepliesEnabled() {
929
1143
  try {
@@ -955,12 +1169,57 @@ function setMicButtonsState(state) {
955
1169
  });
956
1170
  }
957
1171
 
1172
+ /* 2026-05-31 -- Web Speech API path (browser-native STT).
1173
+ Mirrors the Pilot CRM implementation. When the browser
1174
+ exposes SpeechRecognition / webkitSpeechRecognition we use
1175
+ IT instead of MediaRecorder + Google Cloud STT REST. Why:
1176
+ - Instant (no server round-trip).
1177
+ - es-AR + es-CL + es-PE + every regional Spanish locale
1178
+ supported natively (no es-US mapping needed).
1179
+ - Free (the browser routes to Google STT for free under
1180
+ Chrome/Edge; Firefox uses its own backend).
1181
+ - Interim results (text appears as you speak).
1182
+ The Google Cloud STT path stays as a fallback for callers
1183
+ that explicitly want server-side recognition (e.g. audio
1184
+ pre-recorded files via the yf CLI). */
1185
+ let _webSpeechRecognition = null;
1186
+
1187
+ function _webSpeechAvailable() {
1188
+ return typeof window !== 'undefined'
1189
+ && (typeof window.SpeechRecognition !== 'undefined'
1190
+ || typeof window.webkitSpeechRecognition !== 'undefined');
1191
+ }
1192
+
1193
+ function _webSpeechLangFromDoc() {
1194
+ const raw = (document.documentElement.lang
1195
+ || navigator.language || 'es-AR').toString();
1196
+ /* Web Speech API takes the locale as-is, including es-AR.
1197
+ No normalisation needed. */
1198
+ return raw;
1199
+ }
1200
+
958
1201
  async function micToggle(button) {
1202
+ if (_webSpeechRecognition) {
1203
+ /* Already listening -> stop. */
1204
+ try { _webSpeechRecognition.stop(); } catch (_) {}
1205
+ return;
1206
+ }
959
1207
  if (mediaRecorder && mediaRecorder.state === 'recording') {
960
1208
  /* Stop -> onstop handler dispatches transcription. */
961
1209
  mediaRecorder.stop();
962
1210
  return;
963
1211
  }
1212
+ /* Provider-aware dispatch. Default 'browser' uses Web Speech
1213
+ API when available. 'google' / 'whisper' route through the
1214
+ server-side MediaRecorder + REST path. */
1215
+ const provider = sttProvider();
1216
+ if (provider === 'browser' && _webSpeechAvailable()) {
1217
+ return _micToggleWebSpeech(button);
1218
+ }
1219
+ if (provider === 'browser' && !_webSpeechAvailable()) {
1220
+ setStatus('Browser STT unavailable; using server-side fallback.');
1221
+ /* Fall through to MediaRecorder path. */
1222
+ }
964
1223
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
965
1224
  setStatus('Your browser does not support microphone access.', true);
966
1225
  return;
@@ -989,7 +1248,85 @@ async function micToggle(button) {
989
1248
  mediaRecorder.ondataavailable = (e) => {
990
1249
  if (e.data && e.data.size > 0) recordChunks.push(e.data);
991
1250
  };
1251
+
1252
+ /* alpha.29 -- Real VAD for the MediaRecorder path (Google /
1253
+ Whisper STT providers). Tap the same audio stream that
1254
+ MediaRecorder is consuming via AudioContext + AnalyserNode,
1255
+ compute time-domain RMS every 50ms. State machine:
1256
+ - 'pre-voice': waiting for the first sustained speech.
1257
+ - 'voiced': speech detected; resets silence countdown
1258
+ on every chunk above threshold.
1259
+ - 'closing': N ms of sustained silence -> stop().
1260
+ Per Pablo: needs to close as soon as silence falls, just
1261
+ like Web Speech does natively. */
1262
+ const VAD_RMS_THRESHOLD = 0.02; /* normalised 0..1 */
1263
+ const VAD_SILENCE_MS = 800;
1264
+ const VAD_MIN_VOICED_MS = 200;
1265
+ let _vadCtx = null;
1266
+ let _vadAnalyser = null;
1267
+ let _vadSrc = null;
1268
+ let _vadTimer = null;
1269
+ let _vadVoicedStart = 0;
1270
+ let _vadLastVoicedAt = 0;
1271
+ let _vadHasVoiced = false;
1272
+ try {
1273
+ const AC = (window.AudioContext || window.webkitAudioContext);
1274
+ if (AC) {
1275
+ _vadCtx = new AC();
1276
+ _vadSrc = _vadCtx.createMediaStreamSource(stream);
1277
+ _vadAnalyser = _vadCtx.createAnalyser();
1278
+ _vadAnalyser.fftSize = 512;
1279
+ _vadSrc.connect(_vadAnalyser);
1280
+ const buf = new Float32Array(_vadAnalyser.fftSize);
1281
+ _vadTimer = setInterval(() => {
1282
+ if (!mediaRecorder || mediaRecorder.state !== 'recording') return;
1283
+ _vadAnalyser.getFloatTimeDomainData(buf);
1284
+ let sumsq = 0;
1285
+ for (let i = 0; i < buf.length; i++) sumsq += buf[i] * buf[i];
1286
+ const rms = Math.sqrt(sumsq / buf.length);
1287
+ const now = Date.now();
1288
+ if (rms > VAD_RMS_THRESHOLD) {
1289
+ if (!_vadHasVoiced) _vadVoicedStart = now;
1290
+ _vadHasVoiced = (now - _vadVoicedStart) >= VAD_MIN_VOICED_MS || _vadHasVoiced;
1291
+ _vadLastVoicedAt = now;
1292
+ } else if (_vadHasVoiced && (now - _vadLastVoicedAt) > VAD_SILENCE_MS) {
1293
+ /* Voice detected earlier, then sustained silence: close. */
1294
+ try { mediaRecorder.stop(); } catch (_) {}
1295
+ }
1296
+ }, 50);
1297
+ }
1298
+ } catch (_) { /* VAD unavailable; mic still works manually */ }
1299
+ /* Cleanup hook -- onstop also clears the VAD interval. */
1300
+ const _vadCleanup = () => {
1301
+ if (_vadTimer) { clearInterval(_vadTimer); _vadTimer = null; }
1302
+ try { if (_vadSrc) _vadSrc.disconnect(); } catch (_) {}
1303
+ try { if (_vadCtx && _vadCtx.state !== 'closed') _vadCtx.close(); } catch (_) {}
1304
+ };
1305
+ /* 2026-05-31 -- Google STT sync API rejects audio > 1 min
1306
+ with HTTP 400 "Sync input too long". Cap the recording
1307
+ at 55s (5s safety margin) + visual countdown so the user
1308
+ knows + auto-stop when reached. To go past this we would
1309
+ need to upload to GCS + use LongRunningRecognize; out of
1310
+ scope today. */
1311
+ const MAX_RECORD_MS = 55_000;
1312
+ const stopAt = Date.now() + MAX_RECORD_MS;
1313
+ const countdown = setInterval(() => {
1314
+ if (!mediaRecorder || mediaRecorder.state !== 'recording') {
1315
+ clearInterval(countdown);
1316
+ return;
1317
+ }
1318
+ const secLeft = Math.max(0, Math.ceil((stopAt - Date.now()) / 1000));
1319
+ if (secLeft <= 10) {
1320
+ setStatus('Recording... auto-stop in ' + secLeft + 's');
1321
+ }
1322
+ if (secLeft <= 0) {
1323
+ clearInterval(countdown);
1324
+ try { mediaRecorder.stop(); } catch { /* ignore */ }
1325
+ setStatus('Recording auto-stopped at 55s (Google STT 1-min limit). Try shorter.', true);
1326
+ }
1327
+ }, 1000);
992
1328
  mediaRecorder.onstop = async () => {
1329
+ _vadCleanup();
993
1330
  stream.getTracks().forEach((t) => t.stop());
994
1331
  const blob = new Blob(recordChunks, { type: mimeType || 'audio/webm' });
995
1332
  mediaRecorder = null;
@@ -998,6 +1335,14 @@ async function micToggle(button) {
998
1335
  setStatus('No audio captured. Try again.', true);
999
1336
  return;
1000
1337
  }
1338
+ /* Client-side size guard. 4 MB ~ 60s of WebM/Opus at
1339
+ 32 kbps which already trips the Google sync limit.
1340
+ Refuse before even hitting the network. */
1341
+ if (blob.size > 4_000_000) {
1342
+ setMicButtonsState('idle');
1343
+ setStatus('Audio too long (>1 min). Google STT sync limit. Try shorter.', true);
1344
+ return;
1345
+ }
1001
1346
  setMicButtonsState('processing');
1002
1347
  try {
1003
1348
  const buf = await blob.arrayBuffer();
@@ -1005,6 +1350,18 @@ async function micToggle(button) {
1005
1350
  'content-type': 'application/octet-stream',
1006
1351
  'x-audio-format': serverFormat,
1007
1352
  };
1353
+ /* 2026-05-31 FIX: tell the server which language the
1354
+ user is speaking. Without this header the Google STT
1355
+ provider falls back to en-US, which silently mangles
1356
+ Spanish input ("No me escucha" symptom in alpha.19).
1357
+ Source-of-truth priority:
1358
+ 1. <html lang="..."> (set by the panel via i18n).
1359
+ 2. navigator.language (browser locale).
1360
+ 3. 'es-AR' as a last-resort default for our market. */
1361
+ const langGuess = (document.documentElement.lang
1362
+ || navigator.language
1363
+ || 'es-AR').toString();
1364
+ if (langGuess) headers['x-audio-language'] = langGuess;
1008
1365
  /* V1.32 -- carry the active reader doc_id so the
1009
1366
  matcher can resolve commands like "siguiente" /
1010
1367
  "buscar X" against the currently-open document. */
@@ -1026,6 +1383,11 @@ async function micToggle(button) {
1026
1383
  setStatus('Did not catch that -- try again.', true);
1027
1384
  return;
1028
1385
  }
1386
+ /* Mark this turn as voice-originated so playTtsForText
1387
+ knows to reopen the mic afterwards (half-duplex
1388
+ conversational mode). */
1389
+ _lastInputWasVoice = true;
1390
+ _lastVoiceButton = recordingPanelButton;
1029
1391
  /* V1.32 -- if the matcher recognised a reader command,
1030
1392
  dispatch it directly (no Claude round-trip). Otherwise
1031
1393
  the transcript flows into the chat input as before. */
@@ -1093,7 +1455,7 @@ async function dispatchReaderIntent(utterance, intent) {
1093
1455
  }
1094
1456
  /* Update the active reader doc on a successful open so
1095
1457
  subsequent voice commands ("siguiente", etc) target it. */
1096
- if (intent.tool === 'forge.reader.open' && !data.is_error) {
1458
+ if (intent.tool === 'forge_reader_open' && !data.is_error) {
1097
1459
  const docId = data.result && data.result.doc_id;
1098
1460
  if (docId) state.activeReaderDocId = String(docId);
1099
1461
  }
@@ -1212,10 +1574,25 @@ function splitIntoTtsChunks(text, opts) {
1212
1574
  * status bar). */
1213
1575
  async function _fetchTtsChunk(text) {
1214
1576
  try {
1577
+ /* Pass current panel language so the server can pick a voice
1578
+ * persona that matches (es-ES, en-US, etc). The TTS endpoint
1579
+ * resolves voice from { language } if no explicit voice given. */
1580
+ const lang = (document && document.documentElement && document.documentElement.lang)
1581
+ ? document.documentElement.lang : undefined;
1582
+ /* 2026-05-31 -- speakingRate 1.05 for Spanish (mirror Pilot
1583
+ * CRM). Neural2 voices at rate 1.0 tend to swallow final
1584
+ * "s" in Latin-American Spanish; +5% rate makes the voice
1585
+ * articulate each syllable + drops the dropped-s symptom. */
1586
+ let speed;
1587
+ if (lang && /^es(-|$)/i.test(lang)) speed = 1.05;
1588
+ const voiceId = ttsVoice();
1589
+ const body = lang ? { text, language: lang } : { text };
1590
+ if (speed) body.speed = speed;
1591
+ if (voiceId) body.voice = voiceId;
1215
1592
  const r = await fetch('/api/voice/tts', {
1216
1593
  method: 'POST',
1217
1594
  headers: { 'content-type': 'application/json' },
1218
- body: JSON.stringify({ text }),
1595
+ body: JSON.stringify(body),
1219
1596
  });
1220
1597
  if (!r.ok) {
1221
1598
  const data = await r.json().catch(() => ({}));
@@ -1311,7 +1688,7 @@ async function ingestSpecFile(file) {
1311
1688
  state.toolHistory.push({
1312
1689
  turnIndex: state.messages.length - 1,
1313
1690
  rounds: [{
1314
- tool: 'forge.spec.ingest',
1691
+ tool: 'forge_spec_ingest',
1315
1692
  input: { filename: ing.filename, format: ing.format },
1316
1693
  display: 'parsed ' + ing.format + ': ' + (ing.title || ing.filename),
1317
1694
  is_error: false,
@@ -1326,24 +1703,182 @@ async function ingestSpecFile(file) {
1326
1703
  }
1327
1704
  }
1328
1705
 
1706
+ /** Strip markdown emphasis + decorative characters so the TTS
1707
+ * reads fluidly. Without this Forge's chat output (asterisks,
1708
+ * code ticks, dashes, emojis) was being read literally as
1709
+ * "asterisco asterisco". Output keeps acentos + n-tilde + open
1710
+ * punctuation chars -- those ARE pronounced. */
1711
+ function cleanForTts(raw) {
1712
+ let s = String(raw || '');
1713
+ /* Code fences first (greedy, capture content but strip the
1714
+ fence markers + any language hint). */
1715
+ s = s.replace(/\`\`\`[a-zA-Z0-9_-]*\\n?([\\s\\S]*?)\`\`\`/g, ' $1 ');
1716
+ /* Inline code: backticks -> nothing (just keep the content). */
1717
+ s = s.replace(/\`([^\`]+)\`/g, '$1');
1718
+ /* Markdown links: [label](url) -> "label". */
1719
+ s = s.replace(/\\[([^\\]]+)\\]\\([^)]+\\)/g, '$1');
1720
+ /* Bold + italic + strikethrough markers -> nothing. */
1721
+ s = s.replace(/\\*+([^*]+?)\\*+/g, '$1');
1722
+ s = s.replace(/_+([^_]+?)_+/g, '$1');
1723
+ s = s.replace(/~+([^~]+?)~+/g, '$1');
1724
+ /* Standalone emphasis or decorative chars at any position. */
1725
+ s = s.replace(/[*_~\`]+/g, '');
1726
+ /* Heading hashes at line start. */
1727
+ s = s.replace(/^#+\\s*/gm, '');
1728
+ /* Bullet list markers at line start (- + *). */
1729
+ s = s.replace(/^\\s*[-+]\\s+/gm, '');
1730
+ /* Horizontal rules -> period. */
1731
+ s = s.replace(/^[-*_=]{3,}$/gm, '.');
1732
+ /* Emojis + symbols ranges (broad pass; leave latin-1 supplement
1733
+ intact so acentos survive). */
1734
+ s = s.replace(/[\\u{1F300}-\\u{1FAFF}]/gu, '');
1735
+ s = s.replace(/[\\u{2600}-\\u{27BF}]/gu, '');
1736
+ /* Multiple punctuation -> single period. */
1737
+ s = s.replace(/[.!?]{2,}/g, '.');
1738
+ /* Multiple spaces / newlines -> single space. */
1739
+ s = s.replace(/\\s+/g, ' ');
1740
+ return s.trim();
1741
+ }
1742
+
1743
+ /* 2026-05-31 half-duplex fix (per Pablo: "el audio no debe quedar
1744
+ full duplex"). When TTS is playing, the mic MUST be closed so
1745
+ we do not capture Yujin's own voice as if it were user input.
1746
+ That feedback loop is what made STT receive >1 min audio and
1747
+ trip the Google 1-min sync limit.
1748
+
1749
+ Implementation: closeMicForTts() stops any in-progress
1750
+ recording before TTS starts. If the user had push-to-talked,
1751
+ the audio they captured BEFORE TTS started is already in
1752
+ flight; we just prevent NEW captures. */
1753
+ function closeMicForTts() {
1754
+ if (_webSpeechRecognition) {
1755
+ try { _webSpeechRecognition.abort(); } catch (_) {}
1756
+ _webSpeechRecognition = null;
1757
+ setStatus('Mic closed while Yujin speaks (half-duplex).');
1758
+ }
1759
+ if (mediaRecorder && mediaRecorder.state === 'recording') {
1760
+ try { mediaRecorder.stop(); } catch { /* ignore */ }
1761
+ setStatus('Mic closed while Yujin speaks (half-duplex).');
1762
+ }
1763
+ }
1764
+
1765
+ async function _micToggleWebSpeech(button) {
1766
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
1767
+ recordingPanelButton = button;
1768
+ const rec = new SR();
1769
+ rec.continuous = false;
1770
+ rec.interimResults = true;
1771
+ rec.lang = _webSpeechLangFromDoc();
1772
+ let finalText = '';
1773
+ let interim = '';
1774
+ /* 2026-05-31 -- Aggressive silence cutoff. Chrome's default
1775
+ silence timeout for SpeechRecognition is ~3s; per Pablo
1776
+ "cerrar el mic mas rapido". We piggyback on interimResults:
1777
+ every time interim text changes we reset a timer; if the
1778
+ timer fires (no interim activity for SILENCE_MS) we stop
1779
+ the recogniser. */
1780
+ const SILENCE_MS = 900;
1781
+ let silenceTimer = null;
1782
+ const resetSilenceTimer = () => {
1783
+ if (silenceTimer) clearTimeout(silenceTimer);
1784
+ silenceTimer = setTimeout(() => {
1785
+ try { rec.stop(); } catch (_) {}
1786
+ }, SILENCE_MS);
1787
+ };
1788
+ rec.onresult = (e) => {
1789
+ interim = '';
1790
+ for (let i = e.resultIndex; i < e.results.length; i += 1) {
1791
+ const r = e.results[i];
1792
+ if (r.isFinal) finalText += r[0].transcript;
1793
+ else interim += r[0].transcript;
1794
+ }
1795
+ if (interim) setStatus('Listening... ' + interim);
1796
+ /* Activity heard -> reset the silence countdown. */
1797
+ resetSilenceTimer();
1798
+ };
1799
+ rec.onerror = (e) => {
1800
+ const errKind = (e && e.error) ? String(e.error) : 'unknown';
1801
+ if (errKind === 'no-speech' || errKind === 'aborted') {
1802
+ setStatus('');
1803
+ } else {
1804
+ setStatus('Mic error: ' + errKind, true);
1805
+ }
1806
+ };
1807
+ rec.onend = () => {
1808
+ if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null; }
1809
+ _webSpeechRecognition = null;
1810
+ setMicButtonsState('idle');
1811
+ const text = (finalText || '').trim();
1812
+ if (!text) {
1813
+ if (!interim) setStatus('Did not catch that -- try again.', true);
1814
+ return;
1815
+ }
1816
+ _lastInputWasVoice = true;
1817
+ _lastVoiceButton = recordingPanelButton;
1818
+ /* Inject + submit, same as the Google-STT path. */
1819
+ const inputSel = state.mode === 'full' ? '#bar-full input' : '#bar-mini input';
1820
+ const input = $(inputSel);
1821
+ if (input) {
1822
+ input.value = text;
1823
+ send(text);
1824
+ input.value = '';
1825
+ }
1826
+ };
1827
+ try {
1828
+ _webSpeechRecognition = rec;
1829
+ rec.start();
1830
+ setMicButtonsState('recording');
1831
+ setStatus('Listening (web speech)...');
1832
+ /* IMPORTANT (alpha.29 bugfix): DO NOT arm the silence timer
1833
+ here. If we did, and the user takes >900ms to start
1834
+ talking (typical when they click + think), the timer would
1835
+ fire and rec.stop() before any audio arrived ->
1836
+ 'Did not catch that'. The timer is armed by onresult
1837
+ once the FIRST interim/final result lands. */
1838
+ } catch (err) {
1839
+ _webSpeechRecognition = null;
1840
+ setStatus('Could not start mic: ' + (err && err.message ? err.message : String(err)), true);
1841
+ }
1842
+ }
1843
+
1329
1844
  async function playTtsForText(text) {
1845
+ text = cleanForTts(text);
1330
1846
  if (!ttsRepliesEnabled()) return;
1331
1847
  if (!text || text.trim() === '') return;
1848
+ /* HALF-DUPLEX GATE: close mic before TTS starts. */
1849
+ closeMicForTts();
1850
+ const wasVoiceTurn = _lastInputWasVoice;
1851
+ /* Reset so the next turn re-decides; e.g. user can switch
1852
+ to keyboard mid-conversation and the auto-reopen stops. */
1853
+ _lastInputWasVoice = false;
1854
+ const reopenBtn = _lastVoiceButton;
1332
1855
  const chunks = splitIntoTtsChunks(text);
1333
1856
  if (chunks.length === 0) return;
1334
- if (chunks.length === 1) {
1335
- const url = await _fetchTtsChunk(chunks[0]);
1336
- if (url) await _playChunkUrl(url);
1337
- return;
1338
- }
1339
- /* Pipeline: prefetch chunk N+1 while chunk N plays. */
1340
- let nextFetch = _fetchTtsChunk(chunks[0]);
1341
- for (let i = 0; i < chunks.length; i += 1) {
1342
- const url = await nextFetch;
1343
- /* Kick off the next fetch BEFORE awaiting playback so the
1344
- network round-trip overlaps the audio. */
1345
- nextFetch = (i + 1 < chunks.length) ? _fetchTtsChunk(chunks[i + 1]) : Promise.resolve(null);
1346
- if (url) await _playChunkUrl(url);
1857
+ try {
1858
+ if (chunks.length === 1) {
1859
+ const url = await _fetchTtsChunk(chunks[0]);
1860
+ if (url) await _playChunkUrl(url);
1861
+ } else {
1862
+ /* Pipeline: prefetch chunk N+1 while chunk N plays. */
1863
+ let nextFetch = _fetchTtsChunk(chunks[0]);
1864
+ for (let i = 0; i < chunks.length; i += 1) {
1865
+ const url = await nextFetch;
1866
+ nextFetch = (i + 1 < chunks.length) ? _fetchTtsChunk(chunks[i + 1]) : Promise.resolve(null);
1867
+ if (url) await _playChunkUrl(url);
1868
+ }
1869
+ }
1870
+ } finally {
1871
+ /* 2026-05-31 half-duplex auto-reopen. Only in 'auricular'
1872
+ mode (manos libres). In 'mic' mode (push-to-talk) we
1873
+ respect the user's explicit gesture and do NOT reopen.
1874
+ Tiny pause (200ms) so the TTS audio tail does not feed
1875
+ back into the next capture. */
1876
+ if (wasVoiceTurn && reopenBtn && voiceMode() === 'auricular') {
1877
+ setTimeout(() => {
1878
+ if (mediaRecorder && mediaRecorder.state === 'recording') return;
1879
+ try { micToggle(reopenBtn); } catch { /* ignore */ }
1880
+ }, 200);
1881
+ }
1347
1882
  }
1348
1883
  }
1349
1884
 
@@ -1360,7 +1895,124 @@ send = async function (text) {
1360
1895
  }
1361
1896
  };
1362
1897
 
1898
+ /* =========================================================================
1899
+ * Support report collector (2026-05-31 -- F30 lite).
1900
+ * Captures errors that Pablo / users would otherwise have to copy
1901
+ * from DevTools. Flushes batches to /api/forge/support/report.
1902
+ * ========================================================================= */
1903
+ const supportBuffer = [];
1904
+ const supportRequestTrail = [];
1905
+ let supportFlushTimer = null;
1906
+ const SUPPORT_BUFFER_MAX = 50;
1907
+ const SUPPORT_FLUSH_MS = 30000;
1908
+
1909
+ function supportPush(report) {
1910
+ if (supportBuffer.length >= SUPPORT_BUFFER_MAX) return;
1911
+ supportBuffer.push({
1912
+ ...report,
1913
+ client_ts: new Date().toISOString(),
1914
+ view: typeof state !== 'undefined' && state.mode ? state.mode : 'unknown',
1915
+ request_trail: supportRequestTrail.slice(-10),
1916
+ });
1917
+ scheduleSupportFlush();
1918
+ }
1919
+
1920
+ function scheduleSupportFlush() {
1921
+ if (supportFlushTimer) return;
1922
+ supportFlushTimer = setTimeout(supportFlush, SUPPORT_FLUSH_MS);
1923
+ }
1924
+
1925
+ async function supportFlush() {
1926
+ supportFlushTimer = null;
1927
+ if (supportBuffer.length === 0) return;
1928
+ const batch = supportBuffer.splice(0, supportBuffer.length);
1929
+ try {
1930
+ await fetch('/api/forge/support/report', {
1931
+ method: 'POST',
1932
+ headers: { 'content-type': 'application/json' },
1933
+ body: JSON.stringify(batch),
1934
+ keepalive: true, /* survives page unload */
1935
+ });
1936
+ } catch {
1937
+ /* Network error -- drop the batch silently. Trying to
1938
+ report errors via the same broken pipe just loops. */
1939
+ }
1940
+ }
1941
+
1942
+ function installSupportCollector() {
1943
+ window.addEventListener('error', (ev) => {
1944
+ supportPush({
1945
+ level: 'error',
1946
+ source: 'window.onerror',
1947
+ message: ev.message || 'unknown error',
1948
+ stack: ev.error && ev.error.stack ? ev.error.stack : undefined,
1949
+ url: ev.filename || undefined,
1950
+ line: typeof ev.lineno === 'number' ? ev.lineno : undefined,
1951
+ col: typeof ev.colno === 'number' ? ev.colno : undefined,
1952
+ });
1953
+ });
1954
+ window.addEventListener('unhandledrejection', (ev) => {
1955
+ let msg = 'unhandled promise rejection';
1956
+ let stack;
1957
+ if (ev.reason && typeof ev.reason === 'object') {
1958
+ msg = ev.reason.message || String(ev.reason);
1959
+ stack = ev.reason.stack;
1960
+ } else if (ev.reason) {
1961
+ msg = String(ev.reason);
1962
+ }
1963
+ supportPush({
1964
+ level: 'error',
1965
+ source: 'unhandled',
1966
+ message: msg,
1967
+ stack,
1968
+ });
1969
+ });
1970
+ /* fetch interceptor -- record non-2xx + network failures. */
1971
+ const originalFetch = window.fetch.bind(window);
1972
+ window.fetch = async function patchedFetch(input, init) {
1973
+ const url = typeof input === 'string' ? input
1974
+ : (input && input.url) ? input.url : String(input);
1975
+ const method = (init && init.method) || (input && input.method) || 'GET';
1976
+ try {
1977
+ const r = await originalFetch(input, init);
1978
+ /* Track every call (last 10) so reports carry network context. */
1979
+ supportRequestTrail.push({ method, url, status: r.status });
1980
+ while (supportRequestTrail.length > 10) supportRequestTrail.shift();
1981
+ if (r.status >= 500) {
1982
+ supportPush({
1983
+ level: 'error',
1984
+ source: 'fetch',
1985
+ message: 'fetch ' + method + ' ' + url + ' HTTP ' + r.status,
1986
+ });
1987
+ }
1988
+ return r;
1989
+ } catch (err) {
1990
+ supportRequestTrail.push({ method, url });
1991
+ while (supportRequestTrail.length > 10) supportRequestTrail.shift();
1992
+ supportPush({
1993
+ level: 'error',
1994
+ source: 'fetch',
1995
+ message: 'fetch ' + method + ' ' + url + ' threw: '
1996
+ + (err && err.message ? err.message : String(err)),
1997
+ stack: err && err.stack ? err.stack : undefined,
1998
+ });
1999
+ throw err;
2000
+ }
2001
+ };
2002
+ /* Flush on unload using sendBeacon (more reliable than fetch
2003
+ during navigation tear-down). */
2004
+ window.addEventListener('pagehide', () => {
2005
+ if (supportBuffer.length === 0) return;
2006
+ const batch = supportBuffer.splice(0, supportBuffer.length);
2007
+ try {
2008
+ const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
2009
+ navigator.sendBeacon('/api/forge/support/report', blob);
2010
+ } catch { /* ignore */ }
2011
+ });
2012
+ }
2013
+
1363
2014
  document.addEventListener('DOMContentLoaded', () => {
2015
+ installSupportCollector();
1364
2016
  setProj();
1365
2017
  showMode(loadMode());
1366
2018
  $('#yf-globito').addEventListener('click', () => showMode('mini'));
@@ -1376,6 +2028,29 @@ document.addEventListener('DOMContentLoaded', () => {
1376
2028
  /* Voice controls. */
1377
2029
  $$('[data-mic-btn]').forEach((b) => b.addEventListener('click', () => micToggle(b)));
1378
2030
  $$('[data-tts-toggle]').forEach((b) => b.addEventListener('click', () => setTtsReplies(!ttsRepliesEnabled())));
2031
+ $$('[data-voice-mode-btn]').forEach((b) => b.addEventListener('click', () => {
2032
+ setVoiceMode(voiceMode() === 'auricular' ? 'mic' : 'auricular');
2033
+ }));
2034
+ updateVoiceModeButtons();
2035
+ $$('[data-stt-provider-btn]').forEach((b) => b.addEventListener('click', cycleSttProvider));
2036
+ updateSttProviderButtons();
2037
+ /* alpha.29 -- settings dropdown toggle + keys status. */
2038
+ const settingsPanel = $('#yf-settings');
2039
+ $$('[data-settings-toggle]').forEach((b) => b.addEventListener('click', () => {
2040
+ if (!settingsPanel) return;
2041
+ settingsPanel.classList.toggle('hidden');
2042
+ if (!settingsPanel.classList.contains('hidden')) refreshKeysStatus();
2043
+ }));
2044
+ /* Click outside closes. */
2045
+ document.addEventListener('click', (e) => {
2046
+ if (!settingsPanel || settingsPanel.classList.contains('hidden')) return;
2047
+ if (settingsPanel.contains(e.target)) return;
2048
+ if (e.target.closest('[data-settings-toggle]')) return;
2049
+ settingsPanel.classList.add('hidden');
2050
+ });
2051
+ /* TTS voice cycle. */
2052
+ $$('[data-tts-voice-btn]').forEach((b) => b.addEventListener('click', cycleTtsVoice));
2053
+ updateTtsVoiceButtons();
1379
2054
  setMicButtonsState('idle');
1380
2055
  updateTtsToggleButtons();
1381
2056