@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 +6 -0
- package/README.md +30 -10
- package/harness/packages/core/src/agent/modes.js +14 -1
- package/harness/packages/core/src/agent/runAgent.js +23 -2
- package/harness/packages/core/src/browser/index.js +289 -0
- package/harness/packages/core/src/config/home.js +5 -0
- package/harness/packages/core/src/index.js +2 -1
- package/harness/packages/core/src/tools/executors.js +96 -0
- package/harness/packages/core/src/tools/labels.js +4 -0
- package/harness/packages/core/src/tools/permissions.js +4 -0
- package/harness/packages/core/src/tools/schema.js +47 -0
- package/harness/packages/core/test/agent.test.js +30 -1
- package/harness/packages/core/test/browser.test.js +18 -0
- package/harness/packages/core/test/tools.test.js +16 -1
- package/harness/packages/server/public/app.js +29 -0
- package/harness/packages/server/public/index.html +1 -1
- package/harness/packages/server/public/landing/index.html +14 -1
- package/harness/packages/server/public/reconcile.js +3 -1
- package/harness/packages/server/public/styles.css +11 -0
- package/harness/packages/server/src/lib/agentRun.js +2 -2
- package/harness/packages/server/src/lib/channels/telegram.js +130 -25
- package/harness/packages/server/src/routes/files.js +25 -1
- package/harness/packages/server/test/server.test.js +50 -14
- package/harness/packages/server/test/telegram-features.test.js +25 -4
- package/package.json +1 -1
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,
|
|
9
|
-
Groq, Mistral, xAI, Together, Fireworks,
|
|
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
|
|
140
|
-
|
|
141
|
-
-
|
|
142
|
-
-
|
|
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`)
|
|
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
|
|
168
|
-
|
|
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: [
|
|
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
|
-
|
|
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:
|
|
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: {
|