@danieltmn/tamnora 0.9.2 → 0.9.3

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.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,13 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.9.3] - 2026-10-07
9
+
10
+ ### Cambios
11
+
12
+ - feat(telegram): audio solo cuando el usuario lo pide
13
+ - feat: diff lado a lado en TUI, proveedores Anthropic/Gemini y mejoras de consola
14
+
8
15
  ## [0.9.2] - 2026-10-07
9
16
 
10
17
  ### Cambios
@@ -1,7 +1,8 @@
1
1
  // Full-screen terminal console (opencode/codex-like), zero dependencies.
2
2
  // Alt-screen + raw input + incremental rendering; reuses the harness engine.
3
3
 
4
- import { createTools } from '../lib/core.js';
4
+ import { readFileSync } from 'node:fs';
5
+ import { createTools, diffRows, resolveInRoot } from '../lib/core.js';
5
6
  import { runTurn } from '../lib/runtime.js';
6
7
  import { parseKeys } from './keys.js';
7
8
  import { render } from './layout.js';
@@ -67,6 +68,15 @@ export function runTui({
67
68
  tools: [],
68
69
  });
69
70
 
71
+ const readWorkspaceFile = (rel) => {
72
+ if (!rel) return '';
73
+ try {
74
+ return readFileSync(resolveInRoot(toolOptions.root, rel), 'utf8');
75
+ } catch {
76
+ return '';
77
+ }
78
+ };
79
+
70
80
  const onAsk = (name, args) =>
