@danieltmn/tamnora 0.9.3 → 0.9.5

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 (42) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +51 -16
  3. package/harness/packages/cli/src/commands/doctor.js +14 -0
  4. package/harness/packages/cli/src/commands/update.js +91 -21
  5. package/harness/packages/core/src/agent/modes.js +14 -1
  6. package/harness/packages/core/src/agent/runAgent.js +23 -2
  7. package/harness/packages/core/src/browser/index.js +289 -0
  8. package/harness/packages/core/src/config/home.js +5 -0
  9. package/harness/packages/core/src/index.js +9 -2
  10. package/harness/packages/core/src/tools/executors.js +96 -0
  11. package/harness/packages/core/src/tools/labels.js +4 -0
  12. package/harness/packages/core/src/tools/permissions.js +4 -0
  13. package/harness/packages/core/src/tools/schema.js +47 -0
  14. package/harness/packages/core/src/version.js +81 -7
  15. package/harness/packages/core/test/agent.test.js +30 -1
  16. package/harness/packages/core/test/browser.test.js +18 -0
  17. package/harness/packages/core/test/tools.test.js +16 -1
  18. package/harness/packages/core/test/version.test.js +34 -0
  19. package/harness/packages/server/public/app.js +244 -8
  20. package/harness/packages/server/public/index.html +57 -3
  21. package/harness/packages/server/public/landing/index.html +14 -1
  22. package/harness/packages/server/public/markdown.js +54 -0
  23. package/harness/packages/server/public/reconcile.js +3 -1
  24. package/harness/packages/server/public/styles.css +48 -0
  25. package/harness/packages/server/src/lib/advisor.js +177 -0
  26. package/harness/packages/server/src/lib/agentRun.js +4 -3
  27. package/harness/packages/server/src/lib/channels/telegram.js +130 -25
  28. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  29. package/harness/packages/server/src/lib/update.js +82 -0
  30. package/harness/packages/server/src/routes/advisor.js +76 -0
  31. package/harness/packages/server/src/routes/chat.js +30 -0
  32. package/harness/packages/server/src/routes/files.js +25 -1
  33. package/harness/packages/server/src/routes/tts.js +1 -1
  34. package/harness/packages/server/src/routes/update.js +43 -0
  35. package/harness/packages/server/src/server.js +26 -2
  36. package/harness/packages/server/test/advisor.test.js +205 -0
  37. package/harness/packages/server/test/markdown.test.js +43 -1
  38. package/harness/packages/server/test/server.test.js +50 -14
  39. package/harness/packages/server/test/telegram-features.test.js +25 -4
  40. package/harness/packages/server/test/update.test.js +94 -0
  41. package/harness/scripts/self-update.mjs +123 -0
  42. package/package.json +1 -1
@@ -0,0 +1,18 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { existsSync } from 'node:fs';
5
+ import { findBrowserPath } from '../src/browser/index.js';
6
+
7
+ test('findBrowserPath respeta TAMNORA_BROWSER cuando el archivo existe', () => {
8
+ const self = fileURLToPath(import.meta.url);
9
+ assert.ok(existsSync(self));
10
+ const previous = process.env.TAMNORA_BROWSER;
11
+ process.env.TAMNORA_BROWSER = self;
12
+ try {
13
+ assert.equal(findBrowserPath(), self);
14
+ } finally {
15
+ if (previous === undefined) delete process.env.TAMNORA_BROWSER;
16
+ else process.env.TAMNORA_BROWSER = previous;
17
+ }
18
+ });
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { resolveInRoot, toRel } from '../src/tools/paths.js';
7
7
  import { evaluatePermission } from '../src/tools/permissions.js';
8
8
  import { createTools, createToolRegistry } from '../src/tools/index.js';
9
- import { fetchUrlTool } from '../src/tools/executors.js';
9
+ import { fetchUrlTool, previewUrlTool, screenshotTool, consoleTool } from '../src/tools/executors.js';
10
10
 
