@danieltmn/tamnora 0.9.3 → 0.9.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +51 -16
- package/harness/packages/cli/src/commands/doctor.js +14 -0
- package/harness/packages/cli/src/commands/update.js +91 -21
- 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 +9 -2
- 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/src/version.js +81 -7
- 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/core/test/version.test.js +34 -0
- package/harness/packages/server/public/app.js +244 -8
- package/harness/packages/server/public/index.html +57 -3
- package/harness/packages/server/public/landing/index.html +14 -1
- package/harness/packages/server/public/markdown.js +54 -0
- package/harness/packages/server/public/reconcile.js +3 -1
- package/harness/packages/server/public/styles.css +48 -0
- package/harness/packages/server/src/lib/advisor.js +177 -0
- package/harness/packages/server/src/lib/agentRun.js +4 -3
- package/harness/packages/server/src/lib/channels/telegram.js +130 -25
- package/harness/packages/server/src/lib/ttsSettings.js +2 -2
- package/harness/packages/server/src/lib/update.js +82 -0
- package/harness/packages/server/src/routes/advisor.js +76 -0
- package/harness/packages/server/src/routes/chat.js +30 -0
- package/harness/packages/server/src/routes/files.js +25 -1
- package/harness/packages/server/src/routes/tts.js +1 -1
- package/harness/packages/server/src/routes/update.js +43 -0
- package/harness/packages/server/src/server.js +26 -2
- package/harness/packages/server/test/advisor.test.js +205 -0
- package/harness/packages/server/test/markdown.test.js +43 -1
- package/harness/packages/server/test/server.test.js +50 -14
- package/harness/packages/server/test/telegram-features.test.js +25 -4
- package/harness/packages/server/test/update.test.js +94 -0
- package/harness/scripts/self-update.mjs +123 -0
- package/package.json +1 -1
|
@@ -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,
|
|
@@ -110,4 +111,10 @@ export { pidAlive, killPidTree } from './pid.js';
|
|
|
110
111
|
export { openBrowser, openerFor } from './open.js';
|
|
111
112
|
export { detectProcs, packageManager, DEFAULT_PROCESS_ALLOW, staticServerChip } from './procs/detect.js';
|
|
112
113
|
export { killPort } from './procs/kill.js';
|
|
113
|
-
export {
|
|
114
|
+
export {
|
|
115
|
+
ROOT_PACKAGE_NAME,
|
|
116
|
+
readPackageVersion,
|
|
117
|
+
packageRoot,
|
|
118
|
+
compareVersions,
|
|
119
|
+
fetchLatestVersion,
|
|
120
|
+
} from './version.js';
|
|
@@ -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: {
|
|
@@ -2,19 +2,20 @@ import { readFileSync } from 'node:fs';
|
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
|
|
5
|
-
const ROOT_PACKAGE_NAME = '@danieltmn/tamnora';
|
|
5
|
+
export const ROOT_PACKAGE_NAME = '@danieltmn/tamnora';
|
|
6
|
+
|
|
7
|
+
const DEFAULT_REGISTRY = 'https://registry.npmjs.org';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
|
-
* Resolve the
|
|
9
|
-
*
|
|
10
|
-
* callers don't have to count `../` levels.
|
|
10
|
+
* Resolve the harness package dir (where its package.json lives) by walking up
|
|
11
|
+
* from `startUrl`. Robust to file depth so callers don't count `../`.
|
|
11
12
|
*/
|
|
12
|
-
export function
|
|
13
|
+
export function packageRoot(startUrl = import.meta.url) {
|
|
13
14
|
let dir = path.dirname(fileURLToPath(startUrl));
|
|
14
15
|
for (let i = 0; i < 10; i++) {
|
|
15
16
|
try {
|
|
16
17
|
const pkg = JSON.parse(readFileSync(path.join(dir, 'package.json'), 'utf8'));
|
|
17
|
-
if (pkg.name === ROOT_PACKAGE_NAME
|
|
18
|
+
if (pkg.name === ROOT_PACKAGE_NAME) return dir;
|
|
18
19
|
} catch {
|
|
19
20
|
/* no hay package.json acá: seguir subiendo */
|
|
20
21
|
}
|
|
@@ -22,5 +23,78 @@ export function readPackageVersion(startUrl = import.meta.url) {
|
|
|
22
23
|
if (parent === dir) break;
|
|
23
24
|
dir = parent;
|
|
24
25
|
}
|
|
25
|
-
return
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Version publicada del harness (`0.0.0` si no se puede leer). */
|
|
30
|
+
export function readPackageVersion(startUrl = import.meta.url) {
|
|
31
|
+
const root = packageRoot(startUrl);
|
|
32
|
+
if (!root) return '0.0.0';
|
|
33
|
+
try {
|
|
34
|
+
const pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
35
|
+
return pkg.version || '0.0.0';
|
|
36
|
+
} catch {
|
|
37
|
+
return '0.0.0';
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function parseVersion(value) {
|
|
42
|
+
const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(
|
|
43
|
+
String(value ?? '').trim()
|
|
44
|
+
);
|
|
45
|
+
if (!match) return null;
|
|
46
|
+
return { nums: [Number(match[1]), Number(match[2]), Number(match[3])], pre: match[4] ?? '' };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Semver minimo: devuelve -1 / 0 / 1. Una version sin prerelease ordena por
|
|
51
|
+
* encima de una con prerelease (1.0.0 > 1.0.0-beta). Si alguna no se entiende,
|
|
52
|
+
* las trata como iguales (no queremos gritar "actualizacion disponible" por un
|
|
53
|
+
* formato raro).
|
|
54
|
+
*/
|
|
55
|
+
export function compareVersions(a, b) {
|
|
56
|
+
const va = parseVersion(a);
|
|
57
|
+
const vb = parseVersion(b);
|
|
58
|
+
if (!va || !vb) return 0;
|
|
59
|
+
for (let i = 0; i < 3; i++) {
|
|
60
|
+
if (va.nums[i] > vb.nums[i]) return 1;
|
|
61
|
+
if (va.nums[i] < vb.nums[i]) return -1;
|
|
62
|
+
}
|
|
63
|
+
if (va.pre === vb.pre) return 0;
|
|
64
|
+
if (!va.pre) return 1;
|
|
65
|
+
if (!vb.pre) return -1;
|
|
66
|
+
return va.pre < vb.pre ? -1 : 1;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Ultima version publicada en el registro npm, sin depender de `npm` en el
|
|
71
|
+
* PATH. `fetchImpl` es inyectable para tests.
|
|
72
|
+
*/
|
|
73
|
+
export async function fetchLatestVersion({
|
|
74
|
+
pkg = ROOT_PACKAGE_NAME,
|
|
75
|
+
tag = 'latest',
|
|
76
|
+
registry = DEFAULT_REGISTRY,
|
|
77
|
+
fetchImpl = globalThis.fetch,
|
|
78
|
+
timeoutMs = 8000,
|
|
79
|
+
} = {}) {
|
|
80
|
+
if (typeof fetchImpl !== 'function') throw new Error('fetch no disponible');
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
83
|
+
timer.unref?.();
|
|
84
|
+
try {
|
|
85
|
+
const res = await fetchImpl(`${registry}/${pkg}`, {
|
|
86
|
+
headers: { accept: 'application/json' },
|
|
87
|
+
signal: controller.signal,
|
|
88
|
+
});
|
|
89
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
90
|
+
const data = await res.json();
|
|
91
|
+
const distTags = data?.['dist-tags'] ?? {};
|
|
92
|
+
const version = distTags[tag] || distTags.latest;
|
|
93
|
+
if (!version || !/^\d+\.\d+\.\d+/.test(String(version))) {
|
|
94
|
+
throw new Error('respuesta sin version valida');
|
|
95
|
+
}
|
|
96
|
+
return String(version);
|
|
97
|
+
} finally {
|
|
98
|
+
clearTimeout(timer);
|
|
99
|
+
}
|
|
26
100
|
}
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtemp, readFile } from 'node:fs/promises';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import path from 'node:path';
|
|
6
|
-
import { runAgent, parseToolArguments } from '../src/agent/runAgent.js';
|
|
6
|
+
import { runAgent, parseToolArguments, normalizeToolOutput } from '../src/agent/runAgent.js';
|
|
7
7
|
import { createTools } from '../src/tools/index.js';
|
|
8
8
|
|
|
9
9
|
test('agent executes tool calls then returns the final answer', async () => {
|
|
@@ -328,3 +328,32 @@ test('agent respeta el presupuesto de tokens y cierra con resumen', async () =>
|
|
|
328
328
|
assert.equal(result.stopReason, 'budget');
|
|
329
329
|
assert.equal(result.content, 'cierre');
|
|
330
330
|
});
|
|
331
|
+
|
|
332
|
+
test('normalizeToolOutput acepta string y { content, meta }', () => {
|
|
333
|
+
assert.deepEqual(normalizeToolOutput('hola'), { content: 'hola' });
|
|
334
|
+
assert.deepEqual(normalizeToolOutput({ content: 'ok', meta: { a: 1 } }), { content: 'ok', meta: { a: 1 } });
|
|
335
|
+
assert.deepEqual(normalizeToolOutput({ x: 1 }), { content: '{"x":1}' });
|
|
336
|
+
assert.deepEqual(normalizeToolOutput(undefined), { content: '' });
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('tool_result propaga el meta del executor sin ensuciar el contenido', async () => {
|
|
340
|
+
const events = [];
|
|
341
|
+
const provider = {
|
|
342
|
+
async streamChat(_config, { messages }) {
|
|
343
|
+
if (messages.some((m) => m.role === 'tool')) return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
344
|
+
return { content: '', reasoning: '', toolCalls: [{ id: 'c1', function: { name: 'shot', arguments: '{}' } }] };
|
|
345
|
+
},
|
|
346
|
+
};
|
|
347
|
+
await runAgent({
|
|
348
|
+
provider,
|
|
349
|
+
config: {},
|
|
350
|
+
model: 'm',
|
|
351
|
+
messages: [{ role: 'user', content: 'x' }],
|
|
352
|
+
tools: [{ type: 'function', function: { name: 'shot', parameters: {} } }],
|
|
353
|
+
execute: async () => ({ content: 'captura', meta: { screenshot: { name: 'a.png' } } }),
|
|
354
|
+
onEvent: (e) => events.push(e),
|
|
355
|
+
});
|
|
356
|
+
const result = events.find((e) => e.type === 'tool_result');
|
|
357
|
+
assert.equal(result.result, 'captura');
|
|
358
|
+
assert.deepEqual(result.meta, { screenshot: { name: 'a.png' } });
|
|
359
|
+
});
|