@danieltmn/tamnora 0.9.3 → 0.9.4

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,12 @@ 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.4] - 2026-10-07
9
+
10
+ ### Cambios
11
+
12
+ - feat: navegador headless (screenshot/console_errors) y permisos de Telegram
13
+
8
14
  ## [0.9.3] - 2026-10-07
9
15
 
10
16
  ### Cambios
package/README.md CHANGED
@@ -5,8 +5,13 @@ 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 y puede mandar audio bajo pedido
14
+ (`speak`, `/audioresumen`).
10
15
 
11
16
  ## Instalacion
12
17
 
@@ -39,7 +44,8 @@ tamnora tui --mode plan # modo plan: solo lectura, sin escritura ni bash
39
44
  Atajos: `enter` enviar · `↑/↓` historial · `←/→` cursor · `home/end` inicio/fin ·
40
45
  `pgup/pgdn` scroll · `ctrl+l` limpiar · `ctrl+c` cancelar/salir · `/salir`,
41
46
  `/limpiar`. El pegado de bloques multilinea entra como un solo texto y el header
42
- muestra el contexto usado (`ctx <tokens>`).
47
+ muestra el contexto usado (`ctx <tokens>`). Las tools `write`/`edit` muestran un
48
+ **diff lado a lado** (viejo en rojo, nuevo en verde, con numeros de linea).
43
49
 
44
50
  ### Server como servicio
45
51
 
@@ -91,6 +97,11 @@ menu "mas" (los tres puntitos). En el chat:
91
97
  - **Runs durables**: cerrar el celular o cortar la red **no cancela** el turno; al
92
98
  volver, la consola **reconecta** y reconstruye el progreso. Para cortarlo se usa
93
99
  el boton de detener (o `POST /api/sessions/:id/stop`).
100
+ - **Audio**: un unico reproductor encola la narracion, el tool `speak` y
101
+ `/audioresumen`, y reproduce de a uno con pausa y detener.
102
+ - **Comandos**: el input muestra un menu al escribir `/` (`/new`, `/init`, `/goal`,
103
+ `/resumen`, `/audioresumen`, `/compact`, `/clear`, `/cwd`, `/kill`, `/help`).
104
+ - **Capturas**: el tool `screenshot` muestra la imagen inline en su tarjeta.
94
105
 
95
106
  ### Preview
96
107
 
@@ -136,10 +147,15 @@ Con un token de bot (`telegram.token` en `tamnora.json`, `app.json` o
136
147
  `TAMNORA_TELEGRAM_TOKEN`) podes manejar el harness desde el chat. Cada chat mapea
137
148
  a una sesion y el bot soporta:
138
149
 
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`;
150
+ - texto, **imagenes** (entrantes y salientes: el tool `screenshot` llega como foto)
151
+ y **notas de voz** (transcripcion con `telegram.transcribeModel`, default
152
+ `whisper-1`, via `POST {baseUrl}/audio/transcriptions`);
153
+ - aprobaciones `ask` con **botones inline** y una explicacion de lo que va a hacer
154
+ el agente (`Permitir` / `Permitir todo` / `Denegar`); "Permitir todo" deja de
155
+ preguntar por el resto de la sesion;
156
+ - el bot **solo manda audio cuando lo pedis** (tool `speak` o `/audioresumen`);
157
+ - comandos `/new`, `/resumen`, `/audioresumen`, `/status`, `/model`, `/provider`,
158
+ `/mode`, `/stop`, `/help`;
143
159
  - **notificaciones**: si un run de la consola pertenece a una sesion vinculada a un
144
160
  chat, el canal avisa el fin, los errores, los subagentes y los permisos.
145
161
 
@@ -161,11 +177,15 @@ entorno completo, `"inheritEnv": true`.
161
177
  ## Tools
162
178
 
163
179
  Tools locales confinadas al workspace (`read`, `list`, `glob`, `grep`, `write`,
164
- `edit`, `bash`, `fetch_url`) y de Git (`git_status`, `git_diff`, `git_log`,
180
+ `edit`, `bash`, `fetch_url`), de navegador (`screenshot`, `console_errors`,
181
+ `preview_url`), de voz (`speak`) y de Git (`git_status`, `git_diff`, `git_log`,
165
182
  `git_commit`), con permisos `allow`/`ask`/`deny` por tool o por patron.
183
+
166
184
  `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).
