@danieltmn/tamnora 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/README.md +6 -3
  3. package/harness/packages/cli/src/commands/tui.js +2 -2
  4. package/harness/packages/cli/src/tui/app.js +2 -1
  5. package/harness/packages/core/src/agent/compact.js +6 -1
  6. package/harness/packages/core/src/agent/modes.js +29 -0
  7. package/harness/packages/core/src/providers/openaiCompatible.js +30 -17
  8. package/harness/packages/core/src/providers/stream.js +81 -7
  9. package/harness/packages/core/src/tools/executors.js +2 -2
  10. package/harness/packages/core/src/tools/registry.js +21 -1
  11. package/harness/packages/core/src/tools/schema.js +1 -0
  12. package/harness/packages/core/test/compact.test.js +4 -2
  13. package/harness/packages/core/test/providers.test.js +29 -0
  14. package/harness/packages/core/test/stream.test.js +46 -0
  15. package/harness/packages/core/test/tools.test.js +30 -0
  16. package/harness/packages/server/public/app.js +433 -53
  17. package/harness/packages/server/public/index.html +27 -8
  18. package/harness/packages/server/public/styles.css +51 -1
  19. package/harness/packages/server/src/lib/advisor.js +14 -13
  20. package/harness/packages/server/src/lib/channels/telegram.js +8 -3
  21. package/harness/packages/server/src/lib/compact.js +26 -1
  22. package/harness/packages/server/src/lib/scheduler.js +1 -0
  23. package/harness/packages/server/src/lib/sessionView.js +1 -0
  24. package/harness/packages/server/src/lib/tasks.js +11 -1
  25. package/harness/packages/server/src/lib/ttsSettings.js +11 -0
  26. package/harness/packages/server/src/routes/chat.js +20 -26
  27. package/harness/packages/server/src/routes/sessions.js +20 -11
  28. package/harness/packages/server/src/routes/tts.js +4 -2
  29. package/harness/packages/server/test/advisor.test.js +17 -13
  30. package/harness/packages/server/test/server.test.js +154 -51
  31. package/harness/packages/server/test/tts.test.js +5 -1
  32. package/package.json +1 -1
