@danieltmn/tamnora 0.19.0 → 0.20.1

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.
@@ -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
  };
@@ -1721,8 +1776,31 @@ 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) {
1790
+ const body = el('div', 'compact-text text');
1791
+ body.innerHTML = renderMarkdown(clean);
1792
+ card.append(body);
1793
+ }
1794
+ container.appendChild(card);
1795
+ return card;
1796
+ }
1797
+
1724
1798
  function renderStored(message, pane) {
1725
1799
  if (message.agent_id) return;
1800
+ if (message.name === 'compact') {
1801
+ addCompactCard(pane, { text: typeof message.content === 'string' ? message.content : '' });
1802
+ return;
1803
+ }
1726
1804
  if (message.role === 'assistant' && message.name === 'advisor') {
1727
1805
  addAdvisorCard(pane, {
1728
1806
  text: typeof message.content === 'string' ? message.content : '',
@@ -1955,6 +2033,11 @@ function registerSession(session, messages = [], tokens = { in: 0, out: 0 }, run
1955
2033
  el: pane,
1956
2034
  title: session.title || '(sin título)',
1957
2035
  cwd: session.cwd || '',
2036
+ // Tipo de agente de la sesion: se aplica a los controles al abrirla y se
2037
+ // persiste al cambiarlos. Cada pestaña puede correr con uno distinto.
2038
+ provider: session.provider || '',
2039
+ model: session.model || '',
2040
+ mode: session.mode || '',
1958
2041
  working: false,
1959
2042
  tokens,
1960
2043
  lastTurn: { prompt: 0, completion: 0 },
@@ -2048,6 +2131,7 @@ function activateSession(id) {
2048
2131
  auditStatus(record);
2049
2132
  updateStatus();
2050
2133
  renderTokens();
2134
+ applySessionAgent(record);
2051
2135
  renderQueue(id);
2052
2136
  renderTabs();
2053
2137
  refreshSessions();
@@ -2610,6 +2694,9 @@ async function startTurn(sessionKey, pane, input, images, opts = {}) {
2610
2694
  controllers.set(sessionKey, controller);
2611
2695
  setWorking(sessionKey, true);
2612
2696
 
2697
+ // El tipo de agente lo manda la sesion (no los controles globales): asi dos
2698
+ // pestañas con modelos distintos no se pisan.
2699
+ const owner = state.panes.get(sessionKey);
2613
2700
  try {
2614
2701
  await streamPost(
2615
2702
  '/api/chat',
@@ -2617,9 +2704,9 @@ async function startTurn(sessionKey, pane, input, images, opts = {}) {
2617
2704
  input: input || '(imagen adjunta)',
2618
2705
  images: images.length ? images : undefined,
2619
2706
  sessionId: sessionKey,
2620
- provider: providerSelect.value || undefined,
2621
- model: modelSelect.value || undefined,
2622
- mode: modeSelect.value || undefined,
2707
+ provider: owner?.provider || providerSelect.value || undefined,
2708
+ model: owner?.model || modelSelect.value || undefined,
2709
+ mode: owner?.mode || modeSelect.value || undefined,
2623
2710
  yes: yesInput.checked ? true : undefined,
2624
2711
  contextTokens: contextTokensForRequest(),
2625
2712
  interrupt: opts.interrupt ? true : undefined,
@@ -2755,11 +2842,12 @@ async function sendMessage() {
2755
2842
  if (input.startsWith('/') && !images.length) {
2756
2843
  const name = input.slice(1).split(/\s+/)[0].toLowerCase();
2757
2844
  const arg = input.slice(1 + name.length).trim();
2845
+ // Limpiamos el input de una: hay comandos lentos (compactar, resumir) y no
2846
+ // queremos dejar el comando escrito mientras corren.
2847
+ chatInput.value = '';
2848
+ onChatInputChanged();
2758
2849
  const outcome = await runCommand(name, arg);
2759
- if (!outcome || outcome.handled) {
2760
- chatInput.value = '';
2761
- return;
2762
- }
2850
+ if (!outcome || outcome.handled) return;
2763
2851
  input = outcome.input ?? input;
2764
2852
  }
2765
2853
  if (!input && !images.length) return;
@@ -2984,7 +3072,7 @@ function encodeWav(samples, rate = 16000) {
2984
3072
  * `stop()` espera la cola antes de cortar. La usan el micrófono del chat y el
2985
3073
  * "probar" de Ajustes.
2986
3074
  */
2987
- function createRecording() {
3075
+ function createRecording({ silenceMs = 0, noSpeechMs = 0, maxMs = 0, speechThreshold = 0.02 } = {}) {
2988
3076
  let resolveBlob;
2989
3077
  const done = new Promise((resolve) => {
2990
3078
  resolveBlob = resolve;
@@ -2996,9 +3084,15 @@ function createRecording() {
2996
3084
  let sink = null;
2997
3085
  let stopping = false;
2998
3086
  let started = false;
3087
+ let startedAt = 0;
3088
+ let hasSpeech = false;
3089
+ let lastLoudAt = 0;
3090
+ let watchdog = null;
2999
3091
  const chunks = [];
3000
3092
 
3001
3093
  async function cleanup() {
3094
+ if (watchdog) clearInterval(watchdog);
3095
+ watchdog = null;
3002
3096
  try {
3003
3097
  processor?.disconnect();
3004
3098
  } catch {
@@ -3022,6 +3116,37 @@ function createRecording() {
3022
3116
  stream?.getTracks?.().forEach((track) => track.stop());
3023
3117
  }
3024
3118
 
3119
+ function stopRecording() {
3120
+ if (stopping || !started) return;
3121
+ stopping = true;
3122
+ if (watchdog) clearInterval(watchdog);
3123
+ watchdog = null;
3124
+ setTimeout(async () => {
3125
+ const rate = audioCtx?.sampleRate || 48000;
3126
+ const merged = mergeFloat32(chunks);
3127
+ await cleanup();
3128
+ const resampled = await resampleTo16k(merged, rate);
3129
+ resolveBlob(encodeWav(resampled, 16000));
3130
+ }, REC_TAIL_MS);
3131
+ }
3132
+
3133
+ // Manos libres: corta solo por silencio (tras haber hablado), si no hubo voz en
3134
+ // `noSpeechMs` o al llegar a `maxMs`. Sin estas opciones no interviene.
3135
+ function startWatchdog() {
3136
+ if (!silenceMs && !noSpeechMs && !maxMs) return;
3137
+ watchdog = setInterval(() => {
3138
+ if (stopping || !started) return;
3139
+ const now = Date.now();
3140
+ if (maxMs && now - startedAt >= maxMs) return stopRecording();
3141
+ if (!silenceMs) return;
3142
+ if (!hasSpeech) {
3143
+ if (noSpeechMs && now - startedAt >= noSpeechMs) stopRecording();
3144
+ return;
3145
+ }
3146
+ if (now - lastLoudAt >= silenceMs) stopRecording();
3147
+ }, 150);
3148
+ }
3149
+
3025
3150
  return {
3026
3151
  async start() {
3027
3152
  const Ctx = window.AudioContext || window.webkitAudioContext;
@@ -3034,7 +3159,16 @@ function createRecording() {
3034
3159
  source = audioCtx.createMediaStreamSource(stream);
3035
3160
  processor = audioCtx.createScriptProcessor(4096, 1, 1);
3036
3161
  processor.onaudioprocess = (event) => {
3037
- chunks.push(new Float32Array(event.inputBuffer.getChannelData(0)));
3162
+ const frame = event.inputBuffer.getChannelData(0);
3163
+ chunks.push(new Float32Array(frame));
3164
+ if (!silenceMs) return;
3165
+ let sum = 0;
3166
+ for (let i = 0; i < frame.length; i += 2) sum += frame[i] * frame[i];
3167
+ const rms = Math.sqrt(sum / (frame.length / 2));
3168
+ if (rms >= speechThreshold) {
3169
+ hasSpeech = true;
3170
+ lastLoudAt = Date.now();
3171
+ }
3038
3172
  };
3039
3173
  // El procesador solo corre si llega al destination: un gain en 0 evita el eco.
3040
3174
  sink = audioCtx.createGain();
@@ -3043,21 +3177,13 @@ function createRecording() {
3043
3177
  processor.connect(sink);
3044
3178
  sink.connect(audioCtx.destination);
3045
3179
  started = true;
3180
+ startedAt = Date.now();
3181
+ startWatchdog();
3046
3182
  },
3047
3183
  get stopping() {
3048
3184
  return stopping;
3049
3185
  },
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
- },
3186
+ stop: stopRecording,
3061
3187
  done,
3062
3188
  };
3063
3189
  }
@@ -3180,6 +3306,8 @@ function startBrowserMic() {
3180
3306
 
3181
3307
  async function toggleMic() {
3182
3308
  if (!micBtn) return;
3309
+ // Con manos libres el micro lo maneja el loop: no dejamos que se pisen.
3310
+ if (handsFree.active) return;
3183
3311
  if (browserDictation) {
3184
3312
  browserDictation.stop();
3185
3313
  return;
@@ -3229,6 +3357,155 @@ async function toggleMic() {
3229
3357
 
3230
3358
  micBtn?.addEventListener('click', toggleMic);
3231
3359
 
3360
+ /* --- manos libres: conversación por voz ----------------------------------- */
3361
+
3362
+ const HANDS_FREE_SILENCE_MS = 1400; // silencio que cierra tu turno
3363
+ const HANDS_FREE_NOSPEECH_MS = 8000; // si no hablás, vuelve a intentar
3364
+ const HANDS_FREE_MAX_MS = 45000; // tope duro de una intervención
3365
+
3366
+ const handsFreeBtn = $('#hands-free-btn');
3367
+ const handsFree = { active: false, phase: 'idle', token: 0 };
3368
+
3369
+ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
3370
+
3371
+ function renderHandsFree() {
3372
+ if (handsFreeBtn) {
3373
+ handsFreeBtn.classList.toggle('hands-free-active', handsFree.active);
3374
+ handsFreeBtn.classList.toggle('listening', handsFree.active && handsFree.phase === 'listening');
3375
+ handsFreeBtn.setAttribute('aria-pressed', handsFree.active ? 'true' : 'false');
3376
+ handsFreeBtn.title = handsFree.active
3377
+ ? 'manos libres: activado (tocá para desactivar)'
3378
+ : 'manos libres: conversación por voz';
3379
+ }
3380
+ if (handsFree.active && handsFree.phase === 'listening') renderFooterActivity('listening');
3381
+ else if (handsFree.active && handsFree.phase === 'thinking') renderFooterActivity('thinking');
3382
+ else if (handsFree.active) renderFooterActivity(null);
3383
+ }
3384
+
3385
+ function setHandsFreePhase(phase) {
3386
+ handsFree.phase = phase;
3387
+ renderHandsFree();
3388
+ }
3389
+
3390
+ function setHandsFree(on) {
3391
+ const next = Boolean(on);
3392
+ if (handsFree.active === next) return;
3393
+ handsFree.active = next;
3394
+ handsFree.token += 1;
3395
+ if (next) {
3396
+ // Cortamos un dictado manual en curso para no pisarlo.
3397
+ composerRecording?.stop();
3398
+ setHandsFreePhase('idle');
3399
+ renderHandsFree();
3400
+ runHandsFree(handsFree.token).catch(() => {});
3401
+ } else {
3402
+ // Cortamos lo que esté grabando para no dejar el micro abierto.
3403
+ handsFree.recording?.stop();
3404
+ handsFree.recording = null;
3405
+ browserDictation?.stop();
3406
+ setHandsFreePhase('idle');
3407
+ renderHandsFree();
3408
+ }
3409
+ }
3410
+
3411
+ function toggleHandsFree() {
3412
+ setHandsFree(!handsFree.active);
3413
+ }
3414
+
3415
+ handsFreeBtn?.addEventListener('click', toggleHandsFree);
3416
+
3417
+ /** Una escucha de manos libres: graba hasta el silencio y devuelve el texto. */
3418
+ async function handsFreeListen(token) {
3419
+ if (transcribeConfig.mode === 'browser') {
3420
+ const text = await new Promise((resolve, reject) => {
3421
+ let combined = '';
3422
+ let dictation;
3423
+ const finish = () => {
3424
+ if (browserDictation === dictation) browserDictation = null;
3425
+ resolve(combined.trim());
3426
+ };
3427
+ try {
3428
+ dictation = createBrowserDictation({
3429
+ language: transcribeConfig.language,
3430
+ onText: (_final, all) => {
3431
+ combined = all;
3432
+ },
3433
+ onEnd: finish,
3434
+ onError: reject,
3435
+ });
3436
+ } catch (error) {
3437
+ reject(error);
3438
+ return;
3439
+ }
3440
+ browserDictation = dictation;
3441
+ dictation.start();
3442
+ setTimeout(() => {
3443
+ if (browserDictation === dictation) dictation.stop();
3444
+ }, HANDS_FREE_MAX_MS);
3445
+ });
3446
+ return token === handsFree.token ? text : '';
3447
+ }
3448
+
3449
+ const rec = createRecording({
3450
+ silenceMs: HANDS_FREE_SILENCE_MS,
3451
+ noSpeechMs: HANDS_FREE_NOSPEECH_MS,
3452
+ maxMs: HANDS_FREE_MAX_MS,
3453
+ });
3454
+ await rec.start();
3455
+ if (token !== handsFree.token) {
3456
+ rec.stop();
3457
+ return '';
3458
+ }
3459
+ handsFree.recording = rec;
3460
+ setHandsFreePhase('listening');
3461
+ const blob = await rec.done;
3462
+ handsFree.recording = null;
3463
+ if (token !== handsFree.token || !blob.size) return '';
3464
+ setHandsFreePhase('thinking');
3465
+ try {
3466
+ return await transcribirBlob(blob);
3467
+ } catch (error) {
3468
+ toast(`No pude transcribir: ${error.message}`);
3469
+ return '';
3470
+ }
3471
+ }
3472
+
3473
+ /** Loop conversacional: escuchar → enviar → esperar la voz → repetir. */
3474
+ async function runHandsFree(token) {
3475
+ while (handsFree.active && token === handsFree.token) {
3476
+ if (!state.sessionId) {
3477
+ toast('Manos libres necesita una sesión abierta.');
3478
+ setHandsFree(false);
3479
+ return;
3480
+ }
3481
+ if (state.panes.get(state.sessionId)?.working) {
3482
+ await sleep(400);
3483
+ continue;
3484
+ }
3485
+
3486
+ let text = '';
3487
+ try {
3488
+ text = await handsFreeListen(token);
3489
+ } catch (error) {
3490
+ if (token !== handsFree.token) return;
3491
+ toast(`Manos libres: ${error.message}`);
3492
+ setHandsFree(false);
3493
+ return;
3494
+ }
3495
+ if (!handsFree.active || token !== handsFree.token) return;
3496
+ if (!text) continue; // silencio o no se entendió: otra vuelta
3497
+
3498
+ chatInput.value = text;
3499
+ onChatInputChanged();
3500
+ setHandsFreePhase('thinking');
3501
+ await sendMessage();
3502
+ if (!handsFree.active || token !== handsFree.token) return;
3503
+ // Esperamos a que termine de hablar antes de volver a escuchar (evita el eco).
3504
+ await whenAudioIdle();
3505
+ await sleep(250);
3506
+ }
3507
+ }
3508
+
3232
3509
  /* --- image attachments ---------------------------------------------------- */
3233
3510
 
3234
3511
  const attachInput = $('#image-input');
@@ -3465,13 +3742,18 @@ async function runCommand(name, arg) {
3465
3742
  }
3466
3743
  case 'compact': {
3467
3744
  if (!state.sessionId) return needSession();
3468
- notify('Compactando la conversación…');
3745
+ toast('Compactando la conversación…');
3469
3746
  const out = await api(`/api/sessions/${state.sessionId}/compact`, { method: 'POST' }).catch((error) => ({
3470
3747
  error: error.message,
3471
3748
  }));
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}`);
3749
+ if (out.error) {
3750
+ notify(`No se pudo compactar: ${out.error}`, 'notice err');
3751
+ return { handled: true };
3752
+ }
3753
+ // No borramos el pane: el historial queda a la vista y marcamos el corte
3754
+ // con la línea + el resumen que ahora ve el modelo.
3755
+ addCompactCard(activePane, { text: out.summary });
3756
+ scrollMessages();
3475
3757
  refreshSessions();
3476
3758
  return { handled: true };
3477
3759
  }
@@ -3736,7 +4018,17 @@ async function loadFolders(target = '') {
3736
4018
  }
3737
4019
 
3738
4020
  async function createSessionIn(cwd, { title = 'Nueva sesión' } = {}) {
3739
- const created = await api('/api/sessions', { method: 'POST', body: { title, cwd } }).catch((error) => ({
4021
+ const current = state.panes.get(state.sessionId);
4022
+ const created = await api('/api/sessions', {
4023
+ method: 'POST',
4024
+ body: {
4025
+ title,
4026
+ cwd,
4027
+ provider: current?.provider || providerSelect.value || undefined,
4028
+ model: current?.model || modelSelect.value || undefined,
4029
+ mode: current?.mode || modeSelect.value || undefined,
4030
+ },
4031
+ }).catch((error) => ({
3740
4032
  error: error.message,
3741
4033
  }));
3742
4034
  if (created?.error) return created;
@@ -3866,6 +4158,8 @@ async function refreshProviders() {
3866
4158
  state.active = data.active;
3867
4159
  syncTopbar();
3868
4160
  renderProviderList();
4161
+ const record = state.panes.get(state.sessionId);
4162
+ if (record) await applySessionAgent(record);
3869
4163
  }
3870
4164
 
3871
4165
  function isConfigured(p) {
@@ -3881,7 +4175,8 @@ function syncTopbar() {
3881
4175
  option.textContent = p.label && p.label !== p.id ? `${p.label} (${p.id})` : p.id;
3882
4176
  providerSelect.appendChild(option);
3883
4177
  }
3884
- const active = state.active.provider;
4178
+ const record = state.panes.get(state.sessionId);
4179
+ const active = record?.provider || state.active.provider;
3885
4180
  if (active && !configured.some((p) => p.id === active)) {
3886
4181
  const current = state.providers.find((p) => p.id === active);
3887
4182
  const option = document.createElement('option');
@@ -3930,8 +4225,9 @@ function renderProviderList() {
3930
4225
  }
3931
4226
 
3932
4227
  async function useProvider(id) {
3933
- await saveSettings({ provider: id });
3934
- await loadModels({ save: true });
4228
+ providerSelect.value = id;
4229
+ await loadModels({ save: false });
4230
+ await persistAgent({ provider: id, model: modelSelect.value || undefined });
3935
4231
  await refreshProviders();
3936
4232
  }
3937
4233
 
@@ -4661,6 +4957,18 @@ const voz = {
4661
4957
  */
4662
4958
  const ap = { el: $('#audio-player'), audio: new Audio(), queue: [], current: null, ready: false };
4663
4959
 
4960
+ // Avisa cuando el reproductor queda en silencio (cola vacia). Lo usa manos libres
4961
+ // para saber cuando el asistente termino de hablar y volver a escuchar.
4962
+ const audioIdleWaiters = [];
4963
+ function whenAudioIdle() {
4964
+ if (!ap.current && !ap.queue.length) return Promise.resolve();
4965
+ return new Promise((resolve) => audioIdleWaiters.push(resolve));
4966
+ }
4967
+ function notifyAudioIdle() {
4968
+ if (ap.current || ap.queue.length) return;
4969
+ for (const resolve of audioIdleWaiters.splice(0)) resolve();
4970
+ }
4971
+
4664
4972
  function ensurePlayer() {
4665
4973
  if (!ap.el || ap.ready) return;
4666
4974
  ap.ready = true;
@@ -4750,6 +5058,7 @@ function playNextAudio() {
4750
5058
  if (!item) {
4751
5059
  ap.current = null;
4752
5060
  renderAudio();
5061
+ notifyAudioIdle();
4753
5062
  return;
4754
5063
  }
4755
5064
  if (item.maxAgeMs && Date.now() - item.createdAt > item.maxAgeMs) {
@@ -4796,6 +5105,7 @@ function stopAudio() {
4796
5105
  ap.audio.load();
4797
5106
  if (ap.barEl) ap.barEl.style.width = '0%';
4798
5107
  renderAudio();
5108
+ notifyAudioIdle();
4799
5109
  }
4800
5110
 
4801
5111
  function renderAudio() {
@@ -4821,16 +5131,32 @@ function reproducirVoz(frase, { voice, rate, pitch, label, maxAgeMs } = {}) {
4821
5131
  * Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
4822
5132
  * cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
4823
5133
  */
4824
- function hablar(texto, { label = 'narrando', maxAgeMs = 45000 } = {}) {
5134
+ function hablar(texto, { label = 'narrando', maxAgeMs = 45000, force = false } = {}) {
4825
5135
  const frase = String(texto ?? '').trim();
4826
- if (!frase || !voz.settings.enabled) return;
5136
+ if (!frase || (!voz.settings.enabled && !force)) return;
4827
5137
  reproducirVoz(frase, { label, maxAgeMs }).catch(() => {});
4828
5138
  }
4829
5139
 
4830
5140
  /** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
4831
5141
  function narrar(event, data, turn) {
4832
5142
  if (!turn?.live) return;
5143
+ const handsFreeOn = handsFree.active;
4833
5144
  const { enabled, announce } = voz.settings;
5145
+ if (!enabled && !handsFreeOn) return;
5146
+ // Manos libres: conversacion, sin narracion de arranque/herramientas; lee la
5147
+ // respuesta final (y el asesor) aunque la narracion este apagada en Ajustes.
5148
+ if (handsFreeOn) {
5149
+ if (event === 'advice') {
5150
+ if (data?.say) hablar(data.say, { label: 'asesor', maxAgeMs: 0, force: true });
5151
+ } else if (event === 'done') {
5152
+ const spoken = speechText(data?.content);
5153
+ if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0, force: true });
5154
+ else if (data?.say) hablar(data.say, { maxAgeMs: 0, force: true });
5155
+ } else if ((event === 'aborted' || event === 'error') && data?.say) {
5156
+ hablar(data.say, { maxAgeMs: 0, force: true });
5157
+ }
5158
+ return;
5159
+ }
4834
5160
  if (!enabled) return;
4835
5161
  if (event === 'start' && announce.start) hablar(data?.say);
4836
5162
  else if (event === 'tool_call' && announce.tools) hablar(data?.say);
@@ -5162,8 +5488,7 @@ async function testAsesor() {
5162
5488
  setStatus('#asesor-msg', 'consultando…');
5163
5489
  const data = await api('/api/advisor/test', { method: 'POST' }).catch((e) => ({ error: e.message }));
5164
5490
  if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
5165
- const partes = [data.speak ? data.text : '', data.summary ? `resumen: ${data.summary}` : ''].filter(Boolean);
5166
- setStatus('#asesor-msg', partes.length ? partes.join(' · ') : '(no diría nada en este caso)', 'ok');
5491
+ setStatus('#asesor-msg', data?.speak && data.text ? data.text : '(no diría nada en este caso)', 'ok');
5167
5492
  }
5168
5493
 
5169
5494
  /* --- ajustes: perfil del usuario ------------------------------------------ */
@@ -6768,6 +7093,10 @@ async function boot() {
6768
7093
  await loadModes();
6769
7094
  await loadModels();
6770
7095
  await refreshTranscribe();
7096
+ // La config de voz se carga al boot (sin `await`, para no bloquear con el
7097
+ // fetch del catálogo de voces): si no, `voz.settings.enabled` queda en el
7098
+ // default `false` hasta abrir Ajustes y la narración no arranca al entrar.
7099
+ refreshVoz();
6771
7100
  await reconcile();
6772
7101
  await restoreTabs();
6773
7102
  renderAlertSignals();