185
+ esta detras de Cloudflare; para esos casos las tools `screenshot` / `console_errors`
186
+ usan el **Chrome/Edge/Chromium del sistema** como navegador headless (sin
187
+ dependencias): sacan una captura PNG y recolectan los errores de consola. Para
188
+ automatizacion mas completa, registra un MCP (por ejemplo Playwright).
169
189
 
170
190
  ## Subagentes
171
191
 
@@ -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
  }
@@ -0,0 +1,289 @@
1
+ // Navegador headless sin dependencias: usa el Chrome/Edge/Chromium del sistema y
2
+ // lo maneja por el protocolo DevTools (CDP) sobre el WebSocket global de Node
3
+ // 22+ (el mismo recurso que usa el cliente de TTS de Edge). Permite sacar una
4
+ // captura de una pagina y leer los errores de consola, que es lo que un
5
+ // `fetch_url` (GET crudo, sin JS) no puede hacer.
6
+
7
+ import { spawn } from 'node:child_process';
8
+ import { existsSync, mkdtempSync, rmSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import path from 'node:path';
11
+
12
+ const LAUNCH_TIMEOUT_MS = 15000;
13
+
14
+ function winPaths() {
15
+ const roots = [process.env['ProgramFiles'], process.env['ProgramFiles(x86)'], process.env.LOCALAPPDATA].filter(Boolean);
16
+ const out = [];
17
+ for (const root of roots) {
18
+ out.push(path.join(root, 'Google', 'Chrome', 'Application', 'chrome.exe'));
19
+ out.push(path.join(root, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
20
+ }
21
+ return out;
22
+ }
23
+
24
+ const CANDIDATES = {
25
+ win32: winPaths(),
26
+ darwin: [
27
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
28
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
29
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
30
+ ],
31
+ linux: [
32
+ '/usr/bin/google-chrome',
33
+ '/usr/bin/google-chrome-stable',
34
+ '/usr/bin/chromium',
35
+ '/usr/bin/chromium-browser',
36
+ '/usr/bin/microsoft-edge',
37
+ '/snap/bin/chromium',
38
+ ],
39
+ };
40
+
41
+ /** Ruta al navegador headless, o null. `TAMNORA_BROWSER` la forca. */
42
+ export function findBrowserPath() {
43
+ const override = process.env.TAMNORA_BROWSER;
44
+ if (override && existsSync(override)) return override;
45
+ for (const candidate of CANDIDATES[process.platform] ?? []) {
46
+ if (existsSync(candidate)) return candidate;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ function delay(ms) {
52
+ return new Promise((resolve) => setTimeout(resolve, ms));
53
+ }
54
+
55
+ function launchBrowser(bin) {
56
+ return new Promise((resolve, reject) => {
57
+ const profile = mkdtempSync(path.join(tmpdir(), 'tamnora-browser-'));
58
+ const args = [
59
+ '--headless=new',
60
+ '--disable-gpu',
61
+ '--hide-scrollbars',
62
+ '--no-first-run',
63
+ '--no-default-browser-check',
64
+ '--disable-extensions',
65
+ '--disable-background-networking',
66
+ '--remote-debugging-port=0',
67
+ `--user-data-dir=${profile}`,
68
+ 'about:blank',
69
+ ];
70
+ let child;
71
+ try {
72
+ child = spawn(bin, args, { stdio: ['ignore', 'ignore', 'pipe'], windowsHide: true });
73
+ } catch (error) {
74
+ reject(new Error(`no se pudo ejecutar el navegador: ${error.message}`));
75
+ return;
76
+ }
77
+
78
+ let settled = false;
79
+ let stderr = '';
80
+ const finish = (fn) => {
81
+ if (settled) return;
82
+ settled = true;
83
+ clearTimeout(timer);
84
+ fn();
85
+ };
86
+ const timer = setTimeout(
87
+ () => finish(() => reject(new Error('el navegador no arrancó (timeout de DevTools)'))),
88
+ LAUNCH_TIMEOUT_MS
89
+ );
90
+
91
+ child.stderr.on('data', (chunk) => {
92
+ stderr += chunk.toString();
93
+ const match = /DevTools listening on (ws:\/\/\S+)/.exec(stderr);
94
+ if (match) finish(() => resolve({ child, wsUrl: match[1], profile }));
95
+ });
96
+ child.on('error', (error) => finish(() => reject(new Error(`no se pudo ejecutar el navegador: ${error.message}`))));
97
+ child.on('exit', () => finish(() => reject(new Error('el navegador se cerró antes de estar listo'))));
98
+ });
99
+ }
100
+
101
+ class CdpConnection {
102
+ constructor(ws) {
103
+ this.ws = ws;
104
+ this.seq = 0;
105
+ this.pending = new Map();
106
+ this.listeners = new Set();
107
+ ws.addEventListener('message', (event) => {
108
+ let message;
109
+ try {
110
+ message = JSON.parse(typeof event.data === 'string' ? event.data : String(event.data));
111
+ } catch {
112
+ return;
113
+ }
114
+ if (message.id && this.pending.has(message.id)) {
115
+ const { resolve, reject } = this.pending.get(message.id);
116
+ this.pending.delete(message.id);
117
+ if (message.error) reject(new Error(message.error.message || 'error de CDP'));
118
+ else resolve(message.result ?? {});
119
+ } else {
120
+ for (const listener of this.listeners) listener(message);
121
+ }
122
+ });
123
+ }
124
+
125
+ open() {
126
+ return new Promise((resolve, reject) => {
127
+ const timer = setTimeout(() => reject(new Error('timeout conectando al navegador')), 10000);
128
+ this.ws.addEventListener('open', () => {
129
+ clearTimeout(timer);
130
+ resolve();
131
+ }, { once: true });
132
+ this.ws.addEventListener('error', () => {
133
+ clearTimeout(timer);
134
+ reject(new Error('no se pudo conectar al navegador'));
135
+ }, { once: true });
136
+ });
137
+ }
138
+
139
+ send(method, params = {}, sessionId) {
140
+ const id = ++this.seq;
141
+ return new Promise((resolve, reject) => {
142
+ this.pending.set(id, { resolve, reject });
143
+ this.ws.send(JSON.stringify({ id, method, params, ...(sessionId ? { sessionId } : {}) }));
144
+ });
145
+ }
146
+
147
+ on(listener) {
148
+ this.listeners.add(listener);
149
+ return () => this.listeners.delete(listener);
150
+ }
151
+
152
+ close() {
153
+ try {
154
+ this.ws.close();
155
+ } catch {
156
+ /* nada */
157
+ }
158
+ }
159
+ }
160
+
161
+ function describeArg(arg) {
162
+ if (!arg) return '';
163
+ if ('value' in arg) {
164
+ return typeof arg.value === 'string' ? arg.value : JSON.stringify(arg.value);
165
+ }
166
+ if (arg.description) return arg.description;
167
+ if (arg.preview?.properties?.length) {
168
+ return arg.preview.properties.map((p) => `${p.name}: ${p.value}`).join(', ');
169
+ }
170
+ return String(arg.type ?? '');
171
+ }
172
+
173
+ async function openPage(wsUrl, url, { timeoutMs = 20000, collectMessages = true } = {}) {
174
+ const cdp = new CdpConnection(new WebSocket(wsUrl));
175
+ await cdp.open();
176
+ const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
177
+ const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
178
+
179
+ const messages = [];
180
+ const push = (level, text) => {
181
+ const value = String(text ?? '').replace(/\s+/g, ' ').trim();
182
+ if (value) messages.push({ level, text: value });
183
+ };
184
+ if (collectMessages) {
185
+ cdp.on((message) => {
186
+ if (message.sessionId && message.sessionId !== sessionId) return;
187
+ if (message.method === 'Runtime.consoleAPICalled') {
188
+ const level = message.params.type === 'warning' ? 'warning' : message.params.type;
189
+ push(level, (message.params.args ?? []).map(describeArg).join(' '));
190
+ } else if (message.method === 'Runtime.exceptionThrown') {
191
+ const details = message.params.exceptionDetails ?? {};
192
+ push('error', details.exception?.description || details.text || 'excepción');
193
+ } else if (message.method === 'Log.entryAdded') {
194
+ const entry = message.params.entry ?? {};
195
+ if (entry.level === 'error' || entry.level === 'warning') push(entry.level, entry.text);
196
+ }
197
+ });
198
+ }
199
+
200
+ await cdp.send('Page.enable', {}, sessionId);
201
+ await cdp.send('Runtime.enable', {}, sessionId);
202
+ await cdp.send('Log.enable', {}, sessionId).catch(() => {});
203
+
204
+ const loaded = new Promise((resolve) => {
205
+ const off = cdp.on((message) => {
206
+ if (message.method === 'Page.loadEventFired' && message.sessionId === sessionId) {
207
+ off();
208
+ resolve();
209
+ }
210
+ });
211
+ });
212
+ await cdp.send('Page.navigate', { url }, sessionId);
213
+ await Promise.race([loaded, delay(timeoutMs)]);
214
+
215
+ return { cdp, sessionId, targetId, messages };
216
+ }
217
+
218
+ /** Cierra el navegador y sus hijos. En Windows `child.kill` no mata el arbol. */
219
+ function killBrowser(child) {
220
+ if (!child || child.killed) return;
221
+ if (process.platform === 'win32') {
222
+ try {
223
+ spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
224
+ } catch {
225
+ /* nada */
226
+ }
227
+ } else {
228
+ try {
229
+ child.kill('SIGKILL');
230
+ } catch {
231
+ /* nada */
232
+ }
233
+ }
234
+ }
235
+
236
+ async function withBrowser(url, options, run) {
237
+ const bin = findBrowserPath();
238
+ if (!bin) {
239
+ throw new Error('no encontré Chrome/Edge/Chromium; definí TAMNORA_BROWSER con la ruta al ejecutable');
240
+ }
241
+ const { child, wsUrl, profile } = await launchBrowser(bin);
242
+ try {
243
+ const page = await openPage(wsUrl, url, options);
244
+ try {
245
+ return await run(page);
246
+ } finally {
247
+ page.cdp.close();
248
+ }
249
+ } finally {
250
+ killBrowser(child);
251
+ // Chrome tarda en soltar el profile: darle un respiro y no fallar si queda.
252
+ await delay(200);
253
+ try {
254
+ rmSync(profile, { recursive: true, force: true });
255
+ } catch {
256
+ /* profile bloqueado: queda en temp, no es fatal */
257
+ }
258
+ }
259
+ }
260
+
261
+ /**
262
+ * Captura PNG de una URL. Devuelve `{ buffer, width, height, consoleErrors }`.
263
+ */
264
+ export async function captureScreenshot(url, { fullPage = false, width = 1280, height = 720, waitMs = 800, timeoutMs } = {}) {
265
+ return withBrowser(url, { timeoutMs, collectMessages: true }, async ({ cdp, sessionId, messages }) => {
266
+ await cdp
267
+ .send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId)
268
+ .catch(() => {});
269
+ if (waitMs > 0) await delay(waitMs);
270
+ const shot = await cdp.send(
271
+ 'Page.captureScreenshot',
272
+ { format: 'png', captureBeyondViewport: Boolean(fullPage) },
273
+ sessionId
274
+ );
275
+ const errors = messages.filter((m) => m.level === 'error');
276
+ return { buffer: Buffer.from(shot.data, 'base64'), width, height, consoleErrors: errors };
277
+ });
278
+ }
279
+
280
+ /**
281
+ * Abre una URL y devuelve los mensajes de consola. `{ messages, errors }`.
282
+ */
283
+ export async function readConsole(url, { waitMs = 1500, timeoutMs } = {}) {
284
+ return withBrowser(url, { timeoutMs, collectMessages: true }, async ({ messages }) => {
285
+ if (waitMs > 0) await delay(waitMs);
286
+ const errors = messages.filter((m) => m.level === 'error');
287
+ return { messages, errors };
288
+ });
289
+ }
@@ -39,6 +39,11 @@ export function homePath(...parts) {
39
39
  return path.join(homeDir(), ...parts);
40
40
  }
41
41
 
42
+ /** Carpeta de capturas del navegador (la sirve la consola y la usa Telegram). */
43
+ export function screenshotsDir() {
44
+ return homePath('screenshots');
45
+ }
46
+
42
47
  export function appConfigPath() {
43
48
  return homePath('app.json');
44
49
  }
@@ -10,7 +10,7 @@ export {
10
10
  } from './redact.js';
11
11
  export { resolveApiKey, resolveEnvMap } from './config/secrets.js';
12
12
  export { ensureUtf8Console } from './console.js';
13
- export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
13
+ export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments, normalizeToolOutput } from './agent/runAgent.js';
14
14
  export { estimateTokens, trimContext } from './agent/context.js';
15
15
  export { diffLines, diffRows } from './tools/diff.js';
16
16
  export { isGitRepo, gitStatus, gitDiff } from './git/index.js';
@@ -52,6 +52,7 @@ export {
52
52
  setHome,
53
53
  homeDir,
54
54
  homePath,
55
+ screenshotsDir,
55
56
  appConfigPath,
56
57
  databaseFile,
57
58
  runtimePath,
@@ -4,6 +4,8 @@ import path from 'node:path';
4
4
  import { resolveInRoot, toRel } from './paths.js';
5
5
  import { globToRegExp, walkFiles } from './glob.js';
6
6
  import { diffLines } from './diff.js';
7
+ import { captureScreenshot, readConsole } from '../browser/index.js';
8
+ import { screenshotsDir } from '../config/home.js';
7
9
 
8
10
  const MAX_READ_LINES = 2000;
9
11
  const MAX_LIST = 500;
@@ -419,6 +421,97 @@ export function speakTool(_root, { texto, voice, rate } = {}) {
419
421
  return `Audio pedido${opciones ? ` (${opciones})` : ''}: "${recortado}". El cliente lo reproduce o lo envía como nota de voz.`;
420
422
  }
421
423
 
424
+ function normalizeHttpUrl(value) {
425
+ const raw = String(value ?? '').trim();
426
+ if (!raw) return { error: 'falta "url".' };
427
+ let parsed;
428
+ try {
429
+ parsed = new URL(raw);
430
+ } catch {
431
+ return { error: `URL inválida: "${raw}".` };
432
+ }
433
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return { error: 'Solo se admite http(s).' };
434
+ return { url: parsed.toString() };
435
+ }
436
+
437
+ function clampInt(value, fallback, min, max) {
438
+ const n = Math.round(Number(value));
439
+ if (!Number.isFinite(n)) return fallback;
440
+ return Math.min(Math.max(n, min), max);
441
+ }
442
+
443
+ /**
444
+ * Pide abrir una URL en el panel de vista previa de la consola. La entrega es
445
+ * del cliente (igual que `speak`): aca solo se valida la URL.
446
+ */
447
+ export function previewUrlTool(_root, { url } = {}) {
448
+ const parsed = normalizeHttpUrl(url);
449
+ if (parsed.error) return `Error: ${parsed.error}`;
450
+ return `Vista previa pedida: ${parsed.url}. La consola la abre en el panel de vista previa.`;
451
+ }
452
+
453
+ /**
454
+ * Captura de pantalla con el navegador headless del sistema. La guarda en la
455
+ * casa (`screenshots/`) y devuelve un `meta.screenshot` para que la consola la
456
+ * muestre y Telegram la mande como foto.
457
+ */
458
+ export async function screenshotTool(_root, { url, fullPage, width, height } = {}) {
459
+ const parsed = normalizeHttpUrl(url);
460
+ if (parsed.error) return `Error: ${parsed.error}`;
461
+ const size = {
462
+ width: clampInt(width, 1280, 320, 3840),
463
+ height: clampInt(height, 720, 240, 2160),
464
+ };
465
+
466
+ let shot;
467
+ try {
468
+ shot = await captureScreenshot(parsed.url, { fullPage: Boolean(fullPage), ...size });
469
+ } catch (error) {
470
+ return `Error capturando ${parsed.url}: ${error?.message ?? error}`;
471
+ }
472
+
473
+ let name;
474
+ try {
475
+ const dir = screenshotsDir();
476
+ await mkdir(dir, { recursive: true });
477
+ name = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}.png`;
478
+ await writeFile(path.join(dir, name), shot.buffer);
479
+ } catch (error) {
480
+ return `Error guardando la captura: ${error?.message ?? error}`;
481
+ }
482
+
483
+ const errors = shot.consoleErrors ?? [];
484
+ const head = `Captura de ${parsed.url} → capturas/${name} (${size.width}x${size.height}, ${Math.round(shot.buffer.length / 1024)} KB)`;
485
+ const detail = errors.length
486
+ ? `Errores de consola al cargar (${errors.length}):\n${errors.slice(0, 10).map((e) => `- ${e.text}`).join('\n')}`
487
+ : 'Sin errores de consola al cargar.';
488
+ return {
489
+ content: `${head}\n${detail}`,
490
+ meta: { screenshot: { name, url: parsed.url, width: size.width, height: size.height } },
491
+ };
492
+ }
493
+
494
+ /** Errores y mensajes de consola de una pagina (ejecutando su JS). */
495
+ export async function consoleTool(_root, { url, waitMs } = {}) {
496
+ const parsed = normalizeHttpUrl(url);
497
+ if (parsed.error) return `Error: ${parsed.error}`;
498
+
499
+ let result;
500
+ try {
501
+ result = await readConsole(parsed.url, { waitMs: clampInt(waitMs, 1500, 0, 15000) });
502
+ } catch (error) {
503
+ return `Error leyendo la consola de ${parsed.url}: ${error?.message ?? error}`;
504
+ }
505
+
506
+ const { errors, messages } = result;
507
+ const head = errors.length
508
+ ? `Consola de ${parsed.url}: ${errors.length} error(es), ${messages.length} mensaje(s).`
509
+ : `Consola de ${parsed.url}: sin errores (${messages.length} mensaje(s)).`;
510
+ if (!messages.length) return head;
511
+ const shown = messages.slice(0, 100).map((m) => `[${m.level}] ${m.text}`);
512
+ return [head, ...shown].join('\n');
513
+ }
514
+
422
515
  export const EXECUTORS = {
423
516
  read: readTool,
424
517
  write: writeTool,
@@ -433,4 +526,7 @@ export const EXECUTORS = {
433
526
  git_log: gitLogTool,
434
527
  git_commit: gitCommitTool,
435
528
  speak: speakTool,
529
+ preview_url: previewUrlTool,
530
+ screenshot: screenshotTool,
531
+ console_errors: consoleTool,
436
532
  };
@@ -50,6 +50,10 @@ export function summarizeToolCall(name, args = {}) {
50
50
  }
51
51
  case 'fetch_url':
52
52
  return shorten(a.url, 90);
53
+ case 'screenshot':
54
+ case 'console_errors':
55
+ case 'preview_url':
56
+ return shorten(a.url, 90);
53
57
  case 'speak':
54
58
  return shorten(a.texto, 90);
55
59
  case 'git_diff':
@@ -14,6 +14,9 @@ export const DEFAULT_PERMISSIONS = {
14
14
  git_log: 'allow',
15
15
  fetch_url: 'allow',
16
16
  speak: 'allow',
17
+ screenshot: 'allow',
18
+ console_errors: 'allow',
19
+ preview_url: 'allow',
17
20
  write: 'ask',
18
21
  edit: 'ask',
19
22
  bash: 'ask',
@@ -43,6 +46,7 @@ function subjectFor(name, args) {
43
46
  if (name === 'bash') return args?.command ?? '';
44
47
  if (name === 'git_commit') return args?.message ?? '';
45
48
  if (name === 'fetch_url') return args?.url ?? '';
49
+ if (name === 'screenshot' || name === 'console_errors' || name === 'preview_url') return args?.url ?? '';
46
50
  return args?.path ?? '';
47
51
  }
48
52
 
@@ -150,6 +150,53 @@ export const TOOL_DEFINITIONS = [
150
150
  },
151
151
  },
152
152
  },
153
+ {
154
+ type: 'function',
155
+ function: {
156
+ name: 'screenshot',
157
+ description:
158
+ 'Take a screenshot of a web page with a headless browser (renders JavaScript). Use it to show the user a preview of a page (e.g. a local dev server like http://127.0.0.1:5173) or to inspect it visually. Returns the console errors seen while loading.',
159
+ parameters: {
160
+ type: 'object',
161
+ properties: {
162
+ url: { type: 'string', description: 'Absolute http(s) URL to capture' },
163
+ fullPage: { type: 'boolean', description: 'Capture the full scrollable page (default: viewport)' },
164
+ width: { type: 'integer', description: 'Viewport width in px (default 1280)' },
165
+ height: { type: 'integer', description: 'Viewport height in px (default 720)' },
166
+ },
167
+ required: ['url'],
168
+ },
169
+ },
170
+ },
171
+ {
172
+ type: 'function',
173
+ function: {
174
+ name: 'console_errors',
175
+ description:
176
+ 'Open a web page with a headless browser (executes JavaScript) and return its console messages and errors. Use it to check a page for runtime errors.',
177
+ parameters: {
178
+ type: 'object',
179
+ properties: {
180
+ url: { type: 'string', description: 'Absolute http(s) URL' },
181
+ waitMs: { type: 'integer', description: 'Extra ms to wait after load for async errors (default 1500)' },
182
+ },
183
+ required: ['url'],
184
+ },
185
+ },
186
+ },
187
+ {
188
+ type: 'function',
189
+ function: {
190
+ name: 'preview_url',
191
+ description:
192
+ 'Open a URL in the console preview panel for the user. Use it to show a page or dev server without leaving the chat.',
193
+ parameters: {
194
+ type: 'object',
195
+ properties: { url: { type: 'string', description: 'Absolute http(s) URL to preview' } },
196
+ required: ['url'],
197
+ },
198
+ },
199
+ },
153
200
  {
154
201
  type: 'function',
155
202
  function: {