@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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ 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.5] - 2026-10-08
9
+
10
+ ### Agregado
11
+
12
+ - **Actualizacion desde la consola**: `GET /api/update` consulta el registro npm
13
+ y un banner ofrece **actualizar con un click**; `POST /api/update` (solo
14
+ loopback) apaga el server, instala la version nueva con un script detached y lo
15
+ vuelve a levantar. Un click en la version del topbar fuerza el chequeo.
16
+ `tamnora update` detiene el server antes de instalar (Windows bloquea los
17
+ archivos), detecta drift entre instalaciones y lo reinicia; `tamnora doctor`
18
+ avisa si hay una version nueva.
19
+ - **Asesor**: una capa de comentario sobre el run activo que interpreta lo que
20
+ hizo el agente y, si aporta algo, alerta un problema real de codigo, explica un
21
+ proceso o da un consejo. No lee la respuesta del agente. Tarjeta en el chat,
22
+ evento `advice` y voz opcional. Ajustes en la pestana **Asesor** (`app.json` ->
23
+ `advisor`).
24
+ - **Voz: leer la respuesta final**: `tts.announce.answer` lee la respuesta del
25
+ agente limpiando bloques de codigo, tablas y marcas de markdown.
26
+
27
+ ### Corregido
28
+
29
+ - El asesor y la respuesta final ya no se descartan de la cola de audio cuando
30
+ quedan detras de una narracion larga (`maxAgeMs: 0`).
31
+
32
+ ## [0.9.4] - 2026-10-07
33
+
34
+ ### Cambios
35
+
36
+ - feat: navegador headless (screenshot/console_errors) y permisos de Telegram
37
+
8
38
  ## [0.9.3] - 2026-10-07
9
39
 
10
40
  ### Cambios
package/README.md CHANGED
@@ -5,8 +5,19 @@ con TUI, consola web, tools locales, MCP y evaluacion de modelos.
5
5
 
6
6
  - **Sin dependencias**: solo modulos de Node (`node:sqlite`, `node:crypto`, ...).
7
7
  - **Local**: corre en tu maquina y guarda el estado en tu casa local.
8
- - **Multi-proveedor**: OpenRouter, OpenAI, proveedores compatibles (DeepSeek,
9
- Groq, Mistral, xAI, Together, Fireworks, Cerebras, Perplexity) y Ollama.
8
+ - **Multi-proveedor**: OpenRouter, OpenAI, Anthropic (Claude), Google Gemini,
9
+ proveedores compatibles (DeepSeek, Groq, Mistral, xAI, Together, Fireworks,
10
+ Cerebras, Perplexity) y Ollama.
11
+ - **Navegador headless**: tools `screenshot` y `console_errors` (renderiza JS y
12
+ saca una captura PNG o lista los errores de consola) y `preview_url`.
13
+ - **Voz**: el agente narra su progreso, puede **leer la respuesta final** (sin
14
+ bloques de codigo ni tablas) y mandar audio bajo pedido (`speak`,
15
+ `/audioresumen`).
16
+ - **Asesor**: una capa de comentario sobre el run que interpreta lo que hizo el
17
+ agente y, si aporta algo, avisa de un problema en el codigo, explica un proceso
18
+ o da un consejo (con tarjeta en el chat y voz opcional). No lee la respuesta.
19
+ - **Actualizar desde la consola**: la consola avisa cuando hay una version nueva
20
+ y la instala con un click (el server se reinicia solo).
10
21
 
11
22
  ## Instalacion
12
23
 
@@ -39,7 +50,8 @@ tamnora tui --mode plan # modo plan: solo lectura, sin escritura ni bash
39
50
  Atajos: `enter` enviar · `↑/↓` historial · `←/→` cursor · `home/end` inicio/fin ·
40
51
  `pgup/pgdn` scroll · `ctrl+l` limpiar · `ctrl+c` cancelar/salir · `/salir`,