71
81
  new Promise((resolve) => {
72
82
  state.pendingAsk = { name, detail: args?.command ?? args?.path ?? '', resolve };
@@ -186,10 +196,19 @@ export function runTui({
186
196
  } else if (event.type === 'stall') {
187
197
  if (!sawText) assistant.text = `⚠ ${event.seconds} s sin respuesta del modelo (proveedor caído o muy lento). Sigo esperando...`;
188
198
  } else if (event.type === 'tool_call') {
189
- assistant.tools.push({ name: event.name, args: JSON.stringify(event.args) });
199
+ const tool = { name: event.name, args: JSON.stringify(event.args), path: event.args?.path };
200
+ if (event.name === 'write' || event.name === 'edit') {
201
+ tool.oldContent = readWorkspaceFile(event.args?.path);
202
+ }
203
+ assistant.tools.push(tool);
190
204
  } else if (event.type === 'tool_result') {
191
205
  const tool = assistant.tools[assistant.tools.length - 1];
192
- if (tool) tool.result = String(event.result).slice(0, 2000);
206
+ if (tool) {
207
+ tool.result = String(event.result).slice(0, 2000);
208
+ if (tool.oldContent !== undefined) {
209
+ tool.diff = diffRows(tool.oldContent, readWorkspaceFile(tool.path), { maxRows: 400 });
210
+ }
211
+ }
193
212
  }
194
213
  draw();
195
214
  },
@@ -48,6 +48,53 @@ function clip(text, width) {
48
48
  return arr.length <= width ? text : arr.slice(0, width).join('');
49
49
  }
50
50
 
51
+ function sanitize(text) {
52
+ return String(text ?? '').replace(/\t/g, ' ');
53
+ }
54
+
55
+ // Una columna del diff: numero de linea dim y contenido rojo (viejo) / verde
56
+ // (nuevo) / normal (contexto). Devuelve texto con ANSI ya resuelto (raw).
57
+ function diffSide(cell, kind, numW, contentW) {
58
+ if (!cell) return color.dim(' '.repeat(numW + 3 + contentW));
59
+ const num = String(cell.no).padStart(numW);
60
+ const text = clip(sanitize(cell.text), contentW);
61
+ const pad = ' '.repeat(Math.max(0, contentW - chars(text).length));
62
+ const sign = kind === 'del' ? '-' : kind === 'add' ? '+' : ' ';
63
+ const paint = kind === 'del' ? color.red : kind === 'add' ? color.green : (t) => t;
64
+ return color.dim(`${num} `) + paint(`${sign} ${text}`) + pad;
65
+ }
66
+
67
+ // Arma las lineas del diff lado a lado. Devuelve null si el ancho es muy chico
68
+ // (en ese caso el que llama cae al diff de una sola columna).
69
+ function buildDiff(diff, width) {
70
+ const indent = 2;
71
+ const mid = ' │ ';
72
+ const maxNo = diff.reduce((m, row) => Math.max(m, row.left?.no ?? 0, row.right?.no ?? 0), 1);
73
+ const numW = Math.max(3, String(maxNo).length);
74
+ const colW = Math.floor((width - indent - mid.length) / 2);
75
+ const contentW = colW - numW - 3;
76
+ if (contentW < 6) return null;
77
+
78
+ const out = [];
79
+ for (const row of diff) {
80
+ if (row.type === 'skip') {
81
+ out.push({ text: `${' '.repeat(indent)}${color.dim('…')}`, raw: true });
82
+ continue;
83
+ }
84
+ const leftKind = row.type === 'add' ? 'blank' : row.type === 'ctx' ? 'ctx' : 'del';
85
+ const rightKind = row.type === 'del' ? 'blank' : row.type === 'ctx' ? 'ctx' : 'add';
86
+ const line =
87
+ ' '.repeat(indent) +
88
+ diffSide(row.left, leftKind, numW, contentW) +
89
+ color.dim(mid) +
90
+ diffSide(row.right, rightKind, numW, contentW);
91
+ out.push({ text: line, raw: true });
92
+ }
93
+ return out;
94
+ }
95
+
96
+ const TOOL_LABELS = { write: 'Write', edit: 'Edit' };
97
+
51
98
  function buildBody(state, width) {
52
99
  const out = [];
53
100
  for (const message of state.messages) {
@@ -62,6 +109,12 @@ function buildBody(state, width) {
62
109
  for (const line of wrapText(message.text, width - 2)) out.push({ text: ` ${line}`, style: 'assistant' });
63
110
  }
64
111
  for (const tool of message.tools ?? []) {
112
+ const diff = tool.diff?.length ? buildDiff(tool.diff, width) : null;
113
+ if (diff) {
114
+ out.push({ text: ` ✎ ${TOOL_LABELS[tool.name] ?? tool.name} ${tool.path ?? ''}`.trimEnd(), style: 'tool' });
115
+ out.push(...diff);
116
+ continue;
117
+ }
65
118
  out.push({ text: ` ⚙ ${tool.name} ${clip(tool.args ?? '', width - 8)}`, style: 'tool' });
66
119
  if (tool.result !== undefined) {
67
120
  for (const line of wrapText(tool.result, width - 6)) out.push({ text: ` → ${line}`, style: 'dim' });
@@ -121,8 +174,12 @@ export function render(state, { cols = 80, rows = 24 } = {}) {
121
174
  const parts = [];
122
175
  for (let i = 0; i < rows; i++) {
123
176
  const entry = lines[i] ?? { text: '', style: 'dim' };
124
- const style = THEME[entry.style] ?? THEME.assistant;
125
- parts.push(moveTo(i + 1, 1), '\x1b[2K', style(clip(entry.text, width)));
177
+ parts.push(moveTo(i + 1, 1), '\x1b[2K');
178
+ if (entry.raw) parts.push(entry.text);
179
+ else {
180
+ const style = THEME[entry.style] ?? THEME.assistant;
181
+ parts.push(style(clip(entry.text, width)));
182
+ }
126
183
  }
127
184
 
128
185
  const cursorCol = state.pendingAsk ? 2 : Math.min(width, chars(` ❯ ${before}`).length + 1);
@@ -81,6 +81,36 @@ test('render places the cursor at inputCursor', () => {
81
81
  assert.equal(cursor.col, 6);
82
82
  });
83
83
 
84
+ test('render shows a side-by-side diff for write/edit', () => {
85
+ const state = {
86
+ header: { provider: 'p', model: 'm', cwd: '/x' },
87
+ messages: [
88
+ {
89
+ role: 'assistant',
90
+ text: '',
91
+ tools: [
92
+ {
93
+ name: 'edit',
94
+ args: '{"path":"a.js"}',
95
+ path: 'a.js',
96
+ diff: [
97
+ { type: 'ctx', left: { no: 1, text: 'const a = 1;' }, right: { no: 1, text: 'const a = 1;' } },
98
+ { type: 'mod', left: { no: 2, text: 'const b = 2;' }, right: { no: 2, text: 'const b = 3;' } },
99
+ ],
100
+ },
101
+ ],
102
+ },
103
+ ],
104
+ input: '',
105
+ streaming: false,
106
+ pendingAsk: null,
107
+ };
108
+ const { frame } = render(state, { cols: 80, rows: 20 });
109
+ assert.match(frame, /Edit a\.js/);
110
+ assert.match(frame, /const b = 2;/);
111
+ assert.match(frame, /const b = 3;/);
112
+ });
113
+
84
114
  test('render scrolls the viewport', () => {
85
115
  const messages = Array.from({ length: 40 }, (_, i) => ({ role: 'assistant', text: `linea ${i}`, tools: [] }));
86
116
  const base = { header: { provider: 'p', model: 'm', cwd: '/x' }, input: '', streaming: false, pendingAsk: null };
@@ -12,7 +12,7 @@ export { resolveApiKey, resolveEnvMap } from './config/secrets.js';
12
12
  export { ensureUtf8Console } from './console.js';
13
13
  export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
14
14
  export { estimateTokens, trimContext } from './agent/context.js';
15
- export { diffLines } from './tools/diff.js';
15
+ export { diffLines, diffRows } from './tools/diff.js';
16
16
  export { isGitRepo, gitStatus, gitDiff } from './git/index.js';
17
17
  export { summarizeToolCall, previewToolResult } from './tools/labels.js';
18
18
  export { MODES, MODE_IDS, resolveMode } from './agent/modes.js';
@@ -9,6 +9,10 @@ function preset(id, label, baseUrl) {
9
9
  export const OPENAI_COMPATIBLE_PRESETS = [
10
10
  preset('deepseek', 'DeepSeek', 'https://api.deepseek.com/v1'),
11
11
  preset('openai', 'OpenAI', 'https://api.openai.com/v1'),
12
+ // Anthropic y Gemini exponen una capa compatible con OpenAI (chat/completions
13
+ // + tools) que usa el mismo Bearer; sirven con este adaptador sin cambios.
14
+ preset('anthropic', 'Anthropic (Claude)', 'https://api.anthropic.com/v1'),
15
+ preset('gemini', 'Google Gemini', 'https://generativelanguage.googleapis.com/v1beta/openai'),
12
16
  preset('groq', 'Groq', 'https://api.groq.com/openai/v1'),
13
17
  preset('mistral', 'Mistral AI', 'https://api.mistral.ai/v1'),
14
18
  preset('xai', 'xAI (Grok)', 'https://api.x.ai/v1'),
@@ -7,7 +7,9 @@ const MAX_CELLS = 1_500_000;
7
7
 
8
8
  function splitLines(text) {
9
9
  if (text === '' || text == null) return [];
10
- return String(text).replace(/\r\n/g, '\n').split('\n');
10
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
11
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
12
+ return lines;
11
13
  }
12
14
 
13
15
  function computeOps(a, b) {
@@ -88,3 +90,70 @@ export function diffLines(oldText, newText, { context = 3, maxLines = 200 } = {}
88
90
  if (truncated) out.push(' … (diff truncado)');
89
91
  return out.join('\n');
90
92
  }
93
+
94
+ /**
95
+ * Diff estructurado para una vista lado a lado (dos columnas): devuelve filas
96
+ * con la linea vieja (izquierda) y la nueva (derecha) ya numeradas. Las
97
+ * supresiones y adiciones de un mismo bloque se emparejan en la misma fila.
98
+ *
99
+ * @returns {Array<{type:'ctx'|'mod'|'del'|'add'|'skip', left:({no:number,text:string}|null), right:({no:number,text:string}|null)}>}
100
+ * [] si no hay cambios.
101
+ */
102
+ export function diffRows(oldText, newText, { context = 3, maxRows = 400 } = {}) {
103
+ const ops = computeOps(splitLines(oldText), splitLines(newText));
104
+ const changes = ops.map((o, idx) => (o.t === 'eq' ? -1 : idx)).filter((idx) => idx >= 0);
105
+ if (!changes.length) return [];
106
+
107
+ const keep = new Array(ops.length).fill(false);
108
+ for (const idx of changes) {
109
+ for (let j = Math.max(0, idx - context); j <= Math.min(ops.length - 1, idx + context); j++) keep[j] = true;
110
+ }
111
+
112
+ const rows = [];
113
+ let oldNo = 1;
114
+ let newNo = 1;
115
+ let pending = [];
116
+ let skipped = false;
117
+
118
+ const flush = () => {
119
+ const dels = pending.filter((p) => p.t === 'del');
120
+ const adds = pending.filter((p) => p.t === 'add');
121
+ const total = Math.max(dels.length, adds.length);
122
+ for (let i = 0; i < total; i++) {
123
+ const d = dels[i] ?? null;
124
+ const a = adds[i] ?? null;
125
+ rows.push({
126
+ type: d && a ? 'mod' : d ? 'del' : 'add',
127
+ left: d ? { no: d.no, text: d.line } : null,
128
+ right: a ? { no: a.no, text: a.line } : null,
129
+ });
130
+ }
131
+ pending = [];
132
+ };
133
+
134
+ for (let idx = 0; idx < ops.length; idx++) {
135
+ const op = ops[idx];
136
+ if (op.t === 'eq') {
137
+ flush();
138
+ if (keep[idx]) {
139
+ rows.push({ type: 'ctx', left: { no: oldNo, text: op.line }, right: { no: newNo, text: op.line } });
140
+ skipped = false;
141
+ } else if (!skipped) {
142
+ rows.push({ type: 'skip', left: null, right: null });
143
+ skipped = true;
144
+ }
145
+ oldNo++;
146
+ newNo++;
147
+ } else if (op.t === 'del') {
148
+ pending.push({ t: 'del', line: op.line, no: oldNo++ });
149
+ } else {
150
+ pending.push({ t: 'add', line: op.line, no: newNo++ });
151
+ }
152
+ }
153
+ flush();
154
+
155
+ if (rows.length > maxRows) {
156
+ return rows.slice(0, maxRows).concat({ type: 'skip', left: null, right: null });
157
+ }
158
+ return rows;
159
+ }
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { diffLines } from '../src/index.js';
3
+ import { diffLines, diffRows } from '../src/index.js';
4
4
 
5
5
  test('diffLines reports added and removed lines with context', () => {
6
6
  const diff = diffLines('a\nb\nc\n', 'a\nB\nc\n');
@@ -18,3 +18,26 @@ test('diffLines marks a new file as additions', () => {
18
18
  assert.match(diff, /^\+ l1$/m);
19
19
  assert.match(diff, /^\+ l2$/m);
20
20
  });
21
+
22
+ test('diffRows pairs a removed and an added line side by side', () => {
23
+ const rows = diffRows('a\nb\nc\n', 'a\nB\nc\n');
24
+ const mod = rows.find((r) => r.type === 'mod');
25
+ assert.equal(mod.left.text, 'b');
26
+ assert.equal(mod.left.no, 2);
27
+ assert.equal(mod.right.text, 'B');
28
+ assert.equal(mod.right.no, 2);
29
+ assert.ok(rows.some((r) => r.type === 'ctx' && r.left.no === 1 && r.right.no === 1));
30
+ });
31
+
32
+ test('diffRows numbers additions for a new file', () => {
33
+ const rows = diffRows('', 'l1\nl2\n');
34
+ assert.equal(rows.length, 2);
35
+ assert.equal(rows[0].type, 'add');
36
+ assert.equal(rows[0].left, null);
37
+ assert.equal(rows[0].right.text, 'l1');
38
+ assert.equal(rows[1].right.no, 2);
39
+ });
40
+
41
+ test('diffRows returns an empty list when there are no changes', () => {
42
+ assert.deepEqual(diffRows('x\ny\n', 'x\ny\n'), []);
43
+ });
@@ -72,6 +72,10 @@ const ICON_PATHS = {
72
72
  'refresh-cw': ['M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8', 'M21 3v5h-5', 'M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16', 'M8 16H3v5'],
73
73
  trash: ['M3 6h18', 'M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6', 'M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2', 'M10 11v6', 'M14 11v6'],
74
74
  'dots-vertical': ['M12 5h.01', 'M12 12h.01', 'M12 19h.01'],
75
+ 'dots-horizontal': ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'],
76
+ play: ['m6 4 14 8-14 8z'],
77
+ pause: ['M8 5v14', 'M16 5v14'],
78
+ square: ['M6 6h12v12H6z'],
75
79
  copy: ['M8 8V5a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-3', 'M5 8h9a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-9a2 2 0 0 1 2-2Z'],
76
80
  pencil: ['M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5Z', 'm15 5 4 4'],
77
81
  };
@@ -301,6 +305,8 @@ if (statusbarEl) {
301
305
  const PALETTE_COMMANDS = [
302
306
  { id: 'new', label: 'Nueva sesión en otra carpeta', hint: '/new', run: () => openFolderPicker() },
303
307
  { id: 'init', label: 'Analizar el proyecto y crear AGENTS.md', hint: '/init', run: () => runSlash('init') },
308
+ { id: 'resumen', label: 'Resumir la conversación', hint: '/resumen', run: () => runSlash('resumen') },
309
+ { id: 'audioresumen', label: 'Resumen como audio', hint: '/audioresumen', run: () => runSlash('audioresumen') },
304
310
  { id: 'compact', label: 'Compactar la conversación', hint: '/compact', run: () => runSlash('compact') },
305
311
  { id: 'clear', label: 'Limpiar la conversación', hint: '/clear', run: () => runSlash('clear') },
306
312
  { id: 'mode', label: 'Cambiar Plan / Build', hint: 'Ctrl+.', run: () => toggleMode() },
@@ -330,7 +336,7 @@ function runSlash(name) {
330
336
  function paletteItems(query) {
331
337
  const q = query.trim().toLowerCase();
332
338
  const items = PALETTE_COMMANDS.filter(
333
- (c) => !q || c.label.toLowerCase().includes(q) || c.id.includes(q)
339
+ (c) => !q || c.label.toLowerCase().includes(q) || c.id.includes(q) || (c.hint ?? '').toLowerCase().includes(q)
334
340
  ).map((c) => ({ label: c.label, hint: c.hint || 'comando', run: c.run }));
335
341
 
336
342
  for (const session of palette.results.sessions) {
@@ -902,7 +908,7 @@ function addMessageActions(message) {
902
908
  const actions = el('div', 'msg-actions');
903
909
  const trigger = el('button', 'msg-menu-btn');
904
910
  trigger.type = 'button';
905
- trigger.appendChild(icon('dots-vertical', 16));
911
+ trigger.appendChild(icon(window.matchMedia('(min-width: 821px)').matches ? 'dots-horizontal' : 'dots-vertical', 16));
906
912
  trigger.title = 'opciones del mensaje';
907
913
  trigger.setAttribute('aria-haspopup', 'menu');
908
914
  trigger.setAttribute('aria-expanded', 'false');
@@ -1252,18 +1258,13 @@ async function loadFullToolResult(entry, message) {
1252
1258
  }
1253
1259
  }
1254
1260
 
1255
- /** Reproductor del tool `speak`: sintetiza el texto pedido contra /api/tts. */
1256
- function speakAudio(args) {
1261
+ /** Botón del tool `speak`: encola el audio pedido en el reproductor global. */
1262
+ function speakControl(args) {
1257
1263
  const texto = String(args?.texto ?? '').trim();
1258
1264
  if (!texto) return null;
1259
- const params = new URLSearchParams({ texto });
1260
- if (args?.voice) params.set('voice', args.voice);
1261
- if (args?.rate) params.set('rate', args.rate);
1262
- const audio = el('audio', 'tool-audio');
1263
- audio.controls = true;
1264
- audio.preload = 'none';
1265
- audio.src = `/api/tts?${params.toString()}`;
1266
- return audio;
1265
+ const btn = button('', () => reproducirVoz(texto, { voice: args?.voice, rate: args?.rate, label: 'voz' }).catch(() => {}), 'tool-audio-btn');
1266
+ btn.append(icon('play', 13), el('span', '', 'escuchar'));
1267
+ return btn;
1267
1268
  }
1268
1269
 
1269
1270
  /** Texto de la tarjeta: el server ya manda `summary` calculado; esto es el fallback. */
@@ -1277,17 +1278,17 @@ function addToolCard(message, name, args, { callId = null, open = null, round =
1277
1278
  const argsEl = el('span', 'tool-args', summary ?? toolSummary(name, args));
1278
1279
  head.append(glyph, el('span', 'tool-name', name), argsEl);
1279
1280
  const out = el('div', 'tool-out');
1280
- const audio = name === 'speak' ? speakAudio(args) : null;
1281
+ const speak = name === 'speak' ? speakControl(args) : null;
1281
1282
  const more = button('ver completo', () => loadFullToolResult(entry, message), 'tool-more');
1282
1283
  more.hidden = true;
1283
1284
  card.append(head, out);
1284
- if (audio) card.appendChild(audio);
1285
+ if (speak) card.appendChild(speak);
1285
1286
  card.appendChild(more);
1286
1287
  message.tools.appendChild(card);
1287
1288
  ensureToolsToggle(message);
1288
1289
  scrollMessages(paneOf(message));
1289
1290
 
1290
- const entry = { card, out, glyph, argsEl, more, audio, callId, full: false, truncated: false };
1291
+ const entry = { card, out, glyph, argsEl, more, callId, full: false, truncated: false };
1291
1292
  card.addEventListener('toggle', () => {
1292
1293
  if (card.open && entry.truncated && !entry.full) loadFullToolResult(entry, message);
1293
1294
  });
@@ -2022,8 +2023,8 @@ function handleChatEvent(event, data, turn) {
2022
2023
  setActivity(target, 'working');
2023
2024
  turn.lastTool = addToolCard(target, data.name, data.args, { callId: data.id, summary: data.summary });
2024
2025
  if (turn.live && data.name === 'speak') {
2025
- const player = turn.lastTool?.audio;
2026
- if (player) player.play?.().catch?.(() => {});
2026
+ const texto = String(data.args?.texto ?? '').trim();
2027
+ if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
2027
2028
  }
2028
2029
  } else if (event === 'tool_result') {
2029
2030
  setActivity(turn.assistant(), 'thinking');
@@ -2446,8 +2447,8 @@ const micBtn = $('#mic-btn');
2446
2447
 
2447
2448
  // La cuenta 3-2-1 le da tiempo al micro a arrancar (si hablás apenas tocás se
2448
2449
  // pierden las primeras palabras); la cola al cortar evita perder el final.
2449
- const REC_COUNT = [3, 2, 1];
2450
- const REC_COUNT_STEP_MS = 900;
2450
+ const REC_COUNT = [3, 2, 1, 0];
2451
+ const REC_COUNT_STEP_MS = 500;
2451
2452
  const REC_TAIL_MS = 500;
2452
2453
 
2453
2454
  /** Estado visual del botón de dictado: cuenta regresiva, grabando (verde) o transcribiendo. */
@@ -2891,6 +2892,8 @@ const COMMANDS = [
2891
2892
  ['/new', 'nueva sesión (elegís carpeta)'],
2892
2893
  ['/init', 'analizar el proyecto y crear/actualizar AGENTS.md'],
2893
2894
  ['/goal <texto>', 'fijar el objetivo de la sesión'],
2895
+ ['/resumen', 'resumir la conversación (sin borrarla)'],
2896
+ ['/audioresumen', 'el resumen como audio'],
2894
2897
  ['/compact', 'resumir la conversación y liberar contexto'],
2895
2898
  ['/clear', 'borrar los mensajes de esta sesión'],
2896
2899
  ['/cwd', 'mostrar la carpeta de trabajo'],
@@ -2991,6 +2994,32 @@ async function runCommand(name, arg) {
2991
2994
  notify(`Objetivo de la sesión: **${arg}**`);
2992
2995
  return { handled: true };
2993
2996
  }
2997
+ case 'resumen':
2998
+ case 'resume': {
2999
+ if (!state.sessionId) return needSession();
3000
+ notify('Resumiendo la conversación…');
3001
+ const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
3002
+ error: error.message,
3003
+ }));
3004
+ if (out.error) notify(`No se pudo resumir: ${out.error}`, 'notice err');
3005
+ else notify(`**Resumen**\n\n${out.summary}`);
3006
+ return { handled: true };
3007
+ }
3008
+ case 'audioresumen':
3009
+ case 'audio-resumen': {
3010
+ if (!state.sessionId) return needSession();
3011
+ notify('Preparando el resumen…');
3012
+ const out = await api(`/api/sessions/${state.sessionId}/summary`, { method: 'POST' }).catch((error) => ({
3013
+ error: error.message,
3014
+ }));
3015
+ if (out.error) {
3016
+ notify(`No se pudo resumir: ${out.error}`, 'notice err');
3017
+ return { handled: true };
3018
+ }
3019
+ notify(`**Resumen**\n\n${out.summary}`);
3020
+ reproducirVoz(out.summary, { label: 'resumen' }).catch((error) => notify(`No se pudo reproducir el audio: ${error.message}`, 'notice err'));
3021
+ return { handled: true };
3022
+ }
2994
3023
  case 'compact': {
2995
3024
  if (!state.sessionId) return needSession();
2996
3025
  notify('Compactando la conversación…');
@@ -3053,7 +3082,10 @@ function updateSlashMenu() {
3053
3082
  const value = chatInput.value;
3054
3083
  if (!value.startsWith('/') || /\s/.test(value)) return hideSlashMenu();
3055
3084
  const query = value.slice(1).toLowerCase();
3056
- slashMatches = slashCommandEntries().filter((entry) => entry.name.slice(1).toLowerCase().startsWith(query));
3085
+ slashMatches = slashCommandEntries().filter(
3086
+ (entry) =>
3087
+ entry.name.slice(1).toLowerCase().startsWith(query) || entry.desc.toLowerCase().includes(query)
3088
+ );
3057
3089
  if (!slashMatches.length) return hideSlashMenu();
3058
3090
  if (slashIndex >= slashMatches.length) slashIndex = 0;
3059
3091
  renderSlashMenu();
@@ -3896,35 +3928,139 @@ const voz = {
3896
3928
  rate: '+0%',
3897
3929
  announce: { start: true, tools: true, end: true },
3898
3930
  },
3899
- cola: Promise.resolve(),
3900
3931
  };
3901
3932
 
3902
- /** Reproduce una frase contra el endpoint de sintesis del server. */
3903
- function reproducirVoz(frase, { voice, rate } = {}) {
3933
+ /**
3934
+ * Un único reproductor para TODO el audio (narración, tool `speak`,
3935
+ * `/audioresumen`, prueba de voz): encola y reproduce de a uno, en orden, con
3936
+ * controles visibles para pausar o detener. Antes cada audio creaba su propio
3937
+ * `<audio>` y, si el modelo pedía dos, sonaban encimados y sin forma de pararlos.
3938
+ */
3939
+ const ap = { el: $('#audio-player'), audio: new Audio(), queue: [], current: null, ready: false };
3940
+
3941
+ function ensurePlayer() {
3942
+ if (!ap.el || ap.ready) return;
3943
+ ap.ready = true;
3944
+ ap.toggle = button('', () => toggleAudio(), 'ap-btn');
3945
+ ap.iconEl = el('span', 'ap-icon');
3946
+ ap.toggle.appendChild(ap.iconEl);
3947
+ ap.labelEl = el('span', 'ap-label');
3948
+ ap.barEl = el('div', 'ap-bar');
3949
+ const progress = el('div', 'ap-progress');
3950
+ progress.appendChild(ap.barEl);
3951
+ const info = el('div', 'ap-info');
3952
+ info.append(ap.labelEl, progress);
3953
+ ap.queueEl = el('span', 'ap-queue');
3954
+ ap.stopBtn = button('', () => stopAudio(), 'ap-btn ap-stop');
3955
+ ap.stopBtn.title = 'detener';
3956
+ ap.stopBtn.setAttribute('aria-label', 'detener audio');
3957
+ ap.stopBtn.appendChild(icon('square', 14));
3958
+ ap.el.append(ap.toggle, info, ap.queueEl, ap.stopBtn);
3959
+
3960
+ ap.audio.addEventListener('ended', () => advanceAudio(ap.current));
3961
+ ap.audio.addEventListener('error', () => {
3962
+ if (ap.current && ap.audio.getAttribute('src')) advanceAudio(ap.current, new Error('No se pudo reproducir el audio.'));
3963
+ });
3964
+ ap.audio.addEventListener('play', renderAudio);
3965
+ ap.audio.addEventListener('pause', renderAudio);
3966
+ ap.audio.addEventListener('timeupdate', () => {
3967
+ const { duration, currentTime } = ap.audio;
3968
+ if (!Number.isFinite(duration) || duration <= 0) return;
3969
+ ap.barEl.style.width = `${Math.min(100, (currentTime / duration) * 100)}%`;
3970
+ });
3971
+ }
3972
+
3973
+ /** Encola un audio. La promesa se resuelve al terminar (o al detenerlo). */
3974
+ function enqueueSpeech({ texto, voice, rate, label = 'audio', maxAgeMs = 0 } = {}) {
3975
+ const frase = String(texto ?? '').trim();
3976
+ if (!frase) return Promise.resolve();
3977
+ ensurePlayer();
3904
3978
  return new Promise((resolve, reject) => {
3905
- const params = new URLSearchParams({
3906
- texto: frase,
3907
- voice: voice ?? voz.settings.voice,
3908
- rate: rate ?? voz.settings.rate,
3909
- });
3910
- const audio = new Audio(`/api/tts?${params}`);
3911
- audio.onended = () => resolve();
3912
- audio.onerror = () => reject(new Error('no se pudo reproducir (¿sin conexión?)'));
3913
- audio.play().catch(() => reject(new Error('el navegador bloqueó el audio: tocá la página y probá de nuevo')));
3979
+ ap.queue.push({ texto: frase, voice, rate, label, createdAt: Date.now(), maxAgeMs, resolve, reject });
3980
+ if (ap.current) renderAudio();
3981
+ else playNextAudio();
3982
+ });
3983
+ }
3984
+
3985
+ function playNextAudio() {
3986
+ const item = ap.queue.shift();
3987
+ if (!item) {
3988
+ ap.current = null;
3989
+ renderAudio();
3990
+ return;
3991
+ }
3992
+ if (item.maxAgeMs && Date.now() - item.createdAt > item.maxAgeMs) {
3993
+ item.resolve?.();
3994
+ return playNextAudio();
3995
+ }
3996
+ ap.current = item;
3997
+ const params = new URLSearchParams({
3998
+ texto: item.texto,
3999
+ voice: item.voice ?? voz.settings.voice,
4000
+ rate: item.rate ?? voz.settings.rate,
3914
4001
  });
4002
+ ap.audio.src = `/api/tts?${params}`;
4003
+ ap.audio.play().catch(() => {
4004
+ advanceAudio(item, new Error('el navegador bloqueó el audio: tocá la página y probá de nuevo'));
4005
+ });
4006
+ renderAudio();
4007
+ }
4008
+
4009
+ function advanceAudio(item, error) {
4010
+ if (!item || ap.current !== item) return;
4011
+ ap.current = null;
4012
+ if (error) item.reject?.(error);
4013
+ else item.resolve?.();
4014
+ playNextAudio();
4015
+ }
4016
+
4017
+ function toggleAudio() {
4018
+ if (!ap.current) return;
4019
+ if (ap.audio.paused) ap.audio.play().catch(() => {});
4020
+ else ap.audio.pause();
4021
+ }
4022
+
4023
+ /** Detiene lo que suena y vacía lo que espera. */
4024
+ function stopAudio() {
4025
+ for (const item of ap.queue) item.resolve?.();
4026
+ ap.queue = [];
4027
+ const current = ap.current;
4028
+ ap.current = null;
4029
+ current?.resolve?.();
4030
+ ap.audio.pause();
4031
+ ap.audio.removeAttribute('src');
4032
+ ap.audio.load();
4033
+ if (ap.barEl) ap.barEl.style.width = '0%';
4034
+ renderAudio();
4035
+ }
4036
+
4037
+ function renderAudio() {
4038
+ if (!ap.el || !ap.ready) return;
4039
+ const active = Boolean(ap.current) || ap.queue.length > 0;
4040
+ ap.el.hidden = !active;
4041
+ if (!active) return;
4042
+ ap.labelEl.textContent = ap.current?.label ?? 'audio';
4043
+ ap.queueEl.hidden = ap.queue.length === 0;
4044
+ ap.queueEl.textContent = ap.queue.length ? `+${ap.queue.length}` : '';
4045
+ const playing = Boolean(ap.current) && !ap.audio.paused;
4046
+ ap.iconEl.replaceChildren(icon(playing ? 'pause' : 'play', 14));
4047
+ ap.toggle.title = playing ? 'pausar' : 'reproducir';
4048
+ ap.toggle.setAttribute('aria-label', ap.toggle.title);
4049
+ }
4050
+
4051
+ /** Reproduce una frase contra el endpoint de sintesis del server. */
4052
+ function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
4053
+ return enqueueSpeech({ texto: frase, voice, rate, label, maxAgeMs });
3915
4054
  }
3916
4055
 
3917
4056
  /**
3918
- * Encola una frase para hablar (secuencial: una a la vez). Si el turno tarda y
3919
- * la frase quedó vieja en la cola, se descarta: narrar el pasado no informa.
4057
+ * Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
4058
+ * cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
3920
4059
  */
3921
4060
  function hablar(texto) {
3922
4061
  const frase = String(texto ?? '').trim();
3923
4062
  if (!frase || !voz.settings.enabled) return;
3924
- const creado = Date.now();
3925
- voz.cola = voz.cola
3926
- .then(() => (Date.now() - creado > 45000 ? null : reproducirVoz(frase)))
3927
- .catch(() => {});
4063
+ reproducirVoz(frase, { label: 'narrando', maxAgeMs: 45000 }).catch(() => {});
3928
4064
  }
3929
4065
 
3930
4066
  /** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
@@ -34,7 +34,7 @@
34
34
  </head>
35
35
  <body>
36
36
  <header class="topbar">
37
- <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.2</span></div>
37
+ <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.3</span></div>
38
38
  <nav class="tabs">
39
39
  <button class="tab active" data-tab="chat">chat</button>
40
40
  <button class="tab" data-tab="providers">proveedores</button>
@@ -141,6 +141,7 @@
141
141
  <div id="toasts" class="toasts" aria-live="polite"></div>
142
142
  <div id="attach-preview" class="attach-preview" hidden></div>
143
143
  <div id="chat-queue" class="chat-queue" hidden></div>
144
+ <div id="audio-player" class="audio-player" hidden></div>
144
145
  <div class="composer-row">
145
146
  <span class="prompt-mark">❯</span>
146
147
  <button type="button" id="attach-image" class="btn icon" title="Adjuntar imagen (o pegala / arrastrala)" aria-label="adjuntar imagen">
@@ -521,7 +521,7 @@ body.resizing * { cursor: col-resize !important; }
521
521
  }
522
522
  .msg-menu[hidden] { display: none; }
523
523
  .msg-menu-sep { height: 1px; margin: 4px 6px; background: var(--border); }
524
- .msg-actions button.msg-act {
524
+ .msg-menu .msg-act {
525
525
  width: 100%;
526
526
  display: flex;
527
527
  align-items: center;
@@ -537,11 +537,24 @@ body.resizing * { cursor: col-resize !important; }
537
537
  }
538
538
  .msg-act .mi { flex: 0 0 auto; color: var(--muted); }
539
539
  .msg-act .mlabel { min-width: 0; }
540
- .msg-actions button.msg-act:hover { background: var(--theirs); }
541
- .msg-actions button.msg-act:hover .mi { color: var(--accent); }
542
- .msg-actions button.msg-act.danger { color: var(--danger); }
543
- .msg-actions button.msg-act.danger:hover { background: var(--theirs); }
544
- .msg-actions button.msg-act.danger:hover .mi { color: var(--danger); }
540
+ .msg-menu .msg-act:hover { background: var(--theirs); }
541
+ .msg-menu .msg-act:hover .mi { color: var(--accent); }
542
+ .msg-menu .msg-act.danger { color: var(--danger); }
543
+ .msg-menu .msg-act.danger:hover { background: var(--theirs); }
544
+ .msg-menu .msg-act.danger:hover .mi { color: var(--danger); }
545
+
546
+ /* En PC el menú del mensaje va en linea (una fila), con el mismo lenguaje
547
+ visual de la consola. En pantallas chicas queda como lista desplegable. */
548
+ @media (min-width: 821px) {
549
+ .msg-menu {
550
+ flex-direction: row;
551
+ align-items: center;
552
+ min-width: 0;
553
+ gap: 2px;
554
+ }
555
+ .msg-menu .msg-act { width: auto; white-space: nowrap; }
556
+ .msg-menu-sep { width: 1px; height: auto; align-self: stretch; margin: 6px 3px; }
557
+ }
545
558
  .bubble .tools { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; font-weight: 700; }
546
559
 
547
560
  .tools-actions { display: flex; align-items: center; gap: 8px; }
@@ -600,12 +613,73 @@ body.resizing * { cursor: col-resize !important; }
600
613
  overflow: auto;
601
614
  }
602
615
  .tool-card > .tool-out:empty { display: none; }
603
- .tool-card > .tool-audio {
604
- display: block;
605
- width: calc(100% - 28px);
606
- height: 32px;
616
+ .tool-card > .tool-audio-btn {
617
+ display: inline-flex;
618
+ align-items: center;
619
+ gap: 6px;
607
620
  margin: 4px 0 2px 18px;
621
+ padding: 4px 10px;
622
+ border: 1px solid var(--border);
623
+ border-radius: 999px;
624
+ background: transparent;
625
+ color: var(--accent);
626
+ cursor: pointer;
627
+ font: 600 12px var(--sans);
628
+ }
629
+ .tool-card > .tool-audio-btn:hover { background: var(--theirs); }
630
+
631
+ /* Reproductor único de audio (narración, speak, resumen): visible y detenible. */
632
+ .audio-player {
633
+ display: flex;
634
+ align-items: center;
635
+ gap: 10px;
636
+ padding: 7px 10px;
637
+ margin-bottom: 6px;
638
+ border: 1px solid var(--border);
639
+ border-radius: 10px;
640
+ background: var(--solid, var(--panel));
641
+ box-shadow: 0 6px 18px rgb(0 0 0 / 0.12);
608
642
  }
643
+ .audio-player[hidden] { display: none; }
644
+ .audio-player .ap-btn {
645
+ flex: 0 0 auto;
646
+ display: inline-grid;
647
+ place-items: center;
648
+ width: 28px;
649
+ height: 28px;
650
+ padding: 0;
651
+ border: 1px solid var(--border);
652
+ border-radius: 999px;
653
+ background: transparent;
654
+ color: var(--accent);
655
+ cursor: pointer;
656
+ }
657
+ .audio-player .ap-btn:hover { background: var(--theirs); }
658
+ .audio-player .ap-stop { color: var(--danger); }
659
+ .audio-player .ap-info { flex: 1 1 auto; min-width: 0; }
660
+ .audio-player .ap-label {
661
+ display: block;
662
+ color: var(--muted);
663
+ font: 600 11px var(--sans);
664
+ text-transform: uppercase;
665
+ letter-spacing: 0.04em;
666
+ margin-bottom: 4px;
667
+ }
668
+ .audio-player .ap-progress {
669
+ height: 4px;
670
+ border-radius: 999px;
671
+ background: var(--border);
672
+ overflow: hidden;
673
+ }
674
+ .audio-player .ap-bar {
675
+ width: 0%;
676
+ height: 100%;
677
+ border-radius: 999px;
678
+ background: var(--accent);
679
+ transition: width 0.15s linear;
680
+ }
681
+ .audio-player .ap-queue { flex: 0 0 auto; color: var(--muted); font: 600 11px var(--sans); }
682
+ .audio-player .ap-queue[hidden] { display: none; }
609
683
  .tool-more {
610
684
  display: inline-block;
611
685
  margin: 2px 0 2px 18px;
@@ -946,7 +1020,7 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
946
1020
  left: 14px;
947
1021
  right: 14px;
948
1022
  bottom: calc(100% - 6px);
949
- max-height: 260px;
1023
+ max-height: min(60vh, 420px);
950
1024
  overflow-y: auto;
951
1025
  display: flex;
952
1026
  flex-direction: column;
@@ -954,8 +1028,9 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
954
1028
  padding: 6px;
955
1029
  border: 1px solid var(--border);
956
1030
  border-radius: 12px;
957
- background: var(--panel);
1031
+ background: var(--solid, var(--panel));
958
1032
  box-shadow: 0 12px 32px rgb(0 0 0 / 0.22);
1033
+ backdrop-filter: blur(18px);
959
1034
  z-index: 30;
960
1035
  }
961
1036
  .slash-menu[hidden] { display: none; }
@@ -14,7 +14,6 @@ import {
14
14
  resolveProviderFor,
15
15
  toolCallPayload,
16
16
  } from '../agentRun.js';
17
- import { fraseFin } from '../announcer.js';
18
17
  import { mp3AOgg } from '../ffmpeg.js';
19
18
  import { resolveTts } from '../ttsSettings.js';
20
19
  import { transcribeAudio } from '../transcribe.js';
@@ -195,13 +194,6 @@ export function startTelegramChannel(
195
194
  }
196
195
  }
197
196
 
198
- /** Resumen hablado del cierre del turno, según los ajustes de voz. */
199
- async function sendResumenDeVoz(chatId, texto) {
200
- const tts = resolveTts(ctx.app);
201
- if (!tts.enabled || tts.announce.end === false) return;
202
- await enviarNotaDeVoz(chatId, texto);
203
- }
204
-
205
197
  // --- proyectos ------------------------------------------------------------
206
198
 
207
199
  /** Carpeta raíz para elegir proyecto: la predeterminada de la consola o el cwd. */
@@ -458,7 +450,6 @@ export function startTelegramChannel(
458
450
  }
459
451
  const footer = completionFooter(result.stopReason, result.rounds, toolCalls, durationMs);
460
452
  await edit(chatId, message.message_id, `${body}\n\n— ${footer}`);
461
- await sendResumenDeVoz(chatId, fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }));
462
453
  ctx.store.addMessages(sessionId, newMessages);
463
454
  ctx.store.touchSession(sessionId, { provider: providerId, model });
464
455
  } catch (error) {
@@ -784,18 +775,7 @@ export function startTelegramChannel(
784
775
 
785
776
  if (event.type === 'done' && notifyConfig.done) {
786
777
  const text = `[${project}] ✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`;
787
- const decir =
788
- event.data?.say ??
789
- fraseFin({
790
- stopReason: event.data?.stopReason,
791
- rounds: event.data?.rounds,
792
- toolCalls: event.data?.toolCalls,
793
- durationMs: event.data?.durationMs,
794
- });
795
- for (const chatId of targets) {
796
- send(chatId, text).catch(() => {});
797
- sendResumenDeVoz(chatId, `${project}. ${decir}`);
798
- }
778
+ for (const chatId of targets) send(chatId, text).catch(() => {});
799
779
  } else if (event.type === 'error' && notifyConfig.error) {
800
780
  const text = `[${project}] ✗ Error en la sesión: ${event.data?.message ?? ''}`;
801
781
  for (const chatId of targets) send(chatId, text).catch(() => {});
@@ -6,6 +6,49 @@ import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView
6
6
  const COMPACT_INSTRUCTION =
7
7
  'Resumí la conversación en un texto conciso que preserve decisiones tomadas, archivos tocados, hallazgos y el estado actual, para poder continuar el trabajo sin el historial completo.';
8
8
 
9
+ const SUMMARY_INSTRUCTION =
10
+ 'Resumí la conversación para la persona: qué se pidió, qué se hizo, decisiones tomadas, archivos o comandos relevantes y el estado actual. Sé claro y conciso, en español, sin markdown pesado.';
11
+
12
+ function sessionTranscript(messages) {
13
+ return messages
14
+ .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
15
+ .join('\n');
16
+ }
17
+
18
+ /**
19
+ * Corre un resumen no destructivo de la sesión (no toca el historial).
20
+ * Devuelve `{ summary }` o `{ error, status }`.
21
+ */
22
+ async function summarizeSession(ctx, session, instruction) {
23
+ const messages = ctx.store.getMessages(session.id);
24
+ if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
25
+ const providerId = session.provider || ctx.effectiveProviderId();
26
+ const model = session.model || ctx.effectiveModel();
27
+ if (!model) return { error: 'Falta el modelo para resumir.', status: 400 };
28
+
29
+ try {
30
+ const { provider, config } = ctx.resolveProvider({
31
+ ...ctx.providerSettings(providerId),
32
+ apiKey: ctx.apiKeyFor(providerId),
33
+ });
34
+ const result = await runAgent({
35
+ provider,
36
+ config,
37
+ model,
38
+ messages: [
39
+ { role: 'system', content: instruction },
40
+ { role: 'user', content: sessionTranscript(messages).slice(0, 60000) },
41
+ ],
42
+ tools: [],
43
+ });
44
+ const summary = result.content?.trim();
45
+ if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
46
+ return { summary };
47
+ } catch (error) {
48
+ return { error: `No se pudo resumir: ${error?.message ?? error}`, status: 502 };
49
+ }
50
+ }
51
+
9
52
  export function registerSessions(route, ctx) {
10
53
  route('GET', '/api/sessions', ({ res }) => {
11
54
  const awaiting = awaitingBySession(ctx.approvals.list());
@@ -123,40 +166,19 @@ export function registerSessions(route, ctx) {
123
166
  route('POST', '/api/sessions/:id/compact', async ({ res, params }) => {
124
167
  const session = ctx.store.getSession(params.id);
125
168
  if (!session) return sendError(res, 404, 'Sesión no encontrada.');
126
- const messages = ctx.store.getMessages(params.id);
127
- if (!messages.length) return sendError(res, 400, 'La sesión no tiene mensajes para compactar.');
128
-
129
- const providerId = session.provider || ctx.effectiveProviderId();
130
- const model = session.model || ctx.effectiveModel();
131
- if (!model) return sendError(res, 400, 'Falta el modelo para compactar.');
132
-
133
- const transcript = messages
134
- .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
135
- .join('\n');
169
+ const out = await summarizeSession(ctx, session, COMPACT_INSTRUCTION);
170
+ if (out.error) return sendError(res, out.status, out.error);
171
+ ctx.store.clearMessages(params.id);
172
+ ctx.store.touchSession(params.id, { meta: { summary: out.summary } });
173
+ sendJson(res, 200, { summary: out.summary, messages: 0 });
174
+ });
136
175
 
137
- try {
138
- const { provider, config } = ctx.resolveProvider({
139
- ...ctx.providerSettings(providerId),
140
- apiKey: ctx.apiKeyFor(providerId),
141
- });
142
- const result = await runAgent({
143
- provider,
144
- config,
145
- model,
146
- messages: [
147
- { role: 'system', content: COMPACT_INSTRUCTION },
148
- { role: 'user', content: transcript.slice(0, 60000) },
149
- ],
150
- tools: [],
151
- });
152
- const summary = result.content?.trim();
153
- if (!summary) return sendError(res, 502, 'El modelo no devolvió un resumen.');
154
- ctx.store.clearMessages(params.id);
155
- ctx.store.touchSession(params.id, { meta: { summary } });
156
- sendJson(res, 200, { summary, messages: 0 });
157
- } catch (error) {
158
- sendError(res, 502, `No se pudo compactar: ${error?.message ?? error}`);
159
- }
176
+ route('POST', '/api/sessions/:id/summary', async ({ res, params }) => {
177
+ const session = ctx.store.getSession(params.id);
178
+ if (!session) return sendError(res, 404, 'Sesión no encontrada.');
179
+ const out = await summarizeSession(ctx, session, SUMMARY_INSTRUCTION);
180
+ if (out.error) return sendError(res, out.status, out.error);
181
+ sendJson(res, 200, { summary: out.summary });
160
182
  });
161
183
 
162
184
  route('DELETE', '/api/sessions/:id', ({ res, params }) => {
@@ -1719,6 +1719,40 @@ test('compact summarizes the session and clears its messages', async () => {
1719
1719
  }
1720
1720
  });
1721
1721
 
1722
+ test('the summary endpoint returns a summary without clearing the messages', async () => {
1723
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-summary-'));
1724
+ const provider = {
1725
+ id: 'sum',
1726
+ async streamChat(_config, opts) {
1727
+ const system = opts.messages.find((m) => m.role === 'system')?.content ?? '';
1728
+ return { content: system.includes('Resumí la conversación') ? 'RESUMEN FINAL' : 'hola', reasoning: '', toolCalls: [] };
1729
+ },
1730
+ };
1731
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
1732
+ const json = { 'Content-Type': 'application/json' };
1733
+ try {
1734
+ const created = await (
1735
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'S' }) })
1736
+ ).json();
1737
+ const id = created.session.id;
1738
+ await (await fetch(`${base}/api/chat`, {
1739
+ method: 'POST',
1740
+ headers: json,
1741
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
1742
+ })).text();
1743
+
1744
+ const summary = await (
1745
+ await fetch(`${base}/api/sessions/${id}/summary`, { method: 'POST', headers: json })
1746
+ ).json();
1747
+ assert.equal(summary.summary, 'RESUMEN FINAL');
1748
+
1749
+ const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
1750
+ assert.ok(detail.messages.length > 0, 'no borra el historial');
1751
+ } finally {
1752
+ await instance.close();
1753
+ }
1754
+ });
1755
+
1722
1756
  test('exposes modes and persists the active mode', async () => {
1723
1757
  const { instance, base } = await startServer();
1724
1758
  const json = { 'Content-Type': 'application/json' };
@@ -107,7 +107,7 @@ function preselect(ctx, cwd, chatId = '7') {
107
107
  return sessionId;
108
108
  }
109
109
 
110
- test('con la voz activada, el turno de Telegram termina con una nota de voz', async () => {
110
+ test('el cierre del turno de Telegram no manda audio aunque la voz esté activada', async () => {
111
111
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgvoz-'));
112
112
  const ctx = makeCtx(cwd, { tts: { enabled: true, voice: 'es-AR-ElenaNeural', rate: '+0%', announce: { end: true } } });
113
113
  preselect(ctx, cwd);
@@ -117,10 +117,8 @@ test('con la voz activada, el turno de Telegram termina con una nota de voz', as
117
117
  const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
118
118
  try {
119
119
  mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
120
- await waitFor(() => mock.multipart.length > 0);
121
- const nota = mock.multipart[0];
122
- assert.equal(nota.chatId, '7');
123
- assert.match(nota.method, /^send(Voice|Audio)$/);
120
+ await waitFor(() => mock.sent.some((m) => /✓ terminado/.test(m.text ?? '')));
121
+ assert.equal(mock.multipart.length, 0);
124
122
  } finally {
125
123
  global.WebSocket = original;
126
124
  channel.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.9.2",
3
+ "version": "0.9.3",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",