11
11
  async function makeWorkspace() {
12
12
  const root = await mkdtemp(path.join(tmpdir(), 'tamnora-ws-'));
@@ -169,3 +169,18 @@ test('la tool speak valida el texto y la recorta', async () => {
169
169
  const { definitions } = createTools({ root: await makeWorkspace() });
170
170
  assert.ok(definitions.some((d) => d.function.name === 'speak'));
171
171
  });
172
+
173
+ test('screenshot, console_errors y preview_url validan la URL sin lanzar el navegador', async () => {
174
+ assert.match(previewUrlTool(null, { url: 'https://example.com/x' }), /example\.com/);
175
+ assert.match(previewUrlTool(null, {}), /falta "url"/);
176
+ assert.match(await screenshotTool(null, { url: 'ftp://x' }), /Solo se admite http/);
177
+ assert.match(await screenshotTool(null, {}), /falta "url"/);
178
+ assert.match(await consoleTool(null, { url: 'no-url' }), /URL inválida/);
179
+ assert.equal(evaluatePermission('screenshot', {}, {}), 'allow');
180
+ assert.equal(evaluatePermission('console_errors', {}, {}), 'allow');
181
+ assert.equal(evaluatePermission('preview_url', {}, {}), 'allow');
182
+ const { definitions } = createTools({ root: await makeWorkspace() });
183
+ for (const name of ['screenshot', 'console_errors', 'preview_url']) {
184
+ assert.ok(definitions.some((d) => d.function.name === name), `falta ${name}`);
185
+ }
186
+ });
@@ -0,0 +1,34 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { compareVersions, fetchLatestVersion } from '../src/index.js';
4
+
5
+ test('compareVersions ordena semver (incluye prerelease)', () => {
6
+ assert.equal(compareVersions('1.2.3', '1.2.3'), 0);
7
+ assert.equal(compareVersions('1.2.4', '1.2.3'), 1);
8
+ assert.equal(compareVersions('1.3.0', '1.2.9'), 1);
9
+ assert.equal(compareVersions('2.0.0', '1.9.9'), 1);
10
+ assert.equal(compareVersions('1.0.0', '1.0.0-beta'), 1);
11
+ assert.equal(compareVersions('1.0.0-beta', '1.0.0'), -1);
12
+ assert.equal(compareVersions('v0.9.4', '0.9.4'), 0);
13
+ // Formato desconocido: no afirmamos que hay algo nuevo.
14
+ assert.equal(compareVersions('base', '1.0.0'), 0);
15
+ });
16
+
17
+ test('fetchLatestVersion resuelve el tag desde el registro', async () => {
18
+ const fetchImpl = async (url) => {
19
+ assert.match(String(url), /registry\.npmjs\.org\/@danieltmn\/tamnora$/);
20
+ return { ok: true, json: async () => ({ 'dist-tags': { latest: '9.9.9' } }) };
21
+ };
22
+ assert.equal(await fetchLatestVersion({ fetchImpl }), '9.9.9');
23
+ });
24
+
25
+ test('fetchLatestVersion falla claro con HTTP malo o sin version', async () => {
26
+ await assert.rejects(
27
+ fetchLatestVersion({ fetchImpl: async () => ({ ok: false, status: 500 }) }),
28
+ /HTTP 500/
29
+ );
30
+ await assert.rejects(
31
+ fetchLatestVersion({ fetchImpl: async () => ({ ok: true, json: async () => ({}) }) }),
32
+ /version/
33
+ );
34
+ });
@@ -1,6 +1,6 @@
1
1
  // Tamnora console — dependency-free client for the harness server (terminal theme).
2
2
 
3
- import { renderMarkdown } from './markdown.js';
3
+ import { renderMarkdown, speechText } from './markdown.js';
4
4
  import { streamSse, followRun } from './runStream.js';
5
5
  import { diffState, pollDelayMs, localState, summarizeArgs } from './reconcile.js';
6
6
  import { installRelayTransport, showRelayTokenGate } from './relayClient.js';
@@ -217,7 +217,7 @@ function activateTab(name) {
217
217
  hideMoreMenu();
218
218
  if (name === 'runs') refreshRuns();
219
219
  if (name === 'providers') refreshProviders();
220
- if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshTranscribe(); refreshProfile(); }
220
+ if (name === 'ajustes') { refreshMcp(); refreshSkills(); refreshChannels(); refreshLimits(); refreshVoz(); refreshAsesor(); refreshTranscribe(); refreshProfile(); }
221
221
  }
222
222
 