41
52
  `/limpiar`. El pegado de bloques multilinea entra como un solo texto y el header
42
- muestra el contexto usado (`ctx <tokens>`).
53
+ muestra el contexto usado (`ctx <tokens>`). Las tools `write`/`edit` muestran un
54
+ **diff lado a lado** (viejo en rojo, nuevo en verde, con numeros de linea).
43
55
 
44
56
  ### Server como servicio
45
57
 
@@ -50,8 +62,8 @@ tamnora server --reload # reinicia aplicando config/codigo
50
62
  tamnora server --stop
51
63
  tamnora server --status
52
64
  tamnora server --logs --follow
53
- tamnora doctor # verifica Node, casa, puerto y base
54
- tamnora update # busca/instala una version nueva
65
+ tamnora doctor # verifica Node, casa, puerto, base y version nueva
66
+ tamnora update # busca/instala una version nueva (reinicia el server)
55
67
  tamnora autostart install # arranque automatico del server
56
68
  ```
57
69
 
@@ -63,10 +75,14 @@ Tamnora (override con `--home` o `TAMNORA_HOME`):
63
75
  - macOS: `~/Library/Application Support/tamnora`
64
76
  - Linux: `~/.config/tamnora`
65
77
 
66
- > **Actualizar**: en `0.4.1+` alcanza con `tamnora update` (o `--yes`). Si venis de
67
- > **`0.4.0` o anterior**, `tamnora update` fallaba en Windows, asi que hace una vez
68
- > `npm i -g @danieltmn/tamnora@latest` (agrega `--prefer-online` si el cache de npm
69
- > devuelve la version vieja) o corre con `npx @danieltmn/tamnora@latest`.
78
+ > **Actualizar**: en `0.9.5+` la consola avisa cuando hay una version nueva y la
79
+ > instala con un click (el server se reinicia solo); un click en la version del
80
+ > topbar fuerza el chequeo. Por CLI, `tamnora update --yes` ahora **detiene el
81
+ > server antes de instalar** (en Windows libera los archivos) y lo reinicia. Si
82
+ > venis de **`0.8.0` o anterior** y "dice que actualizo pero sigue igual", hace una
83
+ > vez: `tamnora server --stop`, `npm i -g @danieltmn/tamnora@latest` y
84
+ > `tamnora server --start`. Verifica que `where tamnora` coincida con
85
+ > `npm root -g` (si no, hay dos instalaciones y el update toca la otra).
70
86
 
71
87
  ### Consola web
72
88
 
@@ -91,6 +107,16 @@ menu "mas" (los tres puntitos). En el chat:
91
107
  - **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
92
108
  volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
93
109
  el boton de detener (o `POST /api/sessions/:id/stop`).
110
+ - **Audio**: un unico reproductor encola la narracion, el tool `speak`, la
111
+ respuesta final y los consejos del asesor, y reproduce de a uno con pausa y
112
+ detener (Ajustes -> Voz).
113
+ - **Asesor**: en Ajustes -> Asesor se activa una capa que comenta el turno
114
+ (problemas de codigo / explicaciones / consejos) con proveedor y modelo activos.
115
+ - **Actualizar**: cuando npm tiene una version nueva aparece un banner para
116
+ actualizar con un click; el server se reinicia solo.
117
+ - **Comandos**: el input muestra un menu al escribir `/` (`/new`, `/init`, `/goal`,
118
+ `/resumen`, `/audioresumen`, `/compact`, `/clear`, `/cwd`, `/kill`, `/help`).
119
+ - **Capturas**: el tool `screenshot` muestra la imagen inline en su tarjeta.
94
120
 
95
121
  ### Preview
96
122
 
@@ -136,10 +162,15 @@ Con un token de bot (`telegram.token` en `tamnora.json`, `app.json` o
136
162
  `TAMNORA_TELEGRAM_TOKEN`) podes manejar el harness desde el chat. Cada chat mapea
137
163
  a una sesion y el bot soporta:
138
164
 
139
- - texto, **imagenes** y **notas de voz** (transcripcion con `telegram.transcribeModel`,
140
- default `whisper-1`, via `POST {baseUrl}/audio/transcriptions`);
141
- - aprobaciones `ask` con **botones inline** (permitir/denegar);
142
- - comandos `/new`, `/status`, `/model`, `/provider`, `/mode`, `/stop`, `/help`;
165
+ - texto, **imagenes** (entrantes y salientes: el tool `screenshot` llega como foto)
166
+ y **notas de voz** (transcripcion con `telegram.transcribeModel`, default
167
+ `whisper-1`, via `POST {baseUrl}/audio/transcriptions`);
168
+ - aprobaciones `ask` con **botones inline** y una explicacion de lo que va a hacer
169
+ el agente (`Permitir` / `Permitir todo` / `Denegar`); "Permitir todo" deja de
170
+ preguntar por el resto de la sesion;
171
+ - el bot **solo manda audio cuando lo pedis** (tool `speak` o `/audioresumen`);
172
+ - comandos `/new`, `/resumen`, `/audioresumen`, `/status`, `/model`, `/provider`,
173
+ `/mode`, `/stop`, `/help`;
143
174
  - **notificaciones**: si un run de la consola pertenece a una sesion vinculada a un
144
175
  chat, el canal avisa el fin, los errores, los subagentes y los permisos.
145
176
 
@@ -161,11 +192,15 @@ entorno completo, `"inheritEnv": true`.
161
192
  ## Tools
162
193
 
163
194
  Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
164
- `edit`, `bash`, `fetch_url`) y de Git (`git_status`, `git_diff`, `git_log`,
195
+ `edit`, `bash`, `fetch_url`), de navegador (`screenshot`, `console_errors`,
196
+ `preview_url`), de voz (`speak`) y de Git (`git_status`, `git_diff`, `git_log`,
165
197
  `git_commit`), con permisos `allow`/`ask`/`deny` por tool o por patron.
198
+
166
199
  `fetch_url` lee URLs (HTML a texto) y avisa cuando un sitio exige JavaScript o
167
- esta detras de Cloudflare; para esos casos, registra un MCP de navegador (por
168
- ejemplo Playwright).
200
+ esta detras de Cloudflare; para esos casos las tools `screenshot` / `console_errors`
201
+ usan el **Chrome/Edge/Chromium del sistema** como navegador headless (sin
202
+ dependencias): sacan una captura PNG y recolectan los errores de consola. Para
203
+ automatizacion mas completa, registra un MCP (por ejemplo Playwright).
169
204
 
170
205
  ## Subagentes
171
206
 
@@ -7,6 +7,9 @@ import {
7
7
  databasePath,
8
8
  serverLogPath,
9
9
  Store,
10
+ readPackageVersion,
11
+ compareVersions,
12
+ fetchLatestVersion,
10
13
  } from '../../../core/src/index.js';
11
14
  import { parse } from '../lib/util.js';
12
15
  import { pidAlive, readRuntime } from '../lib/daemon.js';
@@ -52,6 +55,17 @@ export async function doctorCommand(argv) {
52
55
  checks.push(['base de datos', dbOk, dbOk ? '' : 'se creara al primer uso']);
53
56
  checks.push(['proveedor activo', Boolean(provider), provider || 'elegi uno en la consola (npm: tamnora serve)']);
54
57
 
58
+ const current = readPackageVersion();
59
+ let versionHint = '';
60
+ try {
61
+ const latest = await fetchLatestVersion();
62
+ versionHint =
63
+ compareVersions(latest, current) > 0 ? `hay v${latest}: corre tamnora update --yes` : 'al dia';
64
+ } catch (error) {
65
+ versionHint = `no pude verificar (${error?.message ?? error})`;
66
+ }
67
+ checks.push([`version (v${current})`, true, versionHint]);
68
+
55
69
  if (serverLive) checks.push([`server (pid ${rt.pid})`, true, rt.url || '']);
56
70
  checks.push([`logs`, true, serverLogPath()]);
57
71
 
@@ -1,16 +1,26 @@
1
1
  import { spawnSync } from 'node:child_process';
2
- import { readPackageVersion } from '../lib/core.js';
2
+ import path from 'node:path';
3
+ import { fetchLatestVersion, compareVersions, packageRoot, readPackageVersion, readApp } from '../lib/core.js';
3
4
  import { parse } from '../lib/util.js';
5
+ import { readRuntime, pidAlive, stopRunningServer, spawnDetached, portHint } from '../lib/daemon.js';
4
6
 
5
7
  const PACKAGE = '@danieltmn/tamnora';
6
8
  const IS_WIN = process.platform === 'win32';
7
9
  const NPM = IS_WIN ? 'npm.cmd' : 'npm';
8
- const OPTIONS = { yes: { type: 'boolean', short: 'y' }, check: { type: 'boolean' }, tag: { type: 'string' }, help: { type: 'boolean', short: 'h' } };
10
+ const OPTIONS = {
11
+ yes: { type: 'boolean', short: 'y' },
12
+ check: { type: 'boolean' },
13
+ restart: { type: 'boolean' },
14
+ 'no-restart': { type: 'boolean' },
15
+ tag: { type: 'string' },
16
+ help: { type: 'boolean', short: 'h' },
17
+ };
9
18
 
10
19
  function npm(args) {
11
- return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true, shell: IS_WIN });
20
+ return spawnSync(NPM, args, { encoding: 'utf8', windowsHide: true, shell: IS_WIN, timeout: 180000 });
12
21
  }
13
22
 
23
+ /** Version del paquete instalado globalmente (la que resuelve `npm ls -g`). */
14
24
  function installedVersion() {
15
25
  const out = npm(['ls', '-g', PACKAGE, '--json', '--depth=0']);
16
26
  try {
@@ -21,10 +31,49 @@ function installedVersion() {
21
31
  }
22
32
  }
23
33
 
34
+ function globalRoot() {
35
+ const out = npm(['root', '-g']);
36
+ return out.status === 0 ? String(out.stdout || '').trim() : '';
37
+ }
38
+
39
+ const norm = (p) => path.resolve(String(p || '')).toLowerCase();
40
+
41
+ /**
42
+ * Detiene el server gestionado (runtime.json) para que Windows libere los
43
+ * archivos bloqueados; sin esto `npm i -g` falla o queda a medias.
44
+ */
45
+ async function stopServerIfRunning() {
46
+ const rt = readRuntime();
47
+ if (!rt || !pidAlive(rt.pid)) return null;
48
+ console.log(`Deteniendo el server (pid ${rt.pid}) para poder reemplazar los archivos...`);
49
+ stopRunningServer();
50
+ for (let i = 0; i < 25 && pidAlive(rt.pid); i++) await new Promise((r) => setTimeout(r, 200));
51
+ return rt;
52
+ }
53
+
54
+ function restartServer(rt) {
55
+ try {
56
+ const bin = process.argv[1];
57
+ const args = [bin, 'server', '--stream', '--no-open'];
58
+ if (rt?.port) args.push('--port', String(rt.port));
59
+ if (rt?.host) args.push('--host', rt.host);
60
+ if (rt?.cwd) args.push('--dir', rt.cwd);
61
+ const { child } = spawnDetached(args, { envFlag: 'TAMNORA_DETACHED' });
62
+ console.log(`Server reiniciado en segundo plano (pid ${child.pid}).`);
63
+ } catch (error) {
64
+ console.error(`No pude reiniciar el server solo: ${error?.message ?? error}`);
65
+ console.error('Reinicialo a mano: tamnora server --start');
66
+ }
67
+ }
68
+
24
69
  export async function updateCommand(argv) {
25
70
  const { values } = parse(argv, OPTIONS);
26
71
  if (values.help) {
27
- console.log('Uso: tamnora update [--check] [--yes]\n\nBusca una version nueva en npm y actualiza la instalacion global.');
72
+ console.log(
73
+ 'Uso: tamnora update [--check] [--yes] [--no-restart]\n\n' +
74
+ 'Busca una version nueva en npm y actualiza la instalacion global.\n' +
75
+ 'Detiene el server antes de instalar (en Windows libera los archivos) y lo reinicia al terminar.'
76
+ );
28
77
  return 0;
29
78
  }
30
79
 
@@ -33,24 +82,20 @@ export async function updateCommand(argv) {
33
82
  const version = readPackageVersion();
34
83
  console.log(`Version actual: ${version}`);
35
84
 
36
- const view = npm(['view', spec, 'version', '--json']);
37
85
  let latest = '';
38
86
  try {
39
- latest = String(JSON.parse(view.stdout)).trim();
40
- } catch {
41
- latest = String(view.stdout || '').trim().replace(/"/g, '');
42
- }
43
- if (view.status !== 0 || !/^\d+\.\d+\.\d+/.test(latest)) {
44
- console.error(`No se pudo consultar npm (${tag}): ${(view.stderr || '').trim() || 'revisa tu conexion'}`);
87
+ latest = await fetchLatestVersion({ pkg: PACKAGE, tag });
88
+ } catch (error) {
89
+ console.error(`No se pudo consultar npm (${tag}): ${error?.message ?? error}`);
45
90
  return 1;
46
91
  }
47
92
  console.log(`Disponible (${tag}): ${latest}`);
48
- if (latest === version) {
93
+ if (compareVersions(latest, version) <= 0) {
49
94
  console.log('Ya estas en la ultima version.');
50
95
  return 0;
51
96
  }
52
97
  if (values.check) {
53
- console.log(`Hay una version nueva. Corre: tamnora update --yes`);
98
+ console.log('Hay una version nueva. Corre: tamnora update --yes');
54
99
  return 0;
55
100
  }
56
101
  if (!values.yes) {
@@ -60,21 +105,46 @@ export async function updateCommand(argv) {
60
105
  return 0;
61
106
  }
62
107
 
108
+ const wasRunning = await stopServerIfRunning();
109
+ if (!wasRunning) {
110
+ // Un server no gestionado (ej. `pnpm serve` en primer plano) tambien
111
+ // bloquea los archivos. Avisamos en vez de matar un proceso ajeno.
112
+ const app = readApp();
113
+ const hint = app?.port ? portHint(app.port) : '';
114
+ if (hint) console.warn(`aviso: algo escucha en el puerto ${app.port} (${hint}); cerralo si la instalacion falla.`);
115
+ }
116
+
63
117
  console.log(`Actualizando a ${spec}...`);
64
- const result = spawnSync(NPM, ['i', '-g', spec], {
65
- stdio: 'inherit',
66
- windowsHide: true,
67
- shell: IS_WIN,
68
- });
118
+ const result = spawnSync(NPM, ['i', '-g', spec], { stdio: 'inherit', windowsHide: true, shell: IS_WIN });
69
119
  if (result.status !== 0) {
70
120
  console.error(`La actualizacion fallo. Proba a mano: npm i -g ${spec}`);
121
+ if (wasRunning) restartServer(wasRunning);
71
122
  return 1;
72
123
  }
124
+
73
125
  const installed = installedVersion() || readPackageVersion();
74
126
  console.log(`Listo. Instalado: v${installed} (pediste ${latest}).`);
75
- if (installed && installed !== latest) {
76
- console.log('Ojo: la version instalada no coincide con la publicada. Cerra procesos que usen la carpeta y reintenta.');
127
+
128
+ // Drift: npm instala en un prefix distinto al de la copia que esta corriendo.
129
+ const runningRoot = packageRoot();
130
+ const expectedRoot = globalRoot();
131
+ const expectedPkg = expectedRoot ? path.join(expectedRoot, ...PACKAGE.split('/')) : '';
132
+ if (runningRoot && expectedPkg && norm(runningRoot) !== norm(expectedPkg)) {
133
+ console.warn('Ojo: la copia global quedo en un lugar distinto al de esta instalacion.');
134
+ console.warn(` instalada: ${expectedPkg}`);
135
+ console.warn(` en uso: ${runningRoot}`);
136
+ console.warn(' puede que el update no afecte la copia que corre; revisa cual resuelve "tamnora".');
137
+ }
138
+ if (installed && compareVersions(installed, latest) < 0) {
139
+ console.warn('Ojo: la version instalada no coincide con la publicada. Cerra procesos que usen la carpeta y reintenta.');
140
+ }
141
+
142
+ if (values['no-restart']) {
143
+ console.log('Reinicia el server cuando quieras: tamnora server --reload');
144
+ } else if (wasRunning || values.restart) {
145
+ restartServer(wasRunning || { port: readApp().port });
146
+ } else {
147
+ console.log('Reinicia el server: tamnora server --reload');
77
148
  }
78
- console.log('Reinicia el server: tamnora server --reload');
79
149
  return 0;
80
150
  }
@@ -13,7 +13,20 @@ export const MODES = {
13
13
  id: 'plan',
14
14
  label: 'Plan',
15
15
  description: 'Solo lectura: investiga y propone un plan, sin modificar nada.',
16
- tools: ['read', 'list', 'glob', 'grep', 'fetch_url', 'speak', 'git_status', 'git_diff', 'git_log'],
16
+ tools: [
17
+ 'read',
18
+ 'list',
19
+ 'glob',
20
+ 'grep',
21
+ 'fetch_url',
22
+ 'speak',
23
+ 'screenshot',
24
+ 'console_errors',
25
+ 'preview_url',
26
+ 'git_status',
27
+ 'git_diff',
28
+ 'git_log',
29
+ ],
17
30
  systemPrompt:
18
31
  'Estás en modo PLAN: no modifiques archivos ni ejecutes comandos. Investigá el proyecto con las herramientas de solo lectura y respondé con un plan concreto y accionable.',
19
32
  },
@@ -215,12 +215,19 @@ export async function runAgent({
215
215
  }
216
216
  }
217
217
 
218
- onEvent?.({ type: 'tool_result', id: call.id, name, result: output });
218
+ const normalized = normalizeToolOutput(output);
219
+ onEvent?.({
220
+ type: 'tool_result',
221
+ id: call.id,
222
+ name,
223
+ result: normalized.content,
224
+ meta: normalized.meta,
225
+ });
219
226
  working.push({
220
227
  role: 'tool',
221
228
  tool_call_id: call.id,
222
229
  name,
223
- content: typeof output === 'string' ? output : JSON.stringify(output),
230
+ content: normalized.content,
224
231
  });
225
232
  }
226
233
 
@@ -315,6 +322,20 @@ function createStallGuard(onEvent, stallWarnMs) {
315
322
  };
316
323
  }
317
324
 
325
+ /**
326
+ * Normaliza el resultado de un tool a `{ content, meta }`. Un executor puede
327
+ * devolver un string (lo normal) o `{ content, meta }` cuando necesita mandar
328
+ * metadata al cliente sin ensuciar lo que ve el modelo (p. ej. una captura).
329
+ */
330
+ export function normalizeToolOutput(output) {
331
+ if (typeof output === 'string') return { content: output };
332
+ if (output && typeof output === 'object' && typeof output.content === 'string') {
333
+ return { content: output.content, meta: output.meta };
334
+ }
335
+ if (output == null) return { content: '' };
336
+ return { content: JSON.stringify(output) };
337
+ }
338
+
318
339
  async function defaultExecute(name) {
319
340
  return `Error: la herramienta "${name}" no está disponible todavía (se agrega en la Fase 1).`;
320
341
  }