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

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 (166) 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 +791 -17
  10. package/dist/chat/panel.js.map +1 -1
  11. package/dist/chat/server.js +734 -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/keys.d.ts +9 -0
  23. package/dist/chat/tools/keys.d.ts.map +1 -0
  24. package/dist/chat/tools/keys.js +194 -0
  25. package/dist/chat/tools/keys.js.map +1 -0
  26. package/dist/chat/tools/lifecycle.js +3 -3
  27. package/dist/chat/tools/manual.js +1 -1
  28. package/dist/chat/tools/reader.js +8 -8
  29. package/dist/chat/tools/workflow.d.ts +45 -0
  30. package/dist/chat/tools/workflow.d.ts.map +1 -0
  31. package/dist/chat/tools/workflow.js +404 -0
  32. package/dist/chat/tools/workflow.js.map +1 -0
  33. package/dist/chat/tools.d.ts.map +1 -1
  34. package/dist/chat/tools.js +31 -4
  35. package/dist/chat/tools.js.map +1 -1
  36. package/dist/commands/approve.d.ts +32 -0
  37. package/dist/commands/approve.d.ts.map +1 -0
  38. package/dist/commands/approve.js +198 -0
  39. package/dist/commands/approve.js.map +1 -0
  40. package/dist/commands/block.d.ts +28 -0
  41. package/dist/commands/block.d.ts.map +1 -0
  42. package/dist/commands/block.js +189 -0
  43. package/dist/commands/block.js.map +1 -0
  44. package/dist/commands/bootstrap.d.ts +35 -0
  45. package/dist/commands/bootstrap.d.ts.map +1 -0
  46. package/dist/commands/bootstrap.js +205 -0
  47. package/dist/commands/bootstrap.js.map +1 -0
  48. package/dist/commands/chat.d.ts +3 -0
  49. package/dist/commands/chat.d.ts.map +1 -1
  50. package/dist/commands/chat.js +46 -1
  51. package/dist/commands/chat.js.map +1 -1
  52. package/dist/commands/clarify.d.ts +30 -0
  53. package/dist/commands/clarify.d.ts.map +1 -0
  54. package/dist/commands/clarify.js +671 -0
  55. package/dist/commands/clarify.js.map +1 -0
  56. package/dist/commands/discover.d.ts +30 -0
  57. package/dist/commands/discover.d.ts.map +1 -0
  58. package/dist/commands/discover.js +178 -0
  59. package/dist/commands/discover.js.map +1 -0
  60. package/dist/commands/doctor.js +94 -42
  61. package/dist/commands/doctor.js.map +1 -1
  62. package/dist/commands/keys_setup.d.ts +53 -0
  63. package/dist/commands/keys_setup.d.ts.map +1 -0
  64. package/dist/commands/keys_setup.js +487 -0
  65. package/dist/commands/keys_setup.js.map +1 -0
  66. package/dist/commands/legacy-audit.d.ts +34 -0
  67. package/dist/commands/legacy-audit.d.ts.map +1 -0
  68. package/dist/commands/legacy-audit.js +270 -0
  69. package/dist/commands/legacy-audit.js.map +1 -0
  70. package/dist/commands/license.d.ts.map +1 -1
  71. package/dist/commands/license.js +41 -0
  72. package/dist/commands/license.js.map +1 -1
  73. package/dist/commands/operate.d.ts +22 -0
  74. package/dist/commands/operate.d.ts.map +1 -0
  75. package/dist/commands/operate.js +523 -0
  76. package/dist/commands/operate.js.map +1 -0
  77. package/dist/commands/spec.d.ts +38 -0
  78. package/dist/commands/spec.d.ts.map +1 -0
  79. package/dist/commands/spec.js +256 -0
  80. package/dist/commands/spec.js.map +1 -0
  81. package/dist/commands/support.d.ts +22 -0
  82. package/dist/commands/support.d.ts.map +1 -0
  83. package/dist/commands/support.js +143 -0
  84. package/dist/commands/support.js.map +1 -0
  85. package/dist/commands/triage.d.ts +34 -0
  86. package/dist/commands/triage.d.ts.map +1 -0
  87. package/dist/commands/triage.js +228 -0
  88. package/dist/commands/triage.js.map +1 -0
  89. package/dist/commands/vault-inventory.d.ts +30 -0
  90. package/dist/commands/vault-inventory.d.ts.map +1 -0
  91. package/dist/commands/vault-inventory.js +214 -0
  92. package/dist/commands/vault-inventory.js.map +1 -0
  93. package/dist/commands/vault.d.ts.map +1 -1
  94. package/dist/commands/vault.js +5 -0
  95. package/dist/commands/vault.js.map +1 -1
  96. package/dist/commands/voice.js +1 -1
  97. package/dist/commands/voice.js.map +1 -1
  98. package/dist/commands/workflow-coverage.d.ts +30 -0
  99. package/dist/commands/workflow-coverage.d.ts.map +1 -0
  100. package/dist/commands/workflow-coverage.js +138 -0
  101. package/dist/commands/workflow-coverage.js.map +1 -0
  102. package/dist/core/keys_envelope.d.ts +13 -0
  103. package/dist/core/keys_envelope.d.ts.map +1 -1
  104. package/dist/core/keys_envelope.js.map +1 -1
  105. package/dist/deploy/adapter.d.ts +93 -0
  106. package/dist/deploy/adapter.d.ts.map +1 -0
  107. package/dist/deploy/adapter.js +42 -0
  108. package/dist/deploy/adapter.js.map +1 -0
  109. package/dist/deploy/aws_adapter.d.ts +28 -0
  110. package/dist/deploy/aws_adapter.d.ts.map +1 -0
  111. package/dist/deploy/aws_adapter.js +98 -0
  112. package/dist/deploy/aws_adapter.js.map +1 -0
  113. package/dist/deploy/cloudflare.d.ts +24 -0
  114. package/dist/deploy/cloudflare.d.ts.map +1 -0
  115. package/dist/deploy/cloudflare.js +169 -0
  116. package/dist/deploy/cloudflare.js.map +1 -0
  117. package/dist/license/hito4_client.d.ts +17 -1
  118. package/dist/license/hito4_client.d.ts.map +1 -1
  119. package/dist/license/hito4_client.js +71 -10
  120. package/dist/license/hito4_client.js.map +1 -1
  121. package/dist/license/index.d.ts.map +1 -1
  122. package/dist/license/index.js +7 -0
  123. package/dist/license/index.js.map +1 -1
  124. package/dist/license/sync.d.ts +54 -0
  125. package/dist/license/sync.d.ts.map +1 -0
  126. package/dist/license/sync.js +131 -0
  127. package/dist/license/sync.js.map +1 -0
  128. package/dist/support/reports.d.ts +31 -0
  129. package/dist/support/reports.d.ts.map +1 -0
  130. package/dist/support/reports.js +162 -0
  131. package/dist/support/reports.js.map +1 -0
  132. package/dist/telemetry/usage.d.ts +67 -0
  133. package/dist/telemetry/usage.d.ts.map +1 -0
  134. package/dist/telemetry/usage.js +208 -0
  135. package/dist/telemetry/usage.js.map +1 -0
  136. package/dist/version.d.ts +1 -1
  137. package/dist/version.d.ts.map +1 -1
  138. package/dist/version.js +1 -1
  139. package/dist/version.js.map +1 -1
  140. package/dist/voice/intents.d.ts +1 -1
  141. package/dist/voice/intents.js +0 -0
  142. package/dist/voice/providers/google.d.ts +9 -0
  143. package/dist/voice/providers/google.d.ts.map +1 -1
  144. package/dist/voice/providers/google.js +204 -28
  145. package/dist/voice/providers/google.js.map +1 -1
  146. package/dist/voice/router.d.ts +10 -0
  147. package/dist/voice/router.d.ts.map +1 -1
  148. package/dist/voice/router.js +39 -20
  149. package/dist/voice/router.js.map +1 -1
  150. package/dist/voice/types.d.ts +5 -2
  151. package/dist/voice/types.d.ts.map +1 -1
  152. package/dist/voice/types.js.map +1 -1
  153. package/dist/workflow/state.d.ts +190 -0
  154. package/dist/workflow/state.d.ts.map +1 -0
  155. package/dist/workflow/state.js +119 -0
  156. package/dist/workflow/state.js.map +1 -0
  157. package/package.json +13 -15
  158. package/templates/nextjs-app/README.md +48 -0
  159. package/templates/nextjs-app/next.config.js +8 -0
  160. package/templates/nextjs-app/package.json +33 -0
  161. package/templates/nextjs-app/src/app/globals.css +43 -0
  162. package/templates/nextjs-app/src/app/layout.tsx +29 -0
  163. package/templates/nextjs-app/src/app/page.tsx +63 -0
  164. package/templates/nextjs-app/src/nac/manifest.ts +36 -0
  165. package/templates/nextjs-app/tsconfig.json +21 -0
  166. package/templates/nextjs-app/yujin.forge.json +11 -0
