@danieltmn/tamnora 0.9.2 → 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.
Files changed (33) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +30 -10
  3. package/harness/packages/cli/src/tui/app.js +22 -3
  4. package/harness/packages/cli/src/tui/layout.js +59 -2
  5. package/harness/packages/cli/test/tui.test.js +30 -0
  6. package/harness/packages/core/src/agent/modes.js +14 -1
  7. package/harness/packages/core/src/agent/runAgent.js +23 -2
  8. package/harness/packages/core/src/browser/index.js +289 -0
  9. package/harness/packages/core/src/config/home.js +5 -0
  10. package/harness/packages/core/src/index.js +3 -2
  11. package/harness/packages/core/src/providers/presets.js +4 -0
  12. package/harness/packages/core/src/tools/diff.js +70 -1
  13. package/harness/packages/core/src/tools/executors.js +96 -0
  14. package/harness/packages/core/src/tools/labels.js +4 -0
  15. package/harness/packages/core/src/tools/permissions.js +4 -0
  16. package/harness/packages/core/src/tools/schema.js +47 -0
  17. package/harness/packages/core/test/agent.test.js +30 -1
  18. package/harness/packages/core/test/browser.test.js +18 -0
  19. package/harness/packages/core/test/diff.test.js +24 -1
  20. package/harness/packages/core/test/tools.test.js +16 -1
  21. package/harness/packages/server/public/app.js +203 -38
  22. package/harness/packages/server/public/index.html +2 -1
  23. package/harness/packages/server/public/landing/index.html +14 -1
  24. package/harness/packages/server/public/reconcile.js +3 -1
  25. package/harness/packages/server/public/styles.css +98 -12
  26. package/harness/packages/server/src/lib/agentRun.js +2 -2
  27. package/harness/packages/server/src/lib/channels/telegram.js +129 -44
  28. package/harness/packages/server/src/routes/files.js +25 -1
  29. package/harness/packages/server/src/routes/sessions.js +55 -33
  30. package/harness/packages/server/test/server.test.js +84 -14
  31. package/harness/packages/server/test/telegram-features.test.js +25 -4
  32. package/harness/packages/server/test/telegram-voz.test.js +3 -5
  33. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,19 @@ 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
+
14
+ ## [0.9.3] - 2026-10-07
15
+
16
+ ### Cambios
17
+
18
+ - feat(telegram): audio solo cuando el usuario lo pide
19
+ - feat: diff lado a lado en TUI, proveedores Anthropic/Gemini y mejoras de consola
20
+
8
21
  ## [0.9.2] - 2026-10-07
9
22
 
10
23
  ### 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
 
@@ -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 };
@@ -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,9 +10,9 @@ 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
- 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';
@@ -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,
@@ -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'),