@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.
Files changed (42) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +51 -16
  3. package/harness/packages/cli/src/commands/doctor.js +14 -0
  4. package/harness/packages/cli/src/commands/update.js +91 -21
  5. package/harness/packages/core/src/agent/modes.js +14 -1
  6. package/harness/packages/core/src/agent/runAgent.js +23 -2
  7. package/harness/packages/core/src/browser/index.js +289 -0
  8. package/harness/packages/core/src/config/home.js +5 -0
  9. package/harness/packages/core/src/index.js +9 -2
  10. package/harness/packages/core/src/tools/executors.js +96 -0
  11. package/harness/packages/core/src/tools/labels.js +4 -0
  12. package/harness/packages/core/src/tools/permissions.js +4 -0
  13. package/harness/packages/core/src/tools/schema.js +47 -0
  14. package/harness/packages/core/src/version.js +81 -7
  15. package/harness/packages/core/test/agent.test.js +30 -1
  16. package/harness/packages/core/test/browser.test.js +18 -0
  17. package/harness/packages/core/test/tools.test.js +16 -1
  18. package/harness/packages/core/test/version.test.js +34 -0
  19. package/harness/packages/server/public/app.js +244 -8
  20. package/harness/packages/server/public/index.html +57 -3
  21. package/harness/packages/server/public/landing/index.html +14 -1
  22. package/harness/packages/server/public/markdown.js +54 -0
  23. package/harness/packages/server/public/reconcile.js +3 -1
  24. package/harness/packages/server/public/styles.css +48 -0
  25. package/harness/packages/server/src/lib/advisor.js +177 -0
  26. package/harness/packages/server/src/lib/agentRun.js +4 -3
  27. package/harness/packages/server/src/lib/channels/telegram.js +130 -25
  28. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  29. package/harness/packages/server/src/lib/update.js +82 -0
  30. package/harness/packages/server/src/routes/advisor.js +76 -0
  31. package/harness/packages/server/src/routes/chat.js +30 -0
  32. package/harness/packages/server/src/routes/files.js +25 -1
  33. package/harness/packages/server/src/routes/tts.js +1 -1
  34. package/harness/packages/server/src/routes/update.js +43 -0
  35. package/harness/packages/server/src/server.js +26 -2
  36. package/harness/packages/server/test/advisor.test.js +205 -0
  37. package/harness/packages/server/test/markdown.test.js +43 -1
  38. package/harness/packages/server/test/server.test.js +50 -14
  39. package/harness/packages/server/test/telegram-features.test.js +25 -4
  40. package/harness/packages/server/test/update.test.js +94 -0
  41. package/harness/scripts/self-update.mjs +123 -0
  42. 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 { readPackageVersion } from './version.js';
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 published package version by walking up from `startUrl` until a
9
- * package.json named `@danieltmn/tamnora` is found. Robust to file depth so
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 readPackageVersion(startUrl = import.meta.url) {
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 && pkg.version) return pkg.version;
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 '0.0.0';
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
+ });