@@ -237,6 +237,59 @@ 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: 12px; min-width: 360px; max-width: 420px;
245
+ max-height: 80vh; overflow-y: auto;
246
+ box-shadow: 0 8px 24px rgba(0,0,0,0.4);
247
+ display: flex; flex-direction: column; gap: 12px;
248
+ }
249
+ .settings-panel.hidden { display: none; }
250
+ .settings-panel .set-section {
251
+ display: flex; flex-direction: column; gap: 6px;
252
+ padding-bottom: 8px; border-bottom: 1px solid rgba(255,255,255,0.08);
253
+ }
254
+ .settings-panel .set-section:last-of-type { border-bottom: 0; padding-bottom: 0; }
255
+ .settings-panel .set-section-title {
256
+ font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px;
257
+ color: var(--ink-2); font-weight: 600;
258
+ }
259
+ .settings-panel .set-row,
260
+ .settings-panel .set-key-row {
261
+ display: flex; justify-content: space-between; align-items: center;
262
+ gap: 12px; font-size: 12px;
263
+ }
264
+ .settings-panel .set-key-row .set-lbl { flex: 1; }
265
+ .settings-panel .set-key-row .set-status { min-width: 24px; text-align: center; }
266
+ .settings-panel .set-lbl { color: var(--ink); font-weight: 500; }
267
+ .settings-panel .set-btn {
268
+ background: var(--bg-2); color: var(--ink); border: 1px solid rgba(255,255,255,0.15);
269
+ padding: 4px 10px; border-radius: 4px; font-size: 11px; cursor: pointer;
270
+ }
271
+ .settings-panel .set-btn:hover { background: rgba(255,255,255,0.1); }
272
+ .settings-panel .set-btn-danger { color: #ff8b7a; border-color: rgba(255,139,122,0.3); }
273
+ .settings-panel .set-btn-danger:hover { background: rgba(255,139,122,0.1); }
274
+ .settings-panel .set-status { font-size: 11px; color: var(--ink-2); }
275
+ .settings-panel .set-link {
276
+ font-size: 11px; color: #6cb6ff; text-decoration: underline;
277
+ }
278
+ .settings-panel .key-editor {
279
+ margin-top: 8px; padding: 10px; background: var(--bg-2);
280
+ border-radius: 4px; display: flex; flex-direction: column; gap: 8px;
281
+ }
282
+ .settings-panel .key-editor.hidden { display: none; }
283
+ .settings-panel .key-editor .ke-title { font-size: 11px; color: var(--ink-2); }
284
+ .settings-panel .key-editor input {
285
+ background: var(--bg-1); color: var(--ink); border: 1px solid rgba(255,255,255,0.2);
286
+ padding: 6px 8px; border-radius: 4px; font-size: 12px; font-family: monospace;
287
+ }
288
+ .settings-panel .key-editor .ke-actions { display: flex; gap: 6px; }
289
+ .settings-panel .key-editor .ke-msg { font-size: 11px; min-height: 14px; }
290
+ .settings-panel .key-editor .ke-msg.error { color: #ff8b7a; }
291
+ .settings-panel .key-editor .ke-msg.success { color: #6dff8b; }
292
+
240
293
  /* ---- chat stream ---- */
241
294
  .stream { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 10px; }
242
295
  .empty { color: var(--ink-muted); text-align: center; padding: 32px 16px; }
@@ -453,12 +506,77 @@ body {
453
506
  <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
507
  </div>
455
508
  <div class="actions">
509
+ <button class="settings-btn" data-settings-toggle aria-label="ajustes" title="Ajustes (modo voz, STT, TTS, claves)">⚙</button>
456
510
  <button class="lang-btn" data-lang-open aria-label="${escapeHtml(tr.languageButton)}" title="${escapeHtml(tr.languageButton)}">🌐</button>
457
511
  <button class="keys-btn" data-vault-open aria-label="${escapeHtml(tr.keysButton)}" title="${escapeHtml(tr.keysButton)}">${escapeHtml(tr.keysButton)}</button>
458
512
  <button id="full-mini" data-nac-id="yujin.panel.full-to-mini" aria-label="${escapeHtml(tr.minimiseButton)}" title="${escapeHtml(tr.minimiseButton)}">▭</button>
459
513
  <button id="full-close" data-nac-id="yujin.panel.full-close" aria-label="${escapeHtml(tr.closeButton)}" title="${escapeHtml(tr.closeButton)}">✕</button>
460
514
  </div>
461
515
  </div>
516
+ <!-- alpha.30: settings dropdown in topbar with editable
517
+ API keys (BYOK) + voice settings + license. -->
518
+ <div id="yf-settings" class="hidden settings-panel">
519
+ <div class="set-section">
520
+ <div class="set-section-title">Voz</div>
521
+ <div class="set-row"><span class="set-lbl">Modo</span>
522
+ <button type="button" data-voice-mode-btn class="set-btn">modo mic</button>
523
+ </div>
524
+ <div class="set-row"><span class="set-lbl">STT</span>
525
+ <button type="button" data-stt-provider-btn class="set-btn">STT navegador</button>
526
+ </div>
527
+ <div class="set-row"><span class="set-lbl">Voz TTS</span>
528
+ <button type="button" data-tts-voice-btn class="set-btn">es-ES-Neural2-A (Lucia)</button>
529
+ </div>
530
+ </div>
531
+ <div class="set-section">
532
+ <div class="set-section-title">API keys (BYOK)</div>
533
+ <div class="set-key-row" data-key-slot="anthropic_api_key"><span class="set-lbl">Brain (Anthropic)</span>
534
+ <span class="set-status" data-key-status>...</span>
535
+ <button type="button" class="set-btn" data-key-edit>Configurar</button>
536
+ </div>
537
+ <div class="set-key-row" data-key-slot="openai_api_key"><span class="set-lbl">Brain (OpenAI)</span>
538
+ <span class="set-status" data-key-status>...</span>
539
+ <button type="button" class="set-btn" data-key-edit>Configurar</button>
540
+ </div>
541
+ <div class="set-key-row" data-key-slot="google_stt_key"><span class="set-lbl">STT Google</span>
542
+ <span class="set-status" data-key-status>...</span>
543
+ <button type="button" class="set-btn" data-key-edit>Configurar</button>
544
+ </div>
545
+ <div class="set-key-row" data-key-slot="google_tts_key"><span class="set-lbl">TTS Google</span>
546
+ <span class="set-status" data-key-status>...</span>
547
+ <button type="button" class="set-btn" data-key-edit>Configurar</button>
548
+ </div>
549
+ <div class="set-key-row" data-key-slot="elevenlabs_api_key"><span class="set-lbl">TTS ElevenLabs</span>
550
+ <span class="set-status" data-key-status>...</span>
551
+ <button type="button" class="set-btn" data-key-edit>Configurar</button>
552
+ </div>
553
+ <div class="set-key-row" data-key-slot="whisper_api_key"><span class="set-lbl">STT Whisper</span>
554
+ <span class="set-status" data-key-status>...</span>
555
+ <button type="button" class="set-btn" data-key-edit>Configurar</button>
556
+ </div>
557
+ </div>
558
+ <div class="set-section">
559
+ <div class="set-section-title">License</div>
560
+ <div class="set-row"><span class="set-lbl">Plan</span>
561
+ <span class="set-status" id="set-license-status">cargando...</span>
562
+ </div>
563
+ <div class="set-row">
564
+ <a href="https://polar.sh/checkout/polar_c_FXDtc94fvh5H8rXORu24IQcSpnOhp9ijG3Cxn4CUWxr"
565
+ target="_blank" rel="noopener" class="set-link">Comprar / activar plan en polar.sh</a>
566
+ </div>
567
+ </div>
568
+ <!-- inline edit form (single, repositioned over the active key row) -->
569
+ <div id="yf-key-editor" class="hidden key-editor">
570
+ <div class="ke-title">Configurar <span id="ke-slot-name"></span></div>
571
+ <input type="password" id="ke-input" placeholder="Pega aqui tu API key" autocomplete="off"/>
572
+ <div class="ke-actions">
573
+ <button type="button" id="ke-save" class="set-btn">Guardar</button>
574
+ <button type="button" id="ke-clear" class="set-btn set-btn-danger">Quitar</button>
575
+ <button type="button" id="ke-cancel" class="set-btn">Cancelar</button>
576
+ </div>
577
+ <div id="ke-msg" class="ke-msg"></div>
578
+ </div>
579
+ </div>
462
580
  <div class="body">
463
581
  <div class="chat-col">
464
582
  <div id="stream-full" data-nac-id="yujin.chat.stream-full" class="stream"></div>
@@ -557,6 +675,19 @@ function renderMd(src) {
557
675
  out = out.replace(/\`([^\`]+)\`/g, '<code>$1</code>');
558
676
  out = out.replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>');
559
677
  out = out.replace(/\\n/g, '<br>');
678
+ /* Layer A.2 -- linkify file paths so the minority who wants to
679
+ * read code can click straight into their editor. Matches:
680
+ * src/foo/bar.ts
681
+ * tests/foo/bar.test.ts
682
+ * docs/SPEC.md
683
+ * packages/*\/src/*.ts
684
+ * Optionally with :line:col suffix. The result is a
685
+ * data-fpath link that the bound click handler resolves via
686
+ * /api/forge/open-in-editor. */
687
+ out = out.replace(
688
+ /(?<![\\w/])((?:src|tests|docs|packages|apps|scripts)\\/[\\w./-]+(?:\\.[\\w]+)(?::\\d+(?::\\d+)?)?)/g,
689
+ '<a class="fpath" data-fpath="$1" href="#" title="Abrir en mi editor">$1</a>',
690
+ );
560
691
  return out;
561
692
  }
562
693
 
@@ -577,6 +708,37 @@ function renderStream() {
577
708
  else div.textContent = m.content;
578
709
  target.appendChild(div);
579
710
  }
711
+ /* Layer A.2 -- bind click handlers on .fpath links to call
712
+ the open-in-editor endpoint + post a trust event. Idempotent
713
+ re-binding is safe because we replace innerHTML each turn. */
714
+ target.querySelectorAll('a.fpath').forEach(function (a) {
715
+ a.addEventListener('click', async function (ev) {
716
+ ev.preventDefault();
717
+ const p = a.getAttribute('data-fpath');
718
+ if (!p) return;
719
+ /* Strip trailing :line:col -- the editor opens at the file. */
720
+ const cleanPath = p.replace(/:\\d+(?::\\d+)?$/, '');
721
+ try {
722
+ const r = await fetch('/api/forge/open-in-editor', {
723
+ method: 'POST',
724
+ headers: { 'content-type': 'application/json' },
725
+ body: JSON.stringify({ path: cleanPath }),
726
+ });
727
+ const body = await r.json();
728
+ if (!body.ok && body.path) {
729
+ navigator.clipboard.writeText(body.path).catch(function () {});
730
+ alert('No editor en PATH. Copie el path: ' + body.path);
731
+ }
732
+ fetch('/api/forge/trust-event', {
733
+ method: 'POST',
734
+ headers: { 'content-type': 'application/json' },
735
+ body: JSON.stringify({ kind: 'editor_open' }),
736
+ }).catch(function () {});
737
+ } catch (e) {
738
+ /* network down -- ignore, the link is best-effort */
739
+ }
740
+ });
741
+ });
580
742
  }
581
743
  if (state.busy) {
582
744
  const d = document.createElement('div');
@@ -920,10 +1082,157 @@ async function vaultTest(slot, button) {
920
1082
  }
921
1083
 
922
1084
  /* ---- Voice integration (V1.6) ---- */
923
- const TTS_TOGGLE_KEY = 'yf-tts-replies-on';
1085
+ const TTS_TOGGLE_KEY = 'yf-tts-replies-on';
1086
+ const VOICE_MODE_KEY = 'yf-voice-mode'; /* 2026-05-31 -- 'mic' | 'auricular' */
924
1087
  let mediaRecorder = null;
925
1088
  let recordChunks = [];
926
1089
  let recordingPanelButton = null;
1090
+ /* 2026-05-31 half-duplex: when the user's last turn came through
1091
+ the mic (not the keyboard), auto-reopen the mic after TTS
1092
+ finishes -- so a conversation flows without push-to-talk every
1093
+ turn. If they typed, do NOT auto-open. Only applies in
1094
+ 'auricular' mode -- in 'mic' (push-to-talk) the user stays
1095
+ in control. */
1096
+ let _lastInputWasVoice = false;
1097
+ let _lastVoiceButton = null;
1098
+
1099
+ /* STT provider selector (alpha.28+). Default 'browser' = Web
1100
+ * Speech API (instant, free, native es-AR). 'google' = our
1101
+ * existing MediaRecorder + Google Cloud STT REST path. 'whisper'
1102
+ * = server routes to Whisper API.
1103
+ * Per Pablo (2026-05-31): "el default queda el del navegador,
1104
+ * pero quiero poder elegir Google u otro". */
1105
+ const STT_PROVIDER_KEY = 'yf-stt-provider';
1106
+ function sttProvider() {
1107
+ try {
1108
+ const v = localStorage.getItem(STT_PROVIDER_KEY);
1109
+ if (v === 'google' || v === 'whisper' || v === 'browser') return v;
1110
+ } catch (_) {}
1111
+ return 'browser';
1112
+ }
1113
+ function setSttProvider(p) {
1114
+ try { localStorage.setItem(STT_PROVIDER_KEY, p); } catch (_) {}
1115
+ updateSttProviderButtons();
1116
+ }
1117
+ function updateSttProviderButtons() {
1118
+ const p = sttProvider();
1119
+ const label = p === 'browser' ? 'STT navegador'
1120
+ : p === 'google' ? 'STT Google'
1121
+ : 'STT Whisper';
1122
+ document.querySelectorAll('[data-stt-provider-btn]').forEach((b) => {
1123
+ b.textContent = label;
1124
+ b.title = 'Click para alternar entre navegador (rapido + es-AR nativo + gratis), '
1125
+ + 'Google Cloud STT (server-side, BYOK) o Whisper (server-side, BYOK).';
1126
+ });
1127
+ }
1128
+ function cycleSttProvider() {
1129
+ const order = ['browser', 'google', 'whisper'];
1130
+ const i = order.indexOf(sttProvider());
1131
+ setSttProvider(order[(i + 1) % order.length]);
1132
+ }
1133
+
1134
+ /* alpha.29 -- TTS voice selection. Pablo: voz default debe ser
1135
+ * la que articule mejor las "s". es-ES (Spain) Neural2 voices
1136
+ * pronunciate finales claros vs es-US (Latin) que se las traga.
1137
+ * Also shows the active persona name in the settings panel. */
1138
+ const TTS_VOICE_KEY = 'yf-tts-voice';
1139
+ /* alpha.31 -- voice catalogue expanded with Studio + Wavenet
1140
+ * variants. Studio voices hyperarticulate (newscaster style)
1141
+ * and pronounce final-'s' clearly; Neural2 voices use natural
1142
+ * Spanish prosody that aspirates 's'. User toggles between
1143
+ * "claridad" (Studio) and "naturalidad" (Neural2). */
1144
+ const TTS_VOICES = [
1145
+ /* Studio voices FIRST -- they articulate every 's' clearly. */
1146
+ { id: 'es-ES-Studio-C', persona: 'Lucia Studio (ES)', gender: 'F', lang: 'es-ES', style: 'Studio (clara)' },
1147
+ { id: 'es-ES-Studio-F', persona: 'Javier Studio (ES)', gender: 'M', lang: 'es-ES', style: 'Studio (clara)' },
1148
+ { id: 'es-US-Studio-B', persona: 'Mateo Studio (LATAM)', gender: 'M', lang: 'es-US', style: 'Studio (clara)' },
1149
+ /* Neural2 (natural Spanish prosody, aspirates final 's'). */
1150
+ { id: 'es-ES-Neural2-A', persona: 'Lucia Neural (ES)', gender: 'F', lang: 'es-ES', style: 'Neural2 (natural)' },
1151
+ { id: 'es-ES-Neural2-B', persona: 'Javier Neural (ES)', gender: 'M', lang: 'es-ES', style: 'Neural2 (natural)' },
1152
+ { id: 'es-ES-Neural2-C', persona: 'Marta Neural (ES)', gender: 'F', lang: 'es-ES', style: 'Neural2 (natural)' },
1153
+ { id: 'es-ES-Neural2-D', persona: 'Carlos Neural (ES)', gender: 'M', lang: 'es-ES', style: 'Neural2 (natural)' },
1154
+ { id: 'es-US-Neural2-A', persona: 'Sofia Neural (LATAM)', gender: 'F', lang: 'es-US', style: 'Neural2 (natural)' },
1155
+ { id: 'es-US-Neural2-B', persona: 'Mateo Neural (LATAM)', gender: 'M', lang: 'es-US', style: 'Neural2 (natural)' },
1156
+ /* Wavenet (older, balanced articulation). */
1157
+ { id: 'es-ES-Wavenet-B', persona: 'Lucia Wavenet (ES)', gender: 'F', lang: 'es-ES', style: 'Wavenet (clasica)' },
1158
+ { id: 'en-US-Neural2-C', persona: 'Emma (EN)', gender: 'F', lang: 'en-US', style: 'Neural2' },
1159
+ ];
1160
+ function ttsVoice() {
1161
+ try {
1162
+ const v = localStorage.getItem(TTS_VOICE_KEY);
1163
+ if (v && TTS_VOICES.find((x) => x.id === v)) return v;
1164
+ } catch (_) {}
1165
+ /* alpha.31 default: Studio -- hyperarticulates 's' finals. */
1166
+ return 'es-ES-Studio-C';
1167
+ }
1168
+ function setTtsVoice(id) {
1169
+ try { localStorage.setItem(TTS_VOICE_KEY, id); } catch (_) {}
1170
+ updateTtsVoiceButtons();
1171
+ }
1172
+ function cycleTtsVoice() {
1173
+ const cur = ttsVoice();
1174
+ const i = TTS_VOICES.findIndex((v) => v.id === cur);
1175
+ const nxt = TTS_VOICES[(i + 1) % TTS_VOICES.length];
1176
+ setTtsVoice(nxt.id);
1177
+ }
1178
+ function updateTtsVoiceButtons() {
1179
+ const cur = ttsVoice();
1180
+ const meta = TTS_VOICES.find((v) => v.id === cur) || TTS_VOICES[0];
1181
+ document.querySelectorAll('[data-tts-voice-btn]').forEach((b) => {
1182
+ b.textContent = meta.persona + ' [' + meta.style + ']';
1183
+ b.title = 'Voz actual: ' + meta.id + ' / ' + meta.persona
1184
+ + ' (' + meta.gender + ') -- ' + meta.style + '. '
1185
+ + 'Click para ciclar. Studio = articula s claro; Neural2 = '
1186
+ + 'natural espaniol (aspira s finales).';
1187
+ });
1188
+ }
1189
+
1190
+ /* Keys status fetcher: GETs /api/forge/keys-status (new endpoint)
1191
+ * and renders a short summary in the settings dropdown. */
1192
+ async function refreshKeysStatus() {
1193
+ const el = document.getElementById('set-keys-status');
1194
+ if (!el) return;
1195
+ el.textContent = 'cargando...';
1196
+ try {
1197
+ const r = await fetch('/api/forge/keys-status');
1198
+ const data = await r.json();
1199
+ if (!r.ok || !data.ok) {
1200
+ el.textContent = 'error: ' + (data.error || r.status);
1201
+ return;
1202
+ }
1203
+ const k = data.keys || {};
1204
+ const parts = [];
1205
+ parts.push((k.anthropic ? '✓' : '✗') + ' brain');
1206
+ parts.push((k.google_stt ? '✓' : '✗') + ' stt');
1207
+ parts.push((k.google_tts || k.elevenlabs ? '✓' : '✗') + ' tts');
1208
+ parts.push((k.license_paid ? '✓' : '✗') + ' license');
1209
+ el.textContent = parts.join(' · ');
1210
+ } catch (err) {
1211
+ el.textContent = 'error fetching: ' + (err && err.message ? err.message : 'unknown');
1212
+ }
1213
+ }
1214
+
1215
+ function voiceMode() {
1216
+ /* Default 'mic' = push-to-talk (preserves pre-2026-05-31 UX). */
1217
+ try {
1218
+ const v = localStorage.getItem(VOICE_MODE_KEY);
1219
+ return v === 'auricular' ? 'auricular' : 'mic';
1220
+ } catch (_) { return 'mic'; }
1221
+ }
1222
+ function setVoiceMode(mode) {
1223
+ try { localStorage.setItem(VOICE_MODE_KEY, mode === 'auricular' ? 'auricular' : 'mic'); } catch (_) {}
1224
+ updateVoiceModeButtons();
1225
+ }
1226
+ function updateVoiceModeButtons() {
1227
+ const mode = voiceMode();
1228
+ document.querySelectorAll('[data-voice-mode-btn]').forEach((b) => {
1229
+ b.setAttribute('aria-pressed', String(mode === 'auricular'));
1230
+ b.textContent = mode === 'auricular' ? 'modo auricular' : 'modo mic';
1231
+ b.title = mode === 'auricular'
1232
+ ? 'Manos libres -- el mic se reabre solo despues de que Yujin habla. Click para cambiar a push-to-talk.'
1233
+ : 'Push-to-talk -- apretas para hablar, soltas. Click para activar manos libres (auricular).';
1234
+ });
1235
+ }
927
1236
 
928
1237
  function ttsRepliesEnabled() {
929
1238
  try {
@@ -955,12 +1264,57 @@ function setMicButtonsState(state) {
955
1264
  });
956
1265
  }
957
1266
 
1267
+ /* 2026-05-31 -- Web Speech API path (browser-native STT).
1268
+ Mirrors the Pilot CRM implementation. When the browser
1269
+ exposes SpeechRecognition / webkitSpeechRecognition we use
1270
+ IT instead of MediaRecorder + Google Cloud STT REST. Why:
1271
+ - Instant (no server round-trip).
1272
+ - es-AR + es-CL + es-PE + every regional Spanish locale
1273
+ supported natively (no es-US mapping needed).
1274
+ - Free (the browser routes to Google STT for free under
1275
+ Chrome/Edge; Firefox uses its own backend).
1276
+ - Interim results (text appears as you speak).
1277
+ The Google Cloud STT path stays as a fallback for callers
1278
+ that explicitly want server-side recognition (e.g. audio
1279
+ pre-recorded files via the yf CLI). */
1280
+ let _webSpeechRecognition = null;
1281
+
1282
+ function _webSpeechAvailable() {
1283
+ return typeof window !== 'undefined'
1284
+ && (typeof window.SpeechRecognition !== 'undefined'
1285
+ || typeof window.webkitSpeechRecognition !== 'undefined');
1286
+ }
1287
+
1288
+ function _webSpeechLangFromDoc() {
1289
+ const raw = (document.documentElement.lang
1290
+ || navigator.language || 'es-AR').toString();
1291
+ /* Web Speech API takes the locale as-is, including es-AR.
1292
+ No normalisation needed. */
1293
+ return raw;
1294
+ }
1295
+
958
1296
  async function micToggle(button) {
1297
+ if (_webSpeechRecognition) {
1298
+ /* Already listening -> stop. */
1299
+ try { _webSpeechRecognition.stop(); } catch (_) {}
1300
+ return;
1301
+ }
959
1302
  if (mediaRecorder && mediaRecorder.state === 'recording') {
960
1303
  /* Stop -> onstop handler dispatches transcription. */
961
1304
  mediaRecorder.stop();
962
1305
  return;
963
1306
  }
1307
+ /* Provider-aware dispatch. Default 'browser' uses Web Speech
1308
+ API when available. 'google' / 'whisper' route through the
1309
+ server-side MediaRecorder + REST path. */
1310
+ const provider = sttProvider();
1311
+ if (provider === 'browser' && _webSpeechAvailable()) {
1312
+ return _micToggleWebSpeech(button);
1313
+ }
1314
+ if (provider === 'browser' && !_webSpeechAvailable()) {
1315
+ setStatus('Browser STT unavailable; using server-side fallback.');
1316
+ /* Fall through to MediaRecorder path. */
1317
+ }
964
1318
  if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
965
1319
  setStatus('Your browser does not support microphone access.', true);
966
1320
  return;
@@ -989,7 +1343,85 @@ async function micToggle(button) {
989
1343
  mediaRecorder.ondataavailable = (e) => {
990
1344
  if (e.data && e.data.size > 0) recordChunks.push(e.data);
991
1345
  };
1346
+
1347
+ /* alpha.29 -- Real VAD for the MediaRecorder path (Google /
1348
+ Whisper STT providers). Tap the same audio stream that
1349
+ MediaRecorder is consuming via AudioContext + AnalyserNode,
1350
+ compute time-domain RMS every 50ms. State machine:
1351
+ - 'pre-voice': waiting for the first sustained speech.
1352
+ - 'voiced': speech detected; resets silence countdown
1353
+ on every chunk above threshold.
1354
+ - 'closing': N ms of sustained silence -> stop().
1355
+ Per Pablo: needs to close as soon as silence falls, just
1356
+ like Web Speech does natively. */
1357
+ const VAD_RMS_THRESHOLD = 0.02; /* normalised 0..1 */
1358
+ const VAD_SILENCE_MS = 800;
1359
+ const VAD_MIN_VOICED_MS = 200;
1360
+ let _vadCtx = null;
1361
+ let _vadAnalyser = null;
1362
+ let _vadSrc = null;
1363
+ let _vadTimer = null;
1364
+ let _vadVoicedStart = 0;
1365
+ let _vadLastVoicedAt = 0;
1366
+ let _vadHasVoiced = false;
1367
+ try {
1368
+ const AC = (window.AudioContext || window.webkitAudioContext);
1369
+ if (AC) {
1370
+ _vadCtx = new AC();
1371
+ _vadSrc = _vadCtx.createMediaStreamSource(stream);
1372
+ _vadAnalyser = _vadCtx.createAnalyser();
1373
+ _vadAnalyser.fftSize = 512;
1374
+ _vadSrc.connect(_vadAnalyser);
1375
+ const buf = new Float32Array(_vadAnalyser.fftSize);
1376
+ _vadTimer = setInterval(() => {
1377
+ if (!mediaRecorder || mediaRecorder.state !== 'recording') return;
1378
+ _vadAnalyser.getFloatTimeDomainData(buf);
1379
+ let sumsq = 0;
1380
+ for (let i = 0; i < buf.length; i++) sumsq += buf[i] * buf[i];
1381
+ const rms = Math.sqrt(sumsq / buf.length);
1382
+ const now = Date.now();
1383
+ if (rms > VAD_RMS_THRESHOLD) {
1384
+ if (!_vadHasVoiced) _vadVoicedStart = now;
1385
+ _vadHasVoiced = (now - _vadVoicedStart) >= VAD_MIN_VOICED_MS || _vadHasVoiced;
1386
+ _vadLastVoicedAt = now;
1387
+ } else if (_vadHasVoiced && (now - _vadLastVoicedAt) > VAD_SILENCE_MS) {
1388
+ /* Voice detected earlier, then sustained silence: close. */
1389
+ try { mediaRecorder.stop(); } catch (_) {}
1390
+ }
1391
+ }, 50);
1392
+ }
1393
+ } catch (_) { /* VAD unavailable; mic still works manually */ }
1394
+ /* Cleanup hook -- onstop also clears the VAD interval. */
1395
+ const _vadCleanup = () => {
1396
+ if (_vadTimer) { clearInterval(_vadTimer); _vadTimer = null; }
1397
+ try { if (_vadSrc) _vadSrc.disconnect(); } catch (_) {}
1398
+ try { if (_vadCtx && _vadCtx.state !== 'closed') _vadCtx.close(); } catch (_) {}
1399
+ };
1400
+ /* 2026-05-31 -- Google STT sync API rejects audio > 1 min
1401
+ with HTTP 400 "Sync input too long". Cap the recording
1402
+ at 55s (5s safety margin) + visual countdown so the user
1403
+ knows + auto-stop when reached. To go past this we would
1404
+ need to upload to GCS + use LongRunningRecognize; out of
1405
+ scope today. */
1406
+ const MAX_RECORD_MS = 55_000;
1407
+ const stopAt = Date.now() + MAX_RECORD_MS;
1408
+ const countdown = setInterval(() => {
1409
+ if (!mediaRecorder || mediaRecorder.state !== 'recording') {
1410
+ clearInterval(countdown);
1411
+ return;
1412
+ }
1413
+ const secLeft = Math.max(0, Math.ceil((stopAt - Date.now()) / 1000));
1414
+ if (secLeft <= 10) {
1415
+ setStatus('Recording... auto-stop in ' + secLeft + 's');
1416
+ }
1417
+ if (secLeft <= 0) {
1418
+ clearInterval(countdown);
1419
+ try { mediaRecorder.stop(); } catch { /* ignore */ }
1420
+ setStatus('Recording auto-stopped at 55s (Google STT 1-min limit). Try shorter.', true);
1421
+ }
1422
+ }, 1000);
992
1423
  mediaRecorder.onstop = async () => {
1424
+ _vadCleanup();
993
1425
  stream.getTracks().forEach((t) => t.stop());
994
1426
  const blob = new Blob(recordChunks, { type: mimeType || 'audio/webm' });
995
1427
  mediaRecorder = null;
@@ -998,6 +1430,14 @@ async function micToggle(button) {
998
1430
  setStatus('No audio captured. Try again.', true);
999
1431
  return;
1000
1432
  }
1433
+ /* Client-side size guard. 4 MB ~ 60s of WebM/Opus at
1434
+ 32 kbps which already trips the Google sync limit.
1435
+ Refuse before even hitting the network. */
1436
+ if (blob.size > 4_000_000) {
1437
+ setMicButtonsState('idle');
1438
+ setStatus('Audio too long (>1 min). Google STT sync limit. Try shorter.', true);
1439
+ return;
1440
+ }
1001
1441
  setMicButtonsState('processing');
1002
1442
  try {
1003
1443
  const buf = await blob.arrayBuffer();
@@ -1005,6 +1445,18 @@ async function micToggle(button) {
1005
1445
  'content-type': 'application/octet-stream',
1006
1446
  'x-audio-format': serverFormat,
1007
1447
  };
1448
+ /* 2026-05-31 FIX: tell the server which language the
1449
+ user is speaking. Without this header the Google STT
1450
+ provider falls back to en-US, which silently mangles
1451
+ Spanish input ("No me escucha" symptom in alpha.19).
1452
+ Source-of-truth priority:
1453
+ 1. <html lang="..."> (set by the panel via i18n).
1454
+ 2. navigator.language (browser locale).
1455
+ 3. 'es-AR' as a last-resort default for our market. */
1456
+ const langGuess = (document.documentElement.lang
1457
+ || navigator.language
1458
+ || 'es-AR').toString();
1459
+ if (langGuess) headers['x-audio-language'] = langGuess;
1008
1460
  /* V1.32 -- carry the active reader doc_id so the
1009
1461
  matcher can resolve commands like "siguiente" /
1010
1462
  "buscar X" against the currently-open document. */
@@ -1026,6 +1478,11 @@ async function micToggle(button) {
1026
1478
  setStatus('Did not catch that -- try again.', true);
1027
1479
  return;
1028
1480
  }
1481
+ /* Mark this turn as voice-originated so playTtsForText
1482
+ knows to reopen the mic afterwards (half-duplex
1483
+ conversational mode). */
1484
+ _lastInputWasVoice = true;
1485
+ _lastVoiceButton = recordingPanelButton;
1029
1486
  /* V1.32 -- if the matcher recognised a reader command,
1030
1487
  dispatch it directly (no Claude round-trip). Otherwise
1031
1488
  the transcript flows into the chat input as before. */
@@ -1093,7 +1550,7 @@ async function dispatchReaderIntent(utterance, intent) {
1093
1550
  }
1094
1551
  /* Update the active reader doc on a successful open so
1095
1552
  subsequent voice commands ("siguiente", etc) target it. */
1096
- if (intent.tool === 'forge.reader.open' && !data.is_error) {
1553
+ if (intent.tool === 'forge_reader_open' && !data.is_error) {
1097
1554
  const docId = data.result && data.result.doc_id;
1098
1555
  if (docId) state.activeReaderDocId = String(docId);
1099
1556
  }
@@ -1212,10 +1669,29 @@ function splitIntoTtsChunks(text, opts) {
1212
1669
  * status bar). */
1213
1670
  async function _fetchTtsChunk(text) {
1214
1671
  try {
1672
+ /* Pass current panel language so the server can pick a voice
1673
+ * persona that matches (es-ES, en-US, etc). The TTS endpoint
1674
+ * resolves voice from { language } if no explicit voice given. */
1675
+ const lang = (document && document.documentElement && document.documentElement.lang)
1676
+ ? document.documentElement.lang : undefined;
1677
+ /* alpha.31 -- Empirically verified (tools/tts_trace_smoke.mjs)
1678
+ * that the panel-side filter preserves every 's'. What the
1679
+ * user heard as 'dropped s' is Google Neural2 voices
1680
+ * naturally aspirating Spanish final-'s' (it is prosodically
1681
+ * correct castellano + LATAM behaviour). The 1.05 rate from
1682
+ * alpha.28 actually made it WORSE (less time per phoneme).
1683
+ * Back to speed=1.0; for hyperarticulated reads switch the
1684
+ * voice in settings to a Studio variant. */
1685
+ let speed;
1686
+ if (lang && /^es(-|$)/i.test(lang)) speed = 1.0;
1687
+ const voiceId = ttsVoice();
1688
+ const body = lang ? { text, language: lang } : { text };
1689
+ if (speed) body.speed = speed;
1690
+ if (voiceId) body.voice = voiceId;
1215
1691
  const r = await fetch('/api/voice/tts', {
1216
1692
  method: 'POST',
1217
1693
  headers: { 'content-type': 'application/json' },
1218
- body: JSON.stringify({ text }),
1694
+ body: JSON.stringify(body),
1219
1695
  });
1220
1696
  if (!r.ok) {
1221
1697
  const data = await r.json().catch(() => ({}));
@@ -1311,7 +1787,7 @@ async function ingestSpecFile(file) {
1311
1787
  state.toolHistory.push({
1312
1788
  turnIndex: state.messages.length - 1,
1313
1789
  rounds: [{
1314
- tool: 'forge.spec.ingest',
1790
+ tool: 'forge_spec_ingest',
1315
1791
  input: { filename: ing.filename, format: ing.format },
1316
1792
  display: 'parsed ' + ing.format + ': ' + (ing.title || ing.filename),
1317
1793
  is_error: false,
@@ -1326,24 +1802,182 @@ async function ingestSpecFile(file) {
1326
1802
  }
1327
1803
  }
1328
1804
 
1805
+ /** Strip markdown emphasis + decorative characters so the TTS
1806
+ * reads fluidly. Without this Forge's chat output (asterisks,
1807
+ * code ticks, dashes, emojis) was being read literally as
1808
+ * "asterisco asterisco". Output keeps acentos + n-tilde + open
1809
+ * punctuation chars -- those ARE pronounced. */
1810
+ function cleanForTts(raw) {
1811
+ let s = String(raw || '');
1812
+ /* Code fences first (greedy, capture content but strip the
1813
+ fence markers + any language hint). */
1814
+ s = s.replace(/\`\`\`[a-zA-Z0-9_-]*\\n?([\\s\\S]*?)\`\`\`/g, ' $1 ');
1815
+ /* Inline code: backticks -> nothing (just keep the content). */
1816
+ s = s.replace(/\`([^\`]+)\`/g, '$1');
1817
+ /* Markdown links: [label](url) -> "label". */
1818
+ s = s.replace(/\\[([^\\]]+)\\]\\([^)]+\\)/g, '$1');
1819
+ /* Bold + italic + strikethrough markers -> nothing. */
1820
+ s = s.replace(/\\*+([^*]+?)\\*+/g, '$1');
1821
+ s = s.replace(/_+([^_]+?)_+/g, '$1');
1822
+ s = s.replace(/~+([^~]+?)~+/g, '$1');
1823
+ /* Standalone emphasis or decorative chars at any position. */
1824
+ s = s.replace(/[*_~\`]+/g, '');
1825
+ /* Heading hashes at line start. */
1826
+ s = s.replace(/^#+\\s*/gm, '');
1827
+ /* Bullet list markers at line start (- + *). */
1828
+ s = s.replace(/^\\s*[-+]\\s+/gm, '');
1829
+ /* Horizontal rules -> period. */
1830
+ s = s.replace(/^[-*_=]{3,}$/gm, '.');
1831
+ /* Emojis + symbols ranges (broad pass; leave latin-1 supplement
1832
+ intact so acentos survive). */
1833
+ s = s.replace(/[\\u{1F300}-\\u{1FAFF}]/gu, '');
1834
+ s = s.replace(/[\\u{2600}-\\u{27BF}]/gu, '');
1835
+ /* Multiple punctuation -> single period. */
1836
+ s = s.replace(/[.!?]{2,}/g, '.');
1837
+ /* Multiple spaces / newlines -> single space. */
1838
+ s = s.replace(/\\s+/g, ' ');
1839
+ return s.trim();
1840
+ }
1841
+
1842
+ /* 2026-05-31 half-duplex fix (per Pablo: "el audio no debe quedar
1843
+ full duplex"). When TTS is playing, the mic MUST be closed so
1844
+ we do not capture Yujin's own voice as if it were user input.
1845
+ That feedback loop is what made STT receive >1 min audio and
1846
+ trip the Google 1-min sync limit.
1847
+
1848
+ Implementation: closeMicForTts() stops any in-progress
1849
+ recording before TTS starts. If the user had push-to-talked,
1850
+ the audio they captured BEFORE TTS started is already in
1851
+ flight; we just prevent NEW captures. */
1852
+ function closeMicForTts() {
1853
+ if (_webSpeechRecognition) {
1854
+ try { _webSpeechRecognition.abort(); } catch (_) {}
1855
+ _webSpeechRecognition = null;
1856
+ setStatus('Mic closed while Yujin speaks (half-duplex).');
1857
+ }
1858
+ if (mediaRecorder && mediaRecorder.state === 'recording') {
1859
+ try { mediaRecorder.stop(); } catch { /* ignore */ }
1860
+ setStatus('Mic closed while Yujin speaks (half-duplex).');
1861
+ }
1862
+ }
1863
+
1864
+ async function _micToggleWebSpeech(button) {
1865
+ const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
1866
+ recordingPanelButton = button;
1867
+ const rec = new SR();
1868
+ rec.continuous = false;
1869
+ rec.interimResults = true;
1870
+ rec.lang = _webSpeechLangFromDoc();
1871
+ let finalText = '';
1872
+ let interim = '';
1873
+ /* 2026-05-31 -- Aggressive silence cutoff. Chrome's default
1874
+ silence timeout for SpeechRecognition is ~3s; per Pablo
1875
+ "cerrar el mic mas rapido". We piggyback on interimResults:
1876
+ every time interim text changes we reset a timer; if the
1877
+ timer fires (no interim activity for SILENCE_MS) we stop
1878
+ the recogniser. */
1879
+ const SILENCE_MS = 900;
1880
+ let silenceTimer = null;
1881
+ const resetSilenceTimer = () => {
1882
+ if (silenceTimer) clearTimeout(silenceTimer);
1883
+ silenceTimer = setTimeout(() => {
1884
+ try { rec.stop(); } catch (_) {}
1885
+ }, SILENCE_MS);
1886
+ };
1887
+ rec.onresult = (e) => {
1888
+ interim = '';
1889
+ for (let i = e.resultIndex; i < e.results.length; i += 1) {
1890
+ const r = e.results[i];
1891
+ if (r.isFinal) finalText += r[0].transcript;
1892
+ else interim += r[0].transcript;
1893
+ }
1894
+ if (interim) setStatus('Listening... ' + interim);
1895
+ /* Activity heard -> reset the silence countdown. */
1896
+ resetSilenceTimer();
1897
+ };
1898
+ rec.onerror = (e) => {
1899
+ const errKind = (e && e.error) ? String(e.error) : 'unknown';
1900
+ if (errKind === 'no-speech' || errKind === 'aborted') {
1901
+ setStatus('');
1902
+ } else {
1903
+ setStatus('Mic error: ' + errKind, true);
1904
+ }
1905
+ };
1906
+ rec.onend = () => {
1907
+ if (silenceTimer) { clearTimeout(silenceTimer); silenceTimer = null; }
1908
+ _webSpeechRecognition = null;
1909
+ setMicButtonsState('idle');
1910
+ const text = (finalText || '').trim();
1911
+ if (!text) {
1912
+ if (!interim) setStatus('Did not catch that -- try again.', true);
1913
+ return;
1914
+ }
1915
+ _lastInputWasVoice = true;
1916
+ _lastVoiceButton = recordingPanelButton;
1917
+ /* Inject + submit, same as the Google-STT path. */
1918
+ const inputSel = state.mode === 'full' ? '#bar-full input' : '#bar-mini input';
1919
+ const input = $(inputSel);
1920
+ if (input) {
1921
+ input.value = text;
1922
+ send(text);
1923
+ input.value = '';
1924
+ }
1925
+ };
1926
+ try {
1927
+ _webSpeechRecognition = rec;
1928
+ rec.start();
1929
+ setMicButtonsState('recording');
1930
+ setStatus('Listening (web speech)...');
1931
+ /* IMPORTANT (alpha.29 bugfix): DO NOT arm the silence timer
1932
+ here. If we did, and the user takes >900ms to start
1933
+ talking (typical when they click + think), the timer would
1934
+ fire and rec.stop() before any audio arrived ->
1935
+ 'Did not catch that'. The timer is armed by onresult
1936
+ once the FIRST interim/final result lands. */
1937
+ } catch (err) {
1938
+ _webSpeechRecognition = null;
1939
+ setStatus('Could not start mic: ' + (err && err.message ? err.message : String(err)), true);
1940
+ }
1941
+ }
1942
+
1329
1943
  async function playTtsForText(text) {
1944
+ text = cleanForTts(text);
1330
1945
  if (!ttsRepliesEnabled()) return;
1331
1946
  if (!text || text.trim() === '') return;
1947
+ /* HALF-DUPLEX GATE: close mic before TTS starts. */
1948
+ closeMicForTts();
1949
+ const wasVoiceTurn = _lastInputWasVoice;
1950
+ /* Reset so the next turn re-decides; e.g. user can switch
1951
+ to keyboard mid-conversation and the auto-reopen stops. */
1952
+ _lastInputWasVoice = false;
1953
+ const reopenBtn = _lastVoiceButton;
1332
1954
  const chunks = splitIntoTtsChunks(text);
1333
1955
  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);
1956
+ try {
1957
+ if (chunks.length === 1) {
1958
+ const url = await _fetchTtsChunk(chunks[0]);
1959
+ if (url) await _playChunkUrl(url);
1960
+ } else {
1961
+ /* Pipeline: prefetch chunk N+1 while chunk N plays. */
1962
+ let nextFetch = _fetchTtsChunk(chunks[0]);
1963
+ for (let i = 0; i < chunks.length; i += 1) {
1964
+ const url = await nextFetch;
1965
+ nextFetch = (i + 1 < chunks.length) ? _fetchTtsChunk(chunks[i + 1]) : Promise.resolve(null);
1966
+ if (url) await _playChunkUrl(url);
1967
+ }
1968
+ }
1969
+ } finally {
1970
+ /* 2026-05-31 half-duplex auto-reopen. Only in 'auricular'
1971
+ mode (manos libres). In 'mic' mode (push-to-talk) we
1972
+ respect the user's explicit gesture and do NOT reopen.
1973
+ Tiny pause (200ms) so the TTS audio tail does not feed
1974
+ back into the next capture. */
1975
+ if (wasVoiceTurn && reopenBtn && voiceMode() === 'auricular') {
1976
+ setTimeout(() => {
1977
+ if (mediaRecorder && mediaRecorder.state === 'recording') return;
1978
+ try { micToggle(reopenBtn); } catch { /* ignore */ }
1979
+ }, 200);
1980
+ }
1347
1981
  }
1348
1982
  }
1349
1983
 
@@ -1360,7 +1994,124 @@ send = async function (text) {
1360
1994
  }
1361
1995
  };
1362
1996
 
1997
+ /* =========================================================================
1998
+ * Support report collector (2026-05-31 -- F30 lite).
1999
+ * Captures errors that Pablo / users would otherwise have to copy
2000
+ * from DevTools. Flushes batches to /api/forge/support/report.
2001
+ * ========================================================================= */
2002
+ const supportBuffer = [];
2003
+ const supportRequestTrail = [];
2004
+ let supportFlushTimer = null;
2005
+ const SUPPORT_BUFFER_MAX = 50;
2006
+ const SUPPORT_FLUSH_MS = 30000;
2007
+
2008
+ function supportPush(report) {
2009
+ if (supportBuffer.length >= SUPPORT_BUFFER_MAX) return;
2010
+ supportBuffer.push({
2011
+ ...report,
2012
+ client_ts: new Date().toISOString(),
2013
+ view: typeof state !== 'undefined' && state.mode ? state.mode : 'unknown',
2014
+ request_trail: supportRequestTrail.slice(-10),
2015
+ });
2016
+ scheduleSupportFlush();
2017
+ }
2018
+
2019
+ function scheduleSupportFlush() {
2020
+ if (supportFlushTimer) return;
2021
+ supportFlushTimer = setTimeout(supportFlush, SUPPORT_FLUSH_MS);
2022
+ }
2023
+
2024
+ async function supportFlush() {
2025
+ supportFlushTimer = null;
2026
+ if (supportBuffer.length === 0) return;
2027
+ const batch = supportBuffer.splice(0, supportBuffer.length);
2028
+ try {
2029
+ await fetch('/api/forge/support/report', {
2030
+ method: 'POST',
2031
+ headers: { 'content-type': 'application/json' },
2032
+ body: JSON.stringify(batch),
2033
+ keepalive: true, /* survives page unload */
2034
+ });
2035
+ } catch {
2036
+ /* Network error -- drop the batch silently. Trying to
2037
+ report errors via the same broken pipe just loops. */
2038
+ }
2039
+ }
2040
+
2041
+ function installSupportCollector() {
2042
+ window.addEventListener('error', (ev) => {
2043
+ supportPush({
2044
+ level: 'error',
2045
+ source: 'window.onerror',
2046
+ message: ev.message || 'unknown error',
2047
+ stack: ev.error && ev.error.stack ? ev.error.stack : undefined,
2048
+ url: ev.filename || undefined,
2049
+ line: typeof ev.lineno === 'number' ? ev.lineno : undefined,
2050
+ col: typeof ev.colno === 'number' ? ev.colno : undefined,
2051
+ });
2052
+ });
2053
+ window.addEventListener('unhandledrejection', (ev) => {
2054
+ let msg = 'unhandled promise rejection';
2055
+ let stack;
2056
+ if (ev.reason && typeof ev.reason === 'object') {
2057
+ msg = ev.reason.message || String(ev.reason);
2058
+ stack = ev.reason.stack;
2059
+ } else if (ev.reason) {
2060
+ msg = String(ev.reason);
2061
+ }
2062
+ supportPush({
2063
+ level: 'error',
2064
+ source: 'unhandled',
2065
+ message: msg,
2066
+ stack,
2067
+ });
2068
+ });
2069
+ /* fetch interceptor -- record non-2xx + network failures. */
2070
+ const originalFetch = window.fetch.bind(window);
2071
+ window.fetch = async function patchedFetch(input, init) {
2072
+ const url = typeof input === 'string' ? input
2073
+ : (input && input.url) ? input.url : String(input);
2074
+ const method = (init && init.method) || (input && input.method) || 'GET';
2075
+ try {
2076
+ const r = await originalFetch(input, init);
2077
+ /* Track every call (last 10) so reports carry network context. */
2078
+ supportRequestTrail.push({ method, url, status: r.status });
2079
+ while (supportRequestTrail.length > 10) supportRequestTrail.shift();
2080
+ if (r.status >= 500) {
2081
+ supportPush({
2082
+ level: 'error',
2083
+ source: 'fetch',
2084
+ message: 'fetch ' + method + ' ' + url + ' HTTP ' + r.status,
2085
+ });
2086
+ }
2087
+ return r;
2088
+ } catch (err) {
2089
+ supportRequestTrail.push({ method, url });
2090
+ while (supportRequestTrail.length > 10) supportRequestTrail.shift();
2091
+ supportPush({
2092
+ level: 'error',
2093
+ source: 'fetch',
2094
+ message: 'fetch ' + method + ' ' + url + ' threw: '
2095
+ + (err && err.message ? err.message : String(err)),
2096
+ stack: err && err.stack ? err.stack : undefined,
2097
+ });
2098
+ throw err;
2099
+ }
2100
+ };
2101
+ /* Flush on unload using sendBeacon (more reliable than fetch
2102
+ during navigation tear-down). */
2103
+ window.addEventListener('pagehide', () => {
2104
+ if (supportBuffer.length === 0) return;
2105
+ const batch = supportBuffer.splice(0, supportBuffer.length);
2106
+ try {
2107
+ const blob = new Blob([JSON.stringify(batch)], { type: 'application/json' });
2108
+ navigator.sendBeacon('/api/forge/support/report', blob);
2109
+ } catch { /* ignore */ }
2110
+ });
2111
+ }
2112
+
1363
2113
  document.addEventListener('DOMContentLoaded', () => {
2114
+ installSupportCollector();
1364
2115
  setProj();
1365
2116
  showMode(loadMode());
1366
2117
  $('#yf-globito').addEventListener('click', () => showMode('mini'));
@@ -1376,6 +2127,29 @@ document.addEventListener('DOMContentLoaded', () => {
1376
2127
  /* Voice controls. */
1377
2128
  $$('[data-mic-btn]').forEach((b) => b.addEventListener('click', () => micToggle(b)));
1378
2129
  $$('[data-tts-toggle]').forEach((b) => b.addEventListener('click', () => setTtsReplies(!ttsRepliesEnabled())));
2130
+ $$('[data-voice-mode-btn]').forEach((b) => b.addEventListener('click', () => {
2131
+ setVoiceMode(voiceMode() === 'auricular' ? 'mic' : 'auricular');
2132
+ }));
2133
+ updateVoiceModeButtons();
2134
+ $$('[data-stt-provider-btn]').forEach((b) => b.addEventListener('click', cycleSttProvider));
2135
+ updateSttProviderButtons();
2136
+ /* alpha.29 -- settings dropdown toggle + keys status. */
2137
+ const settingsPanel = $('#yf-settings');
2138
+ $$('[data-settings-toggle]').forEach((b) => b.addEventListener('click', () => {
2139
+ if (!settingsPanel) return;
2140
+ settingsPanel.classList.toggle('hidden');
2141
+ if (!settingsPanel.classList.contains('hidden')) refreshKeysStatus();
2142
+ }));
2143
+ /* Click outside closes. */
2144
+ document.addEventListener('click', (e) => {
2145
+ if (!settingsPanel || settingsPanel.classList.contains('hidden')) return;
2146
+ if (settingsPanel.contains(e.target)) return;
2147
+ if (e.target.closest('[data-settings-toggle]')) return;
2148
+ settingsPanel.classList.add('hidden');
2149
+ });
2150
+ /* TTS voice cycle. */
2151
+ $$('[data-tts-voice-btn]').forEach((b) => b.addEventListener('click', cycleTtsVoice));
2152
+ updateTtsVoiceButtons();
1379
2153
  setMicButtonsState('idle');
1380
2154
  updateTtsToggleButtons();
1381
2155