@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.
- package/CHANGELOG.md +13 -0
- package/README.md +30 -10
- package/harness/packages/cli/src/tui/app.js +22 -3
- package/harness/packages/cli/src/tui/layout.js +59 -2
- package/harness/packages/cli/test/tui.test.js +30 -0
- 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 +3 -2
- package/harness/packages/core/src/providers/presets.js +4 -0
- package/harness/packages/core/src/tools/diff.js +70 -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/diff.test.js +24 -1
- package/harness/packages/core/test/tools.test.js +16 -1
- package/harness/packages/server/public/app.js +203 -38
- package/harness/packages/server/public/index.html +2 -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 +98 -12
- package/harness/packages/server/src/lib/agentRun.js +2 -2
- package/harness/packages/server/src/lib/channels/telegram.js +129 -44
- package/harness/packages/server/src/routes/files.js +25 -1
- package/harness/packages/server/src/routes/sessions.js +55 -33
- package/harness/packages/server/test/server.test.js +84 -14
- package/harness/packages/server/test/telegram-features.test.js +25 -4
- package/harness/packages/server/test/telegram-voz.test.js +3 -5
- 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,
|
|
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
|
|
|
@@ -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 {
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
125
|
-
parts.push(
|
|
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: [
|
|
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,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'),
|