@@ -465,7 +465,9 @@ async function loadModes() {
465
465
  async function setMode(id, { persist = true } = {}) {
466
466
  if (![...modeSelect.options].some((o) => o.value === id)) return;
467
467
  modeSelect.value = id;
468
- if (persist) await api('/api/settings', { method: 'PUT', body: { mode: id } }).catch(() => null);
468
+ if (persist) await persistAgent({ mode: id });
469
+ // Modo Voz = manos libres (conversación); al salir, se apaga.
470
+ setHandsFree(id === 'voice');
469
471
  updateStatus();
470
472
  }
471
473
 
@@ -499,16 +501,26 @@ function renderCwdPill() {
499
501
  if (cwdPill) cwdPill.title = state.cwd || 'sin carpeta de trabajo';
500
502
  }
501
503
 
502
- // El pill de permisos resume modo + auto-aprobación en uno de tres niveles
503
- // estilo Codex: Read Only, Workspace Write y Full access.
504
+ // El pill de permisos resume modo + auto-aprobación en un control estilo Codex:
505
+ // Read Only, Workspace Write, Full access y Voz (notas .md/.json).
504
506
  function renderModePill() {
505
507
  const plan = modeSelect.value === 'plan';
506
- const full = !plan && Boolean(yesInput?.checked);
507
- if (modePillLabel) modePillLabel.textContent = plan ? 'Read Only' : full ? 'Full access' : 'Workspace Write';
508
+ const voice = modeSelect.value === 'voice';
509
+ const full = !plan && !voice && Boolean(yesInput?.checked);
510
+ if (modePillLabel) {
511
+ modePillLabel.textContent = voice ? 'Voz' : plan ? 'Read Only' : full ? 'Full access' : 'Workspace Write';
512
+ }
508
513
  if (!modeMenu) return;
509
514
  for (const item of modeMenu.querySelectorAll('.ctrl-menu-item')) {
510
515
  const kind = item.dataset.kind;
511
- const active = kind === 'read' ? plan : kind === 'full' ? full : !plan && !full;
516
+ const active =
517
+ kind === 'read'
518
+ ? plan
519
+ : kind === 'voice'
520
+ ? voice
521
+ : kind === 'full'
522
+ ? full
523
+ : !plan && !voice && !full;
512
524
  item.classList.toggle('active', active);
513
525
  item.setAttribute('aria-checked', active ? 'true' : 'false');
514
526
  }
@@ -516,8 +528,11 @@ function renderModePill() {
516
528
 
517
529
  function renderModelPill() {
518
530
  if (!modelPillLabel) return;
519
- modelPillLabel.textContent = state.active.model || 'elegí un modelo';
520
- if (modelPill) modelPill.title = `${state.active.provider || '—'} / ${state.active.model || '—'}`;
531
+ const record = state.panes.get(state.sessionId);
532
+ const provider = record?.provider || state.active.provider;
533
+ const model = record?.model || state.active.model;
534
+ modelPillLabel.textContent = model || 'elegí un modelo';
535
+ if (modelPill) modelPill.title = `${provider || '—'} / ${model || '—'}`;
521
536
  }
522
537
 
523
538
  function closeModeMenu() {
@@ -543,7 +558,7 @@ modeMenu?.addEventListener('click', async (event) => {
543
558
  if (yesInput) yesInput.checked = true;
544
559
  } else {
545
560
  if (yesInput) yesInput.checked = false;
546
- await setMode(kind === 'read' ? 'plan' : 'agent');
561
+ await setMode(kind === 'read' ? 'plan' : kind === 'voice' ? 'voice' : 'agent');
547
562
  }
548
563
  renderModePill();
549
564
  });
@@ -773,7 +788,45 @@ async function saveSettings(patch) {
773
788
  updateStatus();
774
789
  }
775
790
 
776
- async function loadModels({ save = false } = {}) {
791
+ /**
792
+ * Persiste el tipo de agente elegido. Con una sesion abierta, ese tipo es de la
793
+ * sesion (asi dos pestañas pueden correr con proveedor/modelo/modo distintos);
794
+ * ademas se actualiza el default global para que las sesiones nuevas hereden.
795
+ */
796
+ async function persistAgent(patch) {
797
+ const record = state.panes.get(state.sessionId);
798
+ if (record) {
799
+ if (patch.provider) record.provider = patch.provider;
800
+ if (patch.model) record.model = patch.model;
801
+ if (patch.mode) record.mode = patch.mode;
802
+ api(`/api/sessions/${record.id}`, { method: 'PUT', body: patch }).catch(() => null);
803
+ }
804
+ await saveSettings(patch);
805
+ }
806
+
807
+ /** Aplica a los controles compartidos el tipo de agente guardado en una sesion. */
808
+ async function applySessionAgent(record) {
809
+ if (!record) return;
810
+ const id = record.id;
811
+ const provider = record.provider || state.active.provider;
812
+ if (provider && [...providerSelect.options].some((o) => o.value === provider)) {
813
+ providerSelect.value = provider;
814
+ }
815
+ await loadModels({ prefer: record.model || null });
816
+ if (state.sessionId !== id) return;
817
+ const model = record.model || state.active.model;
818
+ if (model && [...modelSelect.options].some((o) => o.value === model)) modelSelect.value = model;
819
+ const mode = record.mode || modeSelect.value || 'agent';
820
+ if ([...modeSelect.options].some((o) => o.value === mode)) modeSelect.value = mode;
821
+ // No auto-encendemos manos libres al abrir (evita pedir el micro al recargar);
822
+ // si la sesión no es de voz, sí apagamos.
823
+ if (mode !== 'voice') setHandsFree(false);
824
+ updateStatus();
825
+ renderModePill();
826
+ renderModelPill();
827
+ }
828
+
829
+ async function loadModels({ save = false, prefer = null } = {}) {
777
830
  const provider = providerSelect.value;
778
831
  modelSelect.disabled = true;
779
832
  const data = await api(`/api/models?provider=${encodeURIComponent(provider)}`).catch((error) => ({
@@ -801,8 +854,9 @@ async function loadModels({ save = false } = {}) {
801
854
  if (model.context) option.dataset.context = String(model.context);
802
855
  modelSelect.appendChild(option);
803
856
  }
804
- if (state.active.model && models.some((m) => m.id === state.active.model)) {
805
- modelSelect.value = state.active.model;
857
+ const wanted = prefer || state.active.model;
858
+ if (wanted && models.some((m) => m.id === wanted)) {
859
+ modelSelect.value = wanted;
806
860
  } else {
807
861
  modelSelect.value = models[0].id;
808
862
  if (save) await saveSettings({ model: modelSelect.value });
@@ -813,10 +867,10 @@ async function loadModels({ save = false } = {}) {
813
867
  }
814
868
 
815
869
  providerSelect.addEventListener('change', async () => {
816
- await saveSettings({ provider: providerSelect.value });
817
- await loadModels({ save: true });
870
+ await loadModels({ save: false });
871
+ await persistAgent({ provider: providerSelect.value, model: modelSelect.value || undefined });
818
872
  });
819
- modelSelect.addEventListener('change', () => saveSettings({ model: modelSelect.value }));
873
+ modelSelect.addEventListener('change', () => persistAgent({ model: modelSelect.value }));
820
874
 
821
875
  /* --- chat ---------------------------------------------------------------- */
822
876
 
@@ -1091,6 +1145,7 @@ async function deleteFrom(message) {
1091
1145
  const ACTIVITY = {
1092
1146
  thinking: { cls: 'loader-thinking', label: 'pensando…' },
1093
1147
  working: { cls: 'loader-working', label: 'trabajando…' },
1148
+ listening: { cls: 'loader-working', label: 'escuchando…' },
1094
1149
  reconnecting: { cls: 'loader-working', label: 'reconectando…' },
1095
1150
  stopping: { cls: 'loader-working', label: 'deteniendo…' },
1096
1151
  };
@@ -1367,7 +1422,7 @@ async function loadFullToolResult(entry, message) {
1367
1422
  function speakControl(args) {
1368
1423
  const texto = String(args?.texto ?? '').trim();
1369
1424
  if (!texto) return null;
1370
- const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
1425
+ const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, pitch: args?.pitch, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
1371
1426
  btn.append(icon('play', 13), el('span', '', 'escuchar'));
1372
1427
  return btn;
1373
1428
  }
@@ -1721,8 +1776,27 @@ function addAdvisorCard(container, { text, kind } = {}) {
1721
1776
  return card;
1722
1777
  }
1723
1778
 
1779
+ /**
1780
+ * Marca del punto de compactación: una línea que corta el historial y, debajo,
1781
+ * el resumen que pasa a ser el contexto del modelo. Lo de arriba sigue visible
1782
+ * como histórico; al modelo solo le llega lo de abajo (más los mensajes nuevos).
1783
+ */
1784
+ function addCompactCard(container, { text, label = 'contexto compactado' } = {}) {
1785
+ if (!container) return null;
1786
+ const card = el('div', 'compact-card');
1787
+ card.append(el('div', 'compact-rule', label));
1788
+ const clean = String(text ?? '').trim();
1789
+ if (clean) card.append(el('div', 'compact-text', clean));
1790
+ container.appendChild(card);
1791
+ return card;
1792
+ }
1793
+
1724
1794
  function renderStored(message, pane) {
1725
1795
  if (message.agent_id) return;
1796
+ if (message.name === 'compact') {
1797
+ addCompactCard(pane, { text: typeof message.content === 'string' ? message.content : '' });
1798
+ return;
1799
+ }
1726
1800
  if (message.role === 'assistant' && message.name === 'advisor') {
1727
1801
  addAdvisorCard(pane, {
1728
1802
  text: typeof message.content === 'string' ? message.content : '',
@@ -1955,6 +2029,11 @@ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, run
1955
2029
  el: pane,
1956
2030
  title: session.title || '(sin título)',
1957
2031
  cwd: session.cwd || '',
2032
+ // Tipo de agente de la sesion: se aplica a los controles al abrirla y se
2033
+ // persiste al cambiarlos. Cada pestaña puede correr con uno distinto.
2034
+ provider: session.provider || '',
2035
+ model: session.model || '',
2036
+ mode: session.mode || '',
1958
2037
  working: false,
1959
2038
  tokens,
1960
2039
  lastTurn: { prompt: 0, completion: 0 },
@@ -2048,6 +2127,7 @@ function activateSession(id) {
2048
2127
  auditStatus(record);
2049
2128
  updateStatus();
2050
2129
  renderTokens();
2130
+ applySessionAgent(record);
2051
2131
  renderQueue(id);
2052
2132
  renderTabs();
2053
2133
  refreshSessions();
@@ -2366,7 +2446,7 @@ function handleChatEvent(event, data, turn) {
2366
2446
  turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
2367
2447
  if (turn.live && data.name === 'speak') {
2368
2448
  const texto = String(data.args?.texto ?? '').trim();
2369
- if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
2449
+ if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, pitch: data.args?.pitch, label: 'voz' }).catch(() => {});
2370
2450
  }
2371
2451
  if (turn.live && data.name === 'preview_url' && data.args?.url) {
2372
2452
  setExplorerMode('preview');
@@ -2610,6 +2690,9 @@ async function startTurn(sessionKey, pane, input, images, opts = {}) {
2610
2690
  controllers.set(sessionKey, controller);
2611
2691
  setWorking(sessionKey, true);
2612
2692
 
2693
+ // El tipo de agente lo manda la sesion (no los controles globales): asi dos
2694
+ // pestañas con modelos distintos no se pisan.
2695
+ const owner = state.panes.get(sessionKey);
2613
2696
  try {
2614
2697
  await streamPost(
2615
2698
  '/api/chat',
@@ -2617,9 +2700,9 @@ async function startTurn(sessionKey, pane, input, images, opts = {}) {
2617
2700
  input: input || '(imagen adjunta)',
2618
2701
  images: images.length ? images : undefined,
2619
2702
  sessionId: sessionKey,
2620
- provider: providerSelect.value || undefined,
2621
- model: modelSelect.value || undefined,
2622
- mode: modeSelect.value || undefined,
2703
+ provider: owner?.provider || providerSelect.value || undefined,
2704
+ model: owner?.model || modelSelect.value || undefined,
2705
+ mode: owner?.mode || modeSelect.value || undefined,
2623
2706
  yes: yesInput.checked ? true : undefined,
2624
2707
  contextTokens: contextTokensForRequest(),
2625
2708
  interrupt: opts.interrupt ? true : undefined,
@@ -2984,7 +3067,7 @@ function encodeWav(samples, rate = 16000) {
2984
3067
  * `stop()` espera la cola antes de cortar. La usan el micrófono del chat y el
2985
3068
  * "probar" de Ajustes.
2986
3069
  */
2987
- function createRecording() {
3070
+ function createRecording({ silenceMs = 0, noSpeechMs = 0, maxMs = 0, speechThreshold = 0.02 } = {}) {
2988
3071
  let resolveBlob;
2989
3072
  const done = new Promise((resolve) => {
2990
3073
  resolveBlob = resolve;
@@ -2996,9 +3079,15 @@ function createRecording() {
2996
3079
  let sink = null;
2997
3080
  let stopping = false;
2998
3081
  let started = false;
3082
+ let startedAt = 0;
3083
+ let hasSpeech = false;
3084
+ let lastLoudAt = 0;
3085
+ let watchdog = null;
2999
3086
  const chunks = [];
3000
3087
 
3001
3088
  async function cleanup() {
3089
+ if (watchdog) clearInterval(watchdog);
3090
+ watchdog = null;
3002
3091
  try {
3003
3092
  processor?.disconnect();
3004
3093
  } catch {
@@ -3022,6 +3111,37 @@ function createRecording() {
3022
3111
  stream?.getTracks?.().forEach((track) => track.stop());
3023
3112
  }
3024
3113
 
3114
+ function stopRecording() {
3115
+ if (stopping || !started) return;
3116
+ stopping = true;
3117
+ if (watchdog) clearInterval(watchdog);
3118
+ watchdog = null;
3119
+ setTimeout(async () => {
3120
+ const rate = audioCtx?.sampleRate || 48000;
3121
+ const merged = mergeFloat32(chunks);
3122
+ await cleanup();
3123
+ const resampled = await resampleTo16k(merged, rate);
3124
+ resolveBlob(encodeWav(resampled, 16000));
3125
+ }, REC_TAIL_MS);
3126
+ }
3127
+
3128
+ // Manos libres: corta solo por silencio (tras haber hablado), si no hubo voz en
3129
+ // `noSpeechMs` o al llegar a `maxMs`. Sin estas opciones no interviene.
3130
+ function startWatchdog() {
3131
+ if (!silenceMs && !noSpeechMs && !maxMs) return;
3132
+ watchdog = setInterval(() => {
3133
+ if (stopping || !started) return;
3134
+ const now = Date.now();
3135
+ if (maxMs && now - startedAt >= maxMs) return stopRecording();
3136
+ if (!silenceMs) return;
3137
+ if (!hasSpeech) {
3138
+ if (noSpeechMs && now - startedAt >= noSpeechMs) stopRecording();
3139
+ return;
3140
+ }
3141
+ if (now - lastLoudAt >= silenceMs) stopRecording();
3142
+ }, 150);
3143
+ }
3144
+
3025
3145
  return {
3026
3146
  async start() {
3027
3147
  const Ctx = window.AudioContext || window.webkitAudioContext;
@@ -3034,7 +3154,16 @@ function createRecording() {
3034
3154
  source = audioCtx.createMediaStreamSource(stream);
3035
3155
  processor = audioCtx.createScriptProcessor(4096, 1, 1);
3036
3156
  processor.onaudioprocess = (event) => {
3037
- chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
3157
+ const frame = event.inputBuffer.getChannelData(0);
3158
+ chunks.push(new Float32Array(frame));
3159
+ if (!silenceMs) return;
3160
+ let sum = 0;
3161
+ for (let i = 0; i < frame.length; i += 2) sum += frame[i] * frame[i];
3162
+ const rms = Math.sqrt(sum / (frame.length / 2));
3163
+ if (rms >= speechThreshold) {
3164
+ hasSpeech = true;
3165
+ lastLoudAt = Date.now();
3166
+ }
3038
3167
  };
3039
3168
  // El procesador solo corre si llega al destination: un gain en 0 evita el eco.
3040
3169
  sink = audioCtx.createGain();
@@ -3043,21 +3172,13 @@ function createRecording() {
3043
3172
  processor.connect(sink);
3044
3173
  sink.connect(audioCtx.destination);
3045
3174
  started = true;
3175
+ startedAt = Date.now();
3176
+ startWatchdog();
3046
3177
  },
3047
3178
  get stopping() {
3048
3179
  return stopping;
3049
3180
  },
3050
- stop() {
3051
- if (stopping || !started) return;
3052
- stopping = true;
3053
- setTimeout(async () => {
3054
- const rate = audioCtx?.sampleRate || 48000;
3055
- const merged = mergeFloat32(chunks);
3056
- await cleanup();
3057
- const resampled = await resampleTo16k(merged, rate);
3058
- resolveBlob(encodeWav(resampled, 16000));
3059
- }, REC_TAIL_MS);
3060
- },
3181
+ stop: stopRecording,
3061
3182
  done,
3062
3183
  };
3063
3184
  }
@@ -3180,6 +3301,8 @@ function startBrowserMic() {
3180
3301
 
3181
3302
  async function toggleMic() {
3182
3303
  if (!micBtn) return;
3304
+ // Con manos libres el micro lo maneja el loop: no dejamos que se pisen.
3305
+ if (handsFree.active) return;
3183
3306
  if (browserDictation) {
3184
3307
  browserDictation.stop();
3185
3308
  return;
@@ -3229,6 +3352,155 @@ async function toggleMic() {
3229
3352
 
3230
3353
  micBtn?.addEventListener('click', toggleMic);
3231
3354
 
3355
+ /* --- manos libres: conversación por voz ----------------------------------- */
3356
+
3357
+ const HANDS_FREE_SILENCE_MS = 1400; // silencio que cierra tu turno
3358
+ const HANDS_FREE_NOSPEECH_MS = 8000; // si no hablás, vuelve a intentar
3359
+ const HANDS_FREE_MAX_MS = 45000; // tope duro de una intervención
3360
+
3361
+ const handsFreeBtn = $('#hands-free-btn');
3362
+ const handsFree = { active: false, phase: 'idle', token: 0 };
3363
+
3364
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3365
+
3366
+ function renderHandsFree() {
3367
+ if (handsFreeBtn) {
3368
+ handsFreeBtn.classList.toggle('hands-free-active', handsFree.active);
3369
+ handsFreeBtn.classList.toggle('listening', handsFree.active && handsFree.phase === 'listening');
3370
+ handsFreeBtn.setAttribute('aria-pressed', handsFree.active ? 'true' : 'false');
3371
+ handsFreeBtn.title = handsFree.active
3372
+ ? 'manos libres: activado (tocá para desactivar)'
3373
+ : 'manos libres: conversación por voz';
3374
+ }
3375
+ if (handsFree.active && handsFree.phase === 'listening') renderFooterActivity('listening');
3376
+ else if (handsFree.active && handsFree.phase === 'thinking') renderFooterActivity('thinking');
3377
+ else if (handsFree.active) renderFooterActivity(null);
3378
+ }
3379
+
3380
+ function setHandsFreePhase(phase) {
3381
+ handsFree.phase = phase;
3382
+ renderHandsFree();
3383
+ }
3384
+
3385
+ function setHandsFree(on) {
3386
+ const next = Boolean(on);
3387
+ if (handsFree.active === next) return;
3388
+ handsFree.active = next;
3389
+ handsFree.token += 1;
3390
+ if (next) {
3391
+ // Cortamos un dictado manual en curso para no pisarlo.
3392
+ composerRecording?.stop();
3393
+ setHandsFreePhase('idle');
3394
+ renderHandsFree();
3395
+ runHandsFree(handsFree.token).catch(() => {});
3396
+ } else {
3397
+ // Cortamos lo que esté grabando para no dejar el micro abierto.
3398
+ handsFree.recording?.stop();
3399
+ handsFree.recording = null;
3400
+ browserDictation?.stop();
3401
+ setHandsFreePhase('idle');
3402
+ renderHandsFree();
3403
+ }
3404
+ }
3405
+
3406
+ function toggleHandsFree() {
3407
+ setHandsFree(!handsFree.active);
3408
+ }
3409
+
3410
+ handsFreeBtn?.addEventListener('click', toggleHandsFree);
3411
+
3412
+ /** Una escucha de manos libres: graba hasta el silencio y devuelve el texto. */
3413
+ async function handsFreeListen(token) {
3414
+ if (transcribeConfig.mode === 'browser') {
3415
+ const text = await new Promise((resolve, reject) => {
3416
+ let combined = '';
3417
+ let dictation;
3418
+ const finish = () => {
3419
+ if (browserDictation === dictation) browserDictation = null;
3420
+ resolve(combined.trim());
3421
+ };
3422
+ try {
3423
+ dictation = createBrowserDictation({
3424
+ language: transcribeConfig.language,
3425
+ onText: (_final, all) => {
3426
+ combined = all;
3427
+ },
3428
+ onEnd: finish,
3429
+ onError: reject,
3430
+ });
3431
+ } catch (error) {
3432
+ reject(error);
3433
+ return;
3434
+ }
3435
+ browserDictation = dictation;
3436
+ dictation.start();
3437
+ setTimeout(() => {
3438
+ if (browserDictation === dictation) dictation.stop();
3439
+ }, HANDS_FREE_MAX_MS);
3440
+ });
3441
+ return token === handsFree.token ? text : '';
3442
+ }
3443
+
3444
+ const rec = createRecording({
3445
+ silenceMs: HANDS_FREE_SILENCE_MS,
3446
+ noSpeechMs: HANDS_FREE_NOSPEECH_MS,
3447
+ maxMs: HANDS_FREE_MAX_MS,
3448
+ });
3449
+ await rec.start();
3450
+ if (token !== handsFree.token) {
3451
+ rec.stop();
3452
+ return '';
3453
+ }
3454
+ handsFree.recording = rec;
3455
+ setHandsFreePhase('listening');
3456
+ const blob = await rec.done;
3457
+ handsFree.recording = null;
3458
+ if (token !== handsFree.token || !blob.size) return '';
3459
+ setHandsFreePhase('thinking');
3460
+ try {
3461
+ return await transcribirBlob(blob);
3462
+ } catch (error) {
3463
+ toast(`No pude transcribir: ${error.message}`);
3464
+ return '';
3465
+ }
3466
+ }
3467
+
3468
+ /** Loop conversacional: escuchar → enviar → esperar la voz → repetir. */
3469
+ async function runHandsFree(token) {
3470
+ while (handsFree.active && token === handsFree.token) {
3471
+ if (!state.sessionId) {
3472
+ toast('Manos libres necesita una sesión abierta.');
3473
+ setHandsFree(false);
3474
+ return;
3475
+ }
3476
+ if (state.panes.get(state.sessionId)?.working) {
3477
+ await sleep(400);
3478
+ continue;
3479
+ }
3480
+
3481
+ let text = '';
3482
+ try {
3483
+ text = await handsFreeListen(token);
3484
+ } catch (error) {
3485
+ if (token !== handsFree.token) return;
3486
+ toast(`Manos libres: ${error.message}`);
3487
+ setHandsFree(false);
3488
+ return;
3489
+ }
3490
+ if (!handsFree.active || token !== handsFree.token) return;
3491
+ if (!text) continue; // silencio o no se entendió: otra vuelta
3492
+
3493
+ chatInput.value = text;
3494
+ onChatInputChanged();
3495
+ setHandsFreePhase('thinking');
3496
+ await sendMessage();
3497
+ if (!handsFree.active || token !== handsFree.token) return;
3498
+ // Esperamos a que termine de hablar antes de volver a escuchar (evita el eco).
3499
+ await whenAudioIdle();
3500
+ await sleep(250);
3501
+ }
3502
+ }
3503
+
3232
3504
  /* --- image attachments ---------------------------------------------------- */
3233
3505
 
3234
3506
  const attachInput = $('#image-input');
@@ -3465,13 +3737,18 @@ async function runCommand(name, arg) {
3465
3737
  }
3466
3738
  case 'compact': {
3467
3739
  if (!state.sessionId) return needSession();
3468
- notify('Compactando la conversación…');
3740
+ toast('Compactando la conversación…');
3469
3741
  const out = await api(`/api/sessions/${state.sessionId}/compact`, { method: 'POST' }).catch((error) => ({
3470
3742
  error: error.message,
3471
3743
  }));
3472
- clear(activePane);
3473
- if (out.error) notify(`No se pudo compactar: ${out.error}`, 'notice err');
3474
- else notify(`Contexto compactado. Resumen guardado:\n\n${out.summary}`);
3744
+ if (out.error) {
3745
+ notify(`No se pudo compactar: ${out.error}`, 'notice err');
3746
+ return { handled: true };
3747
+ }
3748
+ // No borramos el pane: el historial queda a la vista y marcamos el corte
3749
+ // con la línea + el resumen que ahora ve el modelo.
3750
+ addCompactCard(activePane, { text: out.summary });
3751
+ scrollMessages();
3475
3752
  refreshSessions();
3476
3753
  return { handled: true };
3477
3754
  }
@@ -3736,7 +4013,17 @@ async function loadFolders(target = '') {
3736
4013
  }
3737
4014
 
3738
4015
  async function createSessionIn(cwd, { title = 'Nueva sesión' } = {}) {
3739
- const created = await api('/api/sessions', { method: 'POST', body: { title, cwd } }).catch((error) => ({
4016
+ const current = state.panes.get(state.sessionId);
4017
+ const created = await api('/api/sessions', {
4018
+ method: 'POST',
4019
+ body: {
4020
+ title,
4021
+ cwd,
4022
+ provider: current?.provider || providerSelect.value || undefined,
4023
+ model: current?.model || modelSelect.value || undefined,
4024
+ mode: current?.mode || modeSelect.value || undefined,
4025
+ },
4026
+ }).catch((error) => ({
3740
4027
  error: error.message,
3741
4028
  }));
3742
4029
  if (created?.error) return created;
@@ -3866,6 +4153,8 @@ async function refreshProviders() {
3866
4153
  state.active = data.active;
3867
4154
  syncTopbar();
3868
4155
  renderProviderList();
4156
+ const record = state.panes.get(state.sessionId);
4157
+ if (record) await applySessionAgent(record);
3869
4158
  }
3870
4159
 
3871
4160
  function isConfigured(p) {
@@ -3881,7 +4170,8 @@ function syncTopbar() {
3881
4170
  option.textContent = p.label && p.label !== p.id ? `${p.label} (${p.id})` : p.id;
3882
4171
  providerSelect.appendChild(option);
3883
4172
  }
3884
- const active = state.active.provider;
4173
+ const record = state.panes.get(state.sessionId);
4174
+ const active = record?.provider || state.active.provider;
3885
4175
  if (active && !configured.some((p) => p.id === active)) {
3886
4176
  const current = state.providers.find((p) => p.id === active);
3887
4177
  const option = document.createElement('option');
@@ -3930,8 +4220,9 @@ function renderProviderList() {
3930
4220
  }
3931
4221
 
3932
4222
  async function useProvider(id) {
3933
- await saveSettings({ provider: id });
3934
- await loadModels({ save: true });
4223
+ providerSelect.value = id;
4224
+ await loadModels({ save: false });
4225
+ await persistAgent({ provider: id, model: modelSelect.value || undefined });
3935
4226
  await refreshProviders();
3936
4227
  }
3937
4228
 
@@ -4050,6 +4341,9 @@ const tgStatusEl = $('#tg-status');
4050
4341
  const vozEnabledEl = $('#voz-enabled');
4051
4342
  const vozVoiceEl = $('#voz-voice');
4052
4343
  const vozRateEl = $('#voz-rate');
4344
+ const vozRateValEl = $('#voz-rate-val');
4345
+ const vozPitchEl = $('#voz-pitch');
4346
+ const vozPitchValEl = $('#voz-pitch-val');
4053
4347
  const vozAnnStartEl = $('#voz-ann-start');
4054
4348
  const vozAnnToolsEl = $('#voz-ann-tools');
4055
4349
  const vozAnnEndEl = $('#voz-ann-end');
@@ -4645,6 +4939,7 @@ const voz = {
4645
4939
  enabled: false,
4646
4940
  voice: 'es-AR-ElenaNeural',
4647
4941
  rate: '+0%',
4942
+ pitch: '+0Hz',
4648
4943
  announce: { start: true, tools: true, end: true, advice: true, answer: false, summary: false },
4649
4944
  },
4650
4945
  };
@@ -4657,6 +4952,18 @@ const voz = {
4657
4952
  */
4658
4953
  const ap = { el: $('#audio-player'), audio: new Audio(), queue: [], current: null, ready: false };
4659
4954
 
4955
+ // Avisa cuando el reproductor queda en silencio (cola vacia). Lo usa manos libres
4956
+ // para saber cuando el asistente termino de hablar y volver a escuchar.
4957
+ const audioIdleWaiters = [];
4958
+ function whenAudioIdle() {
4959
+ if (!ap.current && !ap.queue.length) return Promise.resolve();
4960
+ return new Promise((resolve) => audioIdleWaiters.push(resolve));
4961
+ }
4962
+ function notifyAudioIdle() {
4963
+ if (ap.current || ap.queue.length) return;
4964
+ for (const resolve of audioIdleWaiters.splice(0)) resolve();
4965
+ }
4966
+
4660
4967
  function ensurePlayer() {
4661
4968
  if (!ap.el || ap.ready) return;
4662
4969
  ap.ready = true;
@@ -4689,13 +4996,53 @@ function ensurePlayer() {
4689
4996
  });
4690
4997
  }
4691
4998
 
4999
+ /** Convierte la velocidad guardada ("+20%") al valor del slider (20). */
5000
+ function rateToSlider(rate) {
5001
+ const m = /^([+-]?)(\d+)%$/.exec(String(rate ?? '').trim());
5002
+ return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
5003
+ }
5004
+
5005
+ /** Convierte el valor del slider (-20) a la velocidad guardada. */
5006
+ function sliderToRate(n) {
5007
+ const v = Math.max(-100, Math.min(100, Math.round(Number(n) || 0)));
5008
+ return v === 0 ? '+0%' : `${v > 0 ? '+' : '-'}${Math.abs(v)}%`;
5009
+ }
5010
+
5011
+ /** Convierte el tono guardado ("-10Hz") al valor del slider (-10). */
5012
+ function pitchToSlider(pitch) {
5013
+ const m = /^([+-]?)(\d+)Hz$/.exec(String(pitch ?? '').trim());
5014
+ return m ? (m[1] === '-' ? -1 : 1) * Number(m[2]) : 0;
5015
+ }
5016
+
5017
+ /** Convierte el valor del slider (-10) al tono guardado. */
5018
+ function sliderToPitch(n) {
5019
+ const v = Math.max(-50, Math.min(50, Math.round(Number(n) || 0)));
5020
+ return v === 0 ? '+0Hz' : `${v > 0 ? '+' : '-'}${Math.abs(v)}Hz`;
5021
+ }
5022
+
5023
+ function etiquetaRate(n) {
5024
+ const v = Math.round(Number(n) || 0);
5025
+ return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)}%`;
5026
+ }
5027
+
5028
+ function etiquetaPitch(n) {
5029
+ const v = Math.round(Number(n) || 0);
5030
+ return v === 0 ? 'normal' : `${v > 0 ? '+' : '−'}${Math.abs(v)} Hz`;
5031
+ }
5032
+
5033
+ /** Refresca la etiqueta numérica de los sliders de voz. */
5034
+ function renderVozSliders() {
5035
+ if (vozRateValEl && vozRateEl) vozRateValEl.textContent = etiquetaRate(vozRateEl.value);
5036
+ if (vozPitchValEl && vozPitchEl) vozPitchValEl.textContent = etiquetaPitch(vozPitchEl.value);
5037
+ }
5038
+
4692
5039
  /** Encola un audio. La promesa se resuelve al terminar (o al detenerlo). */
4693
- function enqueueSpeech({ texto, voice, rate, label = 'audio', maxAgeMs = 0 } = {}) {
5040
+ function enqueueSpeech({ texto, voice, rate, pitch, label = 'audio', maxAgeMs = 0 } = {}) {
4694
5041
  const frase = String(texto ?? '').trim();
4695
5042
  if (!frase) return Promise.resolve();
4696
5043
  ensurePlayer();
4697
5044
  return new Promise((resolve, reject) => {
4698
- ap.queue.push({ texto: frase, voice, rate, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
5045
+ ap.queue.push({ texto: frase, voice, rate, pitch, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
4699
5046
  if (ap.current) renderAudio();
4700
5047
  else playNextAudio();
4701
5048
  });
@@ -4706,6 +5053,7 @@ function playNextAudio() {
4706
5053
  if (!item) {
4707
5054
  ap.current = null;
4708
5055
  renderAudio();
5056
+ notifyAudioIdle();
4709
5057
  return;
4710
5058
  }
4711
5059
  if (item.maxAgeMs && Date.now() - item.createdAt > item.maxAgeMs) {
@@ -4717,6 +5065,7 @@ function playNextAudio() {
4717
5065
  texto: item.texto,
4718
5066
  voice: item.voice ?? voz.settings.voice,
4719
5067
  rate: item.rate ?? voz.settings.rate,
5068
+ pitch: item.pitch ?? voz.settings.pitch,
4720
5069
  });
4721
5070
  ap.audio.src = `/api/tts?${params}`;
4722
5071
  ap.audio.play().catch(() => {
@@ -4751,6 +5100,7 @@ function stopAudio() {
4751
5100
  ap.audio.load();
4752
5101
  if (ap.barEl) ap.barEl.style.width = '0%';
4753
5102
  renderAudio();
5103
+ notifyAudioIdle();
4754
5104
  }
4755
5105
 
4756
5106
  function renderAudio() {
@@ -4768,24 +5118,40 @@ function renderAudio() {
4768
5118
  }
4769
5119
 
4770
5120
  /** Reproduce una frase contra el endpoint de sintesis del server. */
4771
- function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
4772
- return enqueueSpeech({ texto: frase, voice, rate, label, maxAgeMs });
5121
+ function reproducirVoz(frase, { voice, rate, pitch, label, maxAgeMs } = {}) {
5122
+ return enqueueSpeech({ texto: frase, voice, rate, pitch, label, maxAgeMs });
4773
5123
  }
4774
5124
 
4775
5125
  /**
4776
5126
  * Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
4777
5127
  * cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
4778
5128
  */
4779
- function hablar(texto, { label = 'narrando', maxAgeMs = 45000 } = {}) {
5129
+ function hablar(texto, { label = 'narrando', maxAgeMs = 45000, force = false } = {}) {
4780
5130
  const frase = String(texto ?? '').trim();
4781
- if (!frase || !voz.settings.enabled) return;
5131
+ if (!frase || (!voz.settings.enabled && !force)) return;
4782
5132
  reproducirVoz(frase, { label, maxAgeMs }).catch(() => {});
4783
5133
  }
4784
5134
 
4785
5135
  /** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
4786
5136
  function narrar(event, data, turn) {
4787
5137
  if (!turn?.live) return;
5138
+ const handsFreeOn = handsFree.active;
4788
5139
  const { enabled, announce } = voz.settings;
5140
+ if (!enabled && !handsFreeOn) return;
5141
+ // Manos libres: conversacion, sin narracion de arranque/herramientas; lee la
5142
+ // respuesta final (y el asesor) aunque la narracion este apagada en Ajustes.
5143
+ if (handsFreeOn) {
5144
+ if (event === 'advice') {
5145
+ if (data?.say) hablar(data.say, { label: 'asesor', maxAgeMs: 0, force: true });
5146
+ } else if (event === 'done') {
5147
+ const spoken = speechText(data?.content);
5148
+ if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0, force: true });
5149
+ else if (data?.say) hablar(data.say, { maxAgeMs: 0, force: true });
5150
+ } else if ((event === 'aborted' || event === 'error') && data?.say) {
5151
+ hablar(data.say, { maxAgeMs: 0, force: true });
5152
+ }
5153
+ return;
5154
+ }
4789
5155
  if (!enabled) return;
4790
5156
  if (event === 'start' && announce.start) hablar(data?.say);
4791
5157
  else if (event === 'tool_call' && announce.tools) hablar(data?.say);
@@ -4824,6 +5190,7 @@ async function aplicarVoz(data) {
4824
5190
  enabled: data.enabled === true,
4825
5191
  voice: data.voice || 'es-AR-ElenaNeural',
4826
5192
  rate: data.rate || '+0%',
5193
+ pitch: data.pitch || '+0Hz',
4827
5194
  announce: {
4828
5195
  start: data.announce?.start !== false,
4829
5196
  tools: data.announce?.tools !== false,
@@ -4844,7 +5211,9 @@ async function aplicarVoz(data) {
4844
5211
  }
4845
5212
  vozVoiceEl.value = voz.settings.voice;
4846
5213
  }
4847
- if (vozRateEl) vozRateEl.value = voz.settings.rate;
5214
+ if (vozRateEl) vozRateEl.value = rateToSlider(voz.settings.rate);
5215
+ if (vozPitchEl) vozPitchEl.value = pitchToSlider(voz.settings.pitch);
5216
+ renderVozSliders();
4848
5217
  if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
4849
5218
  if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
4850
5219
  if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
@@ -4869,7 +5238,8 @@ async function submitVoz() {
4869
5238
  const body = {
4870
5239
  enabled: vozEnabledEl?.checked === true,
4871
5240
  voice: vozVoiceEl?.value,
4872
- rate: vozRateEl?.value,
5241
+ rate: sliderToRate(vozRateEl?.value),
5242
+ pitch: sliderToPitch(vozPitchEl?.value),
4873
5243
  announce: {
4874
5244
  start: vozAnnStartEl?.checked !== false,
4875
5245
  tools: vozAnnToolsEl?.checked !== false,
@@ -5043,10 +5413,15 @@ trTestBtn?.addEventListener('click', toggleTrTest);
5043
5413
 
5044
5414
  async function testVoz() {
5045
5415
  setStatus('#voz-msg', 'sintetizando…');
5416
+ const trato = ($('#perfil-address')?.value || '').trim();
5417
+ const frase = trato
5418
+ ? `${trato}, esta sería la voz predeterminada para mí`
5419
+ : 'Esta sería la voz predeterminada para mí';
5046
5420
  try {
5047
- await reproducirVoz('Hola!, soy tu agente de IA, y así es la voz con la que cuento lo que estoy haciendo. ¿Qué te parece?', {
5421
+ await reproducirVoz(frase, {
5048
5422
  voice: vozVoiceEl?.value,
5049
- rate: vozRateEl?.value,
5423
+ rate: sliderToRate(vozRateEl?.value),
5424
+ pitch: sliderToPitch(vozPitchEl?.value),
5050
5425
  });
5051
5426
  setStatus('#voz-msg', 'sonó', 'ok');
5052
5427
  } catch (error) {
@@ -5108,8 +5483,7 @@ async function testAsesor() {
5108
5483
  setStatus('#asesor-msg', 'consultando…');
5109
5484
  const data = await api('/api/advisor/test', { method: 'POST' }).catch((e) => ({ error: e.message }));
5110
5485
  if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
5111
- const partes = [data.speak ? data.text : '', data.summary ? `resumen: ${data.summary}` : ''].filter(Boolean);
5112
- setStatus('#asesor-msg', partes.length ? partes.join(' · ') : '(no diría nada en este caso)', 'ok');
5486
+ setStatus('#asesor-msg', data?.speak && data.text ? data.text : '(no diría nada en este caso)', 'ok');
5113
5487
  }
5114
5488
 
5115
5489
  /* --- ajustes: perfil del usuario ------------------------------------------ */
@@ -5416,6 +5790,8 @@ if (mcpListEl) {
5416
5790
  $('#tg-test')?.addEventListener('click', testChannel);
5417
5791
  $('#voz-save')?.addEventListener('click', submitVoz);
5418
5792
  $('#voz-test')?.addEventListener('click', testVoz);
5793
+ vozRateEl?.addEventListener('input', renderVozSliders);
5794
+ vozPitchEl?.addEventListener('input', renderVozSliders);
5419
5795
  $('#asesor-save')?.addEventListener('click', submitAsesor);
5420
5796
  $('#asesor-test')?.addEventListener('click', testAsesor);
5421
5797
  $('#mcp-close').addEventListener('click', closeMcpForm);
@@ -6712,6 +7088,10 @@ async function boot() {
6712
7088
  await loadModes();
6713
7089
  await loadModels();
6714
7090
  await refreshTranscribe();
7091
+ // La config de voz se carga al boot (sin `await`, para no bloquear con el
7092
+ // fetch del catálogo de voces): si no, `voz.settings.enabled` queda en el
7093
+ // default `false` hasta abrir Ajustes y la narración no arranca al entrar.
7094
+ refreshVoz();
6715
7095
  await reconcile();
6716
7096
  await restoreTabs();
6717
7097
  renderAlertSignals();