223
223
  document.querySelectorAll('.tab[data-tab]').forEach((t) =>
@@ -1158,6 +1158,10 @@ function toolSummary(name, args) {
1158
1158
  return shortText(a.url, 90);
1159
1159
  case 'speak':
1160
1160
  return shortText(a.texto, 90);
1161
+ case 'screenshot':
1162
+ case 'console_errors':
1163
+ case 'preview_url':
1164
+ return shortText(a.url, 90);
1161
1165
  case 'git_diff':
1162
1166
  return [a.ref ? `vs ${shortText(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? shortText(a.path, 40) : '']
1163
1167
  .filter(Boolean)
@@ -1189,6 +1193,26 @@ function isFailedToolText(text) {
1189
1193
  return /^Error|^Permiso denegado/i.test(String(text ?? ''));
1190
1194
  }
1191
1195
 
1196
+ /** Nombre de archivo de una captura, del meta o del texto del resultado. */
1197
+ function screenshotName(data, text) {
1198
+ const fromMeta = data?.meta?.screenshot?.name;
1199
+ if (fromMeta) return String(fromMeta);
1200
+ const match = /capturas\/([a-z0-9][a-z0-9-]*\.png)/i.exec(String(text ?? ''));
1201
+ return match ? match[1] : null;
1202
+ }
1203
+
1204
+ /** Muestra la captura del tool `screenshot` en su tarjeta (clic para ampliar). */
1205
+ function showScreenshot(entry, name) {
1206
+ if (!entry || !name || entry.shot) return;
1207
+ entry.shot = true;
1208
+ const img = el('img', 'tool-shot');
1209
+ img.loading = 'lazy';
1210
+ img.alt = 'captura de pantalla';
1211
+ img.src = `/api/screenshots/${encodeURIComponent(name)}`;
1212
+ img.addEventListener('click', () => window.open(img.src, '_blank'));
1213
+ entry.card.appendChild(img);
1214
+ }
1215
+
1192
1216
  /** Cierra la tarjeta de un tool-call (evento tool_result o mensaje persistido). */
1193
1217
  function finishToolCard(entry, data) {
1194
1218
  if (!entry) return;
@@ -1203,6 +1227,7 @@ function finishToolCard(entry, data) {
1203
1227
  entry.truncated = true;
1204
1228
  entry.more.hidden = false;
1205
1229
  }
1230
+ if (!failed) showScreenshot(entry, screenshotName(data, preview));
1206
1231
  }
1207
1232
 
1208
1233
  function previewText(value, maxLines = 24, maxChars = 4000) {
@@ -1439,8 +1464,35 @@ function userImages(content) {
1439
1464
  .filter(Boolean);
1440
1465
  }
1441
1466
 
1467
+ function advisorGlyph(kind) {
1468
+ if (kind === 'code') return '⚠';
1469
+ if (kind === 'explain') return 'ℹ';
1470
+ return '✦';
1471
+ }
1472
+
1473
+ /** Tarjeta del asesor: una capa de comentario, no un mensaje del agente. */
1474
+ function addAdvisorCard(container, { text, kind } = {}) {
1475
+ const clean = String(text ?? '').trim();
1476
+ if (!clean || !container) return null;
1477
+ const exists = [...container.querySelectorAll('.advisor-text')].some((node) => node.textContent === clean);
1478
+ if (exists) return null;
1479
+ const card = el('div', `advisor-card${kind ? ` advisor-${kind}` : ''}`);
1480
+ const head = el('div', 'advisor-head');
1481
+ head.append(el('span', 'advisor-glyph', advisorGlyph(kind)), el('span', 'advisor-title', 'asesor'));
1482
+ card.append(head, el('div', 'advisor-text', clean));
1483
+ container.appendChild(card);
1484
+ return card;
1485
+ }
1486
+
1442
1487
  function renderStored(message, pane) {
1443
1488
  if (message.agent_id) return;
1489
+ if (message.role === 'assistant' && message.name === 'advisor') {
1490
+ addAdvisorCard(pane, {
1491
+ text: typeof message.content === 'string' ? message.content : '',
1492
+ kind: message.mode,
1493
+ });
1494
+ return;
1495
+ }
1444
1496
  if (message.role === 'user') {
1445
1497
  const bubble = addMessage('user', userText(message.content), pane);
1446
1498
  if (message.id) {
@@ -1981,6 +2033,11 @@ function addContinueButton(turn, data) {
1981
2033
  function handleChatEvent(event, data, turn) {
1982
2034
  if (handleAgentEvent(event, data, turn)) return;
1983
2035
  narrar(event, data, turn);
2036
+ if (event === 'advice') {
2037
+ addAdvisorCard(turn.pane, { text: data?.text, kind: data?.kind });
2038
+ scrollMessages(turn.pane);
2039
+ return;
2040
+ }
1984
2041
  if (event === 'agent_start') {
1985
2042
  turn.agents.set(data.agentId, addAgentCard(turn.assistant(), data));
1986
2043
  } else if (event === 'agent_end') {
@@ -2026,6 +2083,10 @@ function handleChatEvent(event, data, turn) {
2026
2083
  const texto = String(data.args?.texto ?? '').trim();
2027
2084
  if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
2028
2085
  }
2086
+ if (turn.live && data.name === 'preview_url' && data.args?.url) {
2087
+ setExplorerMode('preview');
2088
+ loadPreview(data.args.url);
2089
+ }
2029
2090
  } else if (event === 'tool_result') {
2030
2091
  setActivity(turn.assistant(), 'thinking');
2031
2092
  finishToolCard(turn.lastTool, data);
@@ -3593,7 +3654,15 @@ const vozRateEl = $('#voz-rate');
3593
3654
  const vozAnnStartEl = $('#voz-ann-start');
3594
3655
  const vozAnnToolsEl = $('#voz-ann-tools');
3595
3656
  const vozAnnEndEl = $('#voz-ann-end');
3657
+ const vozAnnAdviceEl = $('#voz-ann-advice');
3658
+ const vozAnnAnswerEl = $('#voz-ann-answer');
3596
3659
  const vozFfmpegEl = $('#voz-ffmpeg');
3660
+ const asesorEnabledEl = $('#asesor-enabled');
3661
+ const asesorCodeEl = $('#asesor-code');
3662
+ const asesorExplainEl = $('#asesor-explain');
3663
+ const asesorAdviceEl = $('#asesor-advice');
3664
+ const asesorWordsEl = $('#asesor-words');
3665
+ const asesorNotesEl = $('#asesor-notes');
3597
3666
  const trProviderEl = $('#tr-provider');
3598
3667
  const trModelEl = $('#tr-model');
3599
3668
 
@@ -3926,7 +3995,7 @@ const voz = {
3926
3995
  enabled: false,
3927
3996
  voice: 'es-AR-ElenaNeural',
3928
3997
  rate: '+0%',
3929
- announce: { start: true, tools: true, end: true },
3998
+ announce: { start: true, tools: true, end: true, advice: true, answer: false },
3930
3999
  },
3931
4000
  };
3932
4001
 
@@ -4057,10 +4126,10 @@ function reproducirVoz(frase, { voice, rate, label, maxAgeMs } = {}) {
4057
4126
  * Narra una frase del turno. Si el turno tarda y la frase quedó vieja en la
4058
4127
  * cola, se descarta al llegar su turno (`maxAgeMs`): narrar el pasado no informa.
4059
4128
  */
4060
- function hablar(texto) {
4129
+ function hablar(texto, { label = 'narrando', maxAgeMs = 45000 } = {}) {
4061
4130
  const frase = String(texto ?? '').trim();
4062
4131
  if (!frase || !voz.settings.enabled) return;
4063
- reproducirVoz(frase, { label: 'narrando', maxAgeMs: 45000 }).catch(() => {});
4132
+ reproducirVoz(frase, { label, maxAgeMs }).catch(() => {});
4064
4133
  }
4065
4134
 
4066
4135
  /** Habla el progreso de un turno EN VIVO: el replay de una reconexion no habla. */
@@ -4070,7 +4139,15 @@ function narrar(event, data, turn) {
4070
4139
  if (!enabled) return;
4071
4140
  if (event === 'start' && announce.start) hablar(data?.say);
4072
4141
  else if (event === 'tool_call' && announce.tools) hablar(data?.say);
4073
- else if ((event === 'done' || event === 'aborted' || event === 'error') && announce.end) hablar(data?.say);
4142
+ // El asesor y la respuesta final son el cierre del turno: si quedan en cola
4143
+ // detras de un audio largo, igual hay que leerlos (sin vencimiento).
4144
+ else if (event === 'advice' && announce.advice) hablar(data?.say, { label: 'asesor', maxAgeMs: 0 });
4145
+ else if (event === 'done' && announce.answer) {
4146
+ // Lee la respuesta final limpia; si era solo codigo, cae al cierre.
4147
+ const spoken = speechText(data?.content);
4148
+ if (spoken) hablar(spoken, { label: 'respuesta', maxAgeMs: 0 });
4149
+ else if (announce.end) hablar(data?.say);
4150
+ } else if ((event === 'done' || event === 'aborted' || event === 'error') && announce.end) hablar(data?.say);
4074
4151
  }
4075
4152
 
4076
4153
  async function cargarVoces() {
@@ -4096,6 +4173,8 @@ async function aplicarVoz(data) {
4096
4173
  start: data.announce?.start !== false,
4097
4174
  tools: data.announce?.tools !== false,
4098
4175
  end: data.announce?.end !== false,
4176
+ advice: data.announce?.advice !== false,
4177
+ answer: data.announce?.answer === true,
4099
4178
  },
4100
4179
  };
4101
4180
  if (vozEnabledEl) vozEnabledEl.checked = voz.settings.enabled;
@@ -4113,6 +4192,8 @@ async function aplicarVoz(data) {
4113
4192
  if (vozAnnStartEl) vozAnnStartEl.checked = voz.settings.announce.start;
4114
4193
  if (vozAnnToolsEl) vozAnnToolsEl.checked = voz.settings.announce.tools;
4115
4194
  if (vozAnnEndEl) vozAnnEndEl.checked = voz.settings.announce.end;
4195
+ if (vozAnnAdviceEl) vozAnnAdviceEl.checked = voz.settings.announce.advice;
4196
+ if (vozAnnAnswerEl) vozAnnAnswerEl.checked = voz.settings.announce.answer;
4116
4197
  const estado = $('#voz-status');
4117
4198
  if (estado) {
4118
4199
  estado.className = `summary${voz.settings.enabled ? ' ok' : ''}`;
@@ -4136,6 +4217,8 @@ async function submitVoz() {
4136
4217
  start: vozAnnStartEl?.checked !== false,
4137
4218
  tools: vozAnnToolsEl?.checked !== false,
4138
4219
  end: vozAnnEndEl?.checked !== false,
4220
+ advice: vozAnnAdviceEl?.checked !== false,
4221
+ answer: vozAnnAnswerEl?.checked === true,
4139
4222
  },
4140
4223
  };
4141
4224
  const data = await api('/api/tts/settings', { method: 'PUT', body }).catch((e) => ({ error: e.message }));
@@ -4313,6 +4396,57 @@ async function testVoz() {
4313
4396
  }
4314
4397
  }
4315
4398
 
4399
+ /* --- ajustes: asesor ------------------------------------------------------- */
4400
+
4401
+ function aplicarAsesor(data) {
4402
+ if (!data) return;
4403
+ if (asesorEnabledEl) asesorEnabledEl.checked = data.enabled === true;
4404
+ if (asesorCodeEl) asesorCodeEl.checked = data.code !== false;
4405
+ if (asesorExplainEl) asesorExplainEl.checked = data.explain !== false;
4406
+ if (asesorAdviceEl) asesorAdviceEl.checked = data.advice !== false;
4407
+ if (asesorWordsEl) asesorWordsEl.value = data.maxWords ?? 40;
4408
+ if (asesorNotesEl) asesorNotesEl.value = data.notes ?? '';
4409
+ const estado = $('#asesor-status');
4410
+ if (estado) {
4411
+ estado.className = `summary${data.enabled ? ' ok' : ''}`;
4412
+ estado.textContent = data.enabled ? 'activo' : 'apagado';
4413
+ }
4414
+ }
4415
+
4416
+ async function refreshAsesor() {
4417
+ if (!asesorEnabledEl) return;
4418
+ const data = await api('/api/advisor/settings').catch(() => null);
4419
+ if (data) aplicarAsesor(data);
4420
+ }
4421
+
4422
+ function asesorBody() {
4423
+ return {
4424
+ enabled: asesorEnabledEl?.checked === true,
4425
+ code: asesorCodeEl?.checked !== false,
4426
+ explain: asesorExplainEl?.checked !== false,
4427
+ advice: asesorAdviceEl?.checked !== false,
4428
+ maxWords: Number(asesorWordsEl?.value) || 40,
4429
+ notes: asesorNotesEl?.value ?? '',
4430
+ };
4431
+ }
4432
+
4433
+ async function submitAsesor() {
4434
+ setStatus('#asesor-msg', 'guardando…');
4435
+ const data = await api('/api/advisor/settings', { method: 'PUT', body: asesorBody() }).catch((e) => ({
4436
+ error: e.message,
4437
+ }));
4438
+ if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
4439
+ aplicarAsesor(data);
4440
+ setStatus('#asesor-msg', 'guardado', 'ok');
4441
+ }
4442
+
4443
+ async function testAsesor() {
4444
+ setStatus('#asesor-msg', 'consultando…');
4445
+ const data = await api('/api/advisor/test', { method: 'POST' }).catch((e) => ({ error: e.message }));
4446
+ if (data?.error) return setStatus('#asesor-msg', data.error, 'err');
4447
+ setStatus('#asesor-msg', data.speak ? data.text : '(no diría nada en este caso)', 'ok');
4448
+ }
4449
+
4316
4450
  /* --- ajustes: perfil del usuario ------------------------------------------ */
4317
4451
 
4318
4452
  const perfil = {
@@ -4445,8 +4579,10 @@ if (mcpListEl) {
4445
4579
  $('#mcp-reload').addEventListener('click', () => refreshMcp({ connect: true }));
4446
4580
  $('#tg-save')?.addEventListener('click', submitChannel);
4447
4581
  $('#tg-test')?.addEventListener('click', testChannel);
4448
- $('#voz-save')?.addEventListener('click', submitVoz);
4449
- $('#voz-test')?.addEventListener('click', testVoz);
4582
+ $('#voz-save')?.addEventListener('click', submitVoz);
4583
+ $('#voz-test')?.addEventListener('click', testVoz);
4584
+ $('#asesor-save')?.addEventListener('click', submitAsesor);
4585
+ $('#asesor-test')?.addEventListener('click', testAsesor);
4450
4586
  $('#mcp-close').addEventListener('click', closeMcpForm);
4451
4587
  $('#mf-cancel').addEventListener('click', closeMcpForm);
4452
4588
  $('#mf-transport').addEventListener('change', syncMcpFormFields);
@@ -5407,6 +5543,105 @@ function watchLifecycle() {
5407
5543
  window.addEventListener('pageshow', wake);
5408
5544
  window.addEventListener('online', wake);
5409
5545
  window.addEventListener('focus', wake);
5546
+ // La cache del server dura 10 min: revisar al volver a la pestaña es barato.
5547
+ refreshUpdate();
5548
+ }
5549
+
5550
+ /* --- actualizacion de version --------------------------------------------- */
5551
+
5552
+ const UPDATE_DISMISS_KEY = 'tamnora-update-dismissed';
5553
+ const updateBanner = $('#update-banner');
5554
+ const updateBannerText = $('#update-banner-text');
5555
+ let updateInfo = null;
5556
+
5557
+ function showUpdateBanner(info, { force = false } = {}) {
5558
+ if (!updateBanner || !info?.updateAvailable) return;
5559
+ if (!force && storage.get(UPDATE_DISMISS_KEY) === String(info.latest)) return;
5560
+ updateInfo = info;
5561
+ updateBannerText.textContent = `Hay una versión nueva (v${info.latest}); tenés la v${info.current}.`;
5562
+ updateBanner.hidden = false;
5563
+ const ver = document.querySelector('.brand .ver');
5564
+ if (ver) {
5565
+ ver.classList.add('has-update');
5566
+ ver.title = `Actualización disponible: v${info.latest}`;
5567
+ }
5568
+ }
5569
+
5570
+ async function refreshUpdate() {
5571
+ if (!updateBanner) return;
5572
+ const info = await api('/api/update').catch(() => null);
5573
+ if (info?.updateAvailable) showUpdateBanner(info);
5574
+ }
5575
+
5576
+ function dismissUpdate() {
5577
+ if (updateInfo?.latest) storage.set(UPDATE_DISMISS_KEY, String(updateInfo.latest));
5578
+ if (updateBanner) updateBanner.hidden = true;
5579
+ }
5580
+
5581
+ /** Chequeo manual (click en la version): fuerza la consulta y avisa el resultado. */
5582
+ async function forceUpdateCheck() {
5583
+ const ver = document.querySelector('.brand .ver');
5584
+ const original = ver?.textContent;
5585
+ if (ver) ver.textContent = '…';
5586
+ const info = await api('/api/update?force=1').catch(() => null);
5587
+ if (ver) ver.textContent = info?.current ? `v${info.current}` : original;
5588
+ if (!info) return toast('No pude consultar la versión.');
5589
+ if (info.updateAvailable) {
5590
+ showUpdateBanner(info, { force: true });
5591
+ toast(`Hay una versión nueva: v${info.latest}`);
5592
+ } else if (info.error) {
5593
+ toast(`No pude consultar npm: ${info.error}`);
5594
+ } else {
5595
+ toast(`Estás al día (v${info.current}).`);
5596
+ }
5597
+ }
5598
+
5599
+ /** Espera a que el server reiniciado vuelva a responder `/api/health`. */
5600
+ async function waitServerVersion(tries = 45) {
5601
+ for (let i = 0; i < tries; i++) {
5602
+ await new Promise((r) => setTimeout(r, 2000));
5603
+ const health = await fetch('/api/health', { cache: 'no-store' })
5604
+ .then((r) => (r.ok ? r.json() : null))
5605
+ .catch(() => null);
5606
+ if (health?.version) return health.version;
5607
+ }
5608
+ return '';
5609
+ }
5610
+
5611
+ async function runUpdate() {
5612
+ if (!updateInfo) return;
5613
+ if (!confirm(`¿Actualizar a la v${updateInfo.latest}? La consola se reinicia sola.`)) return;
5614
+ const runBtn = $('#update-run');
5615
+ const laterBtn = $('#update-later');
5616
+ if (runBtn) runBtn.disabled = true;
5617
+ if (laterBtn) laterBtn.disabled = true;
5618
+ updateBannerText.textContent = `Actualizando a v${updateInfo.latest}…`;
5619
+ const res = await api('/api/update', { method: 'POST' }).catch((error) => ({ error: error.message }));
5620
+ if (res?.error) {
5621
+ updateBannerText.textContent = `No se pudo actualizar: ${res.error}`;
5622
+ if (runBtn) runBtn.disabled = false;
5623
+ if (laterBtn) laterBtn.disabled = false;
5624
+ return;
5625
+ }
5626
+ updateBannerText.textContent = 'Instalando la versión nueva… la consola se va a reconectar.';
5627
+ const version = await waitServerVersion();
5628
+ if (version) {
5629
+ updateBannerText.textContent = `Actualizado a v${version}. Recargando…`;
5630
+ setTimeout(() => location.reload(), 900);
5631
+ } else {
5632
+ updateBannerText.textContent = 'No pude confirmar el reinicio. Recargá la página en unos segundos.';
5633
+ if (runBtn) runBtn.disabled = false;
5634
+ if (laterBtn) laterBtn.disabled = false;
5635
+ }
5636
+ }
5637
+
5638
+ $('#update-run')?.addEventListener('click', runUpdate);
5639
+ $('#update-later')?.addEventListener('click', dismissUpdate);
5640
+ const brandVerEl = document.querySelector('.brand .ver');
5641
+ if (brandVerEl) {
5642
+ brandVerEl.title = 'buscar actualización';
5643
+ brandVerEl.classList.add('ver-action');
5644
+ brandVerEl.addEventListener('click', forceUpdateCheck);
5410
5645
  }
5411
5646
 
5412
5647
  /* --- boot ---------------------------------------------------------------- */
@@ -5430,6 +5665,7 @@ async function boot() {
5430
5665
  const ver = document.querySelector('.brand .ver');
5431
5666
  if (ver) ver.textContent = `v${health.version}`;
5432
5667
  }
5668
+ refreshUpdate();
5433
5669
  await refreshProviders();
5434
5670
  await loadModes();
5435
5671
  await loadModels();
@@ -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.3</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.5</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>
@@ -88,6 +88,15 @@
88
88
  </div>
89
89
  </header>
90
90
 
91
+ <div id="update-banner" class="update-banner" hidden>
92
+ <span class="update-banner-icon" aria-hidden="true">⬆</span>
93
+ <span id="update-banner-text" class="update-banner-text"></span>
94
+ <div class="update-banner-actions">
95
+ <button id="update-run" type="button" class="btn primary">actualizar</button>
96
+ <button id="update-later" type="button" class="btn">más tarde</button>
97
+ </div>
98
+ </div>
99
+
91
100
  <main>
92
101
  <section id="view-chat" class="view active">
93
102
  <aside id="sidebar" class="sidebar">
@@ -415,8 +424,9 @@
415
424
  <button type="button" class="ajustes-tab" data-panel="mcp" role="tab">MCP</button>
416
425
  <button type="button" class="ajustes-tab" data-panel="skills" role="tab">Skills</button>
417
426
  <button type="button" class="ajustes-tab" data-panel="telegram" role="tab">Telegram</button>
418
- <button type="button" class="ajustes-tab" data-panel="voz" role="tab">Voz</button>
419
- <button type="button" class="ajustes-tab" data-panel="transcripcion" role="tab">Transcripción</button>
427
+ <button type="button" class="ajustes-tab" data-panel="voz" role="tab">Voz</button>
428
+ <button type="button" class="ajustes-tab" data-panel="asesor" role="tab">Asesor</button>
429
+ <button type="button" class="ajustes-tab" data-panel="transcripcion" role="tab">Transcripción</button>
420
430
  <button type="button" class="ajustes-tab" data-panel="limites" role="tab">Límites</button>
421
431
  </nav>
422
432
 
@@ -626,6 +636,12 @@
626
636
  <label class="check" title="avisar al terminar">
627
637
  <input id="voz-ann-end" type="checkbox" /> el cierre
628
638
  </label>
639
+ <label class="check" title="leer los consejos del asesor">
640
+ <input id="voz-ann-advice" type="checkbox" /> los consejos del asesor
641
+ </label>
642
+ <label class="check" title="leer la respuesta final del agente, sin bloques de código ni tablas">
643
+ <input id="voz-ann-answer" type="checkbox" /> la respuesta final (sin código)
644
+ </label>
629
645
  </div>
630
646
  <p id="voz-ffmpeg" class="muted" hidden>
631
647
  Sin <code>ffmpeg</code> en el PATH, en Telegram llega como audio MP3 en vez de nota
@@ -638,6 +654,44 @@
638
654
  </div>
639
655
  </div>
640
656
 
657
+ <div class="panel ajustes-panel" data-panel="asesor">
658
+ <div class="row between">
659
+ <h2>Asesor</h2>
660
+ <span id="asesor-status" class="summary">cargando…</span>
661
+ </div>
662
+ <p class="muted verify-line">
663
+ Un asesor que observa lo que el agente hace, lo interpreta y solo habla cuando tiene
664
+ algo que aportar: te alerta de un problema en el código, te explica un proceso o te da
665
+ un consejo. No lee la respuesta del agente. Usa el proveedor y modelo activos.
666
+ </p>
667
+ <label class="check" title="tener un asesor que interprete y comente el turno">
668
+ <input id="asesor-enabled" type="checkbox" /> Habilitar el asesor
669
+ </label>
670
+ <div class="voz-announce">
671
+ <span class="muted">qué aporta:</span>
672
+ <label class="check" title="alertar de problemas reales en el código">
673
+ <input id="asesor-code" type="checkbox" /> problemas en el código
674
+ </label>
675
+ <label class="check" title="explicar procesos o decisiones">
676
+ <input id="asesor-explain" type="checkbox" /> explicar procesos
677
+ </label>
678
+ <label class="check" title="dar consejos concretos">
679
+ <input id="asesor-advice" type="checkbox" /> consejos
680
+ </label>
681
+ </div>
682
+ <label class="field"><span>largo de cada consejo (palabras)</span>
683
+ <input id="asesor-words" type="number" min="10" max="120" step="5" value="40" />
684
+ </label>
685
+ <label class="field"><span>instrucciones para el asesor (opcional)</span>
686
+ <textarea id="asesor-notes" rows="3" placeholder="Ej: avisame siempre si toco secretos o config de producción."></textarea>
687
+ </label>
688
+ <div class="row">
689
+ <button id="asesor-save" class="btn primary" type="button">guardar</button>
690
+ <button id="asesor-test" class="btn" type="button">probar asesor</button>
691
+ <span id="asesor-msg" class="summary"></span>
692
+ </div>
693
+ </div>
694
+
641
695
  <div class="panel ajustes-panel" data-panel="transcripcion">
642
696
  <div class="row between">
643
697
  <h2>Transcripción</h2>
@@ -451,7 +451,8 @@
451
451
 
452
452
  <p class="hero-copy">
453
453
  Un harness local para orquestar agentes de IA, ejecutar tools en tu workspace y mantener el
454
- control desde tu máquina o el celular (túnel + QR o Telegram).
454
+ control desde tu máquina o el celular (túnel + QR o Telegram). Renderiza páginas con un
455
+ navegador headless, narra su progreso y hace trazable cada corrida.
455
456
  </p>
456
457
 
457
458
  <div class="actions">
@@ -472,6 +473,18 @@
472
473
  <div class="feature-icon">✓</div>
473
474
  <div><b>Control</b><span>Permisos allow/ask/deny y runs trazables.</span></div>
474
475
  </div>
476
+ <div class="feature">
477
+ <div class="feature-icon">◎</div>
478
+ <div><b>Navegador</b><span>Screenshot y errores de consola de una página.</span></div>
479
+ </div>
480
+ <div class="feature">
481
+ <div class="feature-icon">♪</div>
482
+ <div><b>Voz</b><span>Narra el progreso y manda audio bajo pedido.</span></div>
483
+ </div>
484
+ <div class="feature">
485
+ <div class="feature-icon">⌘</div>
486
+ <div><b>Multi-modelo</b><span>OpenRouter, OpenAI, Claude, Gemini y Ollama.</span></div>
487
+ </div>
475
488
  </div>
476
489
  </section>
477
490
 
@@ -215,3 +215,57 @@ export function renderMarkdown(text) {
215
215
  flush();
216
216
  return out.join('\n');
217
217
  }
218
+
219
+ function cleanSpeechLine(line) {
220
+ return line
221
+ .replace(/^\s*#{1,6}\s+/, '')
222
+ .replace(/^\s*>\s?/, '')
223
+ .replace(/^\s*([-*+]|\d+[.)])\s+/, '')
224
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, '$1')
225
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, '$1')
226
+ .replace(/`([^`]+)`/g, '$1')
227
+ .replace(/\*\*([^*]+)\*\*/g, '$1')
228
+ .replace(/__([^_]+)__/g, '$1')
229
+ .replace(/(^|[^*\w])\*([^*\n]+)\*/g, '$1$2')
230
+ .replace(/(^|[^_\w])_([^_\n]+)_/g, '$1$2');
231
+ }
232
+
233
+ /**
234
+ * Version hablada de una respuesta: deja el texto corrido y saca lo que no se
235
+ * lee bien (bloques de codigo, tablas, marcas de markdown). Recorta a `maxChars`
236
+ * cortando en un limite de oracion, para no mandar una sintesis gigante al TTS.
237
+ */
238
+ export function speechText(text, { maxChars = 1500 } = {}) {
239
+ const stripped = String(text ?? '')
240
+ .replace(/```[\s\S]*?```/g, ' ')
241
+ .replace(/~~~[\s\S]*?~~~/g, ' ');
242
+ const lines = stripped.replace(/\r\n?/g, '\n').split('\n');
243
+ const kept = [];
244
+ let i = 0;
245
+ while (i < lines.length) {
246
+ const line = lines[i];
247
+ if (line.includes('|') && i + 1 < lines.length && isDelimiterRow(lines[i + 1])) {
248
+ i += 2;
249
+ while (i < lines.length && lines[i].trim() && lines[i].includes('|')) i += 1;
250
+ continue;
251
+ }
252
+ if (/^\s*([-*_])(\s*\1){2,}\s*$/.test(line)) {
253
+ i += 1;
254
+ continue;
255
+ }
256
+ kept.push(cleanSpeechLine(line));
257
+ i += 1;
258
+ }
259
+ let out = kept.join('\n').replace(/\s+/g, ' ').trim();
260
+ if (out.length > maxChars) {
261
+ const slice = out.slice(0, maxChars);
262
+ const cut = Math.max(
263
+ slice.lastIndexOf('. '),
264
+ slice.lastIndexOf('! '),
265
+ slice.lastIndexOf('? '),
266
+ slice.lastIndexOf('; ')
267
+ );
268
+ out = `${(cut > maxChars * 0.5 ? slice.slice(0, cut + 1) : slice).trim()}…`;
269
+ }
270
+ return out;
271
+ }
@@ -110,7 +110,9 @@ export function summarizeArgs(name, args, max = 240) {
110
110
  if ((name === 'write' || name === 'edit' || name === 'read') && a.path) {
111
111
  return a.content ? `${a.path} · ${humanSize(a.content)}` : String(a.path);
112
112
  }
113
- if ((name === 'fetch_url' || name === 'preview_url') && a.url) return cut(a.url);
113
+ if ((name === 'fetch_url' || name === 'preview_url' || name === 'screenshot' || name === 'console_errors') && a.url) {
114
+ return cut(a.url);
115
+ }
114
116
  if (name === 'task' && (a.description || a.prompt)) return cut(a.description || a.prompt);
115
117
  const keys = Object.keys(a);
116
118
  if (keys.length === 1 && typeof a[keys[0]] === 'string') return cut(a[keys[0]]);