@danieltmn/tamnora 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +30 -10
  3. package/harness/packages/cli/src/tui/app.js +22 -3
  4. package/harness/packages/cli/src/tui/layout.js +59 -2
  5. package/harness/packages/cli/test/tui.test.js +30 -0
  6. package/harness/packages/core/src/agent/modes.js +14 -1
  7. package/harness/packages/core/src/agent/runAgent.js +23 -2
  8. package/harness/packages/core/src/browser/index.js +289 -0
  9. package/harness/packages/core/src/config/home.js +5 -0
  10. package/harness/packages/core/src/index.js +3 -2
  11. package/harness/packages/core/src/providers/presets.js +4 -0
  12. package/harness/packages/core/src/tools/diff.js +70 -1
  13. package/harness/packages/core/src/tools/executors.js +96 -0
  14. package/harness/packages/core/src/tools/labels.js +4 -0
  15. package/harness/packages/core/src/tools/permissions.js +4 -0
  16. package/harness/packages/core/src/tools/schema.js +47 -0
  17. package/harness/packages/core/test/agent.test.js +30 -1
  18. package/harness/packages/core/test/browser.test.js +18 -0
  19. package/harness/packages/core/test/diff.test.js +24 -1
  20. package/harness/packages/core/test/tools.test.js +16 -1
  21. package/harness/packages/server/public/app.js +203 -38
  22. package/harness/packages/server/public/index.html +2 -1
  23. package/harness/packages/server/public/landing/index.html +14 -1
  24. package/harness/packages/server/public/reconcile.js +3 -1
  25. package/harness/packages/server/public/styles.css +98 -12
  26. package/harness/packages/server/src/lib/agentRun.js +2 -2
  27. package/harness/packages/server/src/lib/channels/telegram.js +129 -44
  28. package/harness/packages/server/src/routes/files.js +25 -1
  29. package/harness/packages/server/src/routes/sessions.js +55 -33
  30. package/harness/packages/server/test/server.test.js +84 -14
  31. package/harness/packages/server/test/telegram-features.test.js +25 -4
  32. package/harness/packages/server/test/telegram-voz.test.js +3 -5
  33. package/package.json +1 -1
@@ -7,7 +7,9 @@ const MAX_CELLS = 1_500_000;
7
7
 
8
8
  function splitLines(text) {
9
9
  if (text === '' || text == null) return [];
10
- return String(text).replace(/\r\n/g, '\n').split('\n');
10
+ const lines = String(text).replace(/\r\n/g, '\n').split('\n');
11
+ if (lines.length > 1 && lines[lines.length - 1] === '') lines.pop();
12
+ return lines;
11
13
  }
12
14
 
13
15
  function computeOps(a, b) {
@@ -88,3 +90,70 @@ export function diffLines(oldText, newText, { context = 3, maxLines = 200 } = {}
88
90
  if (truncated) out.push(' … (diff truncado)');
89
91
  return out.join('\n');
90
92
  }
93
+
94
+ /**
95
+ * Diff estructurado para una vista lado a lado (dos columnas): devuelve filas
96
+ * con la linea vieja (izquierda) y la nueva (derecha) ya numeradas. Las
97
+ * supresiones y adiciones de un mismo bloque se emparejan en la misma fila.
98
+ *
99
+ * @returns {Array<{type:'ctx'|'mod'|'del'|'add'|'skip', left:({no:number,text:string}|null), right:({no:number,text:string}|null)}>}
100
+ * [] si no hay cambios.
101
+ */
102
+ export function diffRows(oldText, newText, { context = 3, maxRows = 400 } = {}) {
103
+ const ops = computeOps(splitLines(oldText), splitLines(newText));
104
+ const changes = ops.map((o, idx) => (o.t === 'eq' ? -1 : idx)).filter((idx) => idx >= 0);
105
+ if (!changes.length) return [];
106
+
107
+ const keep = new Array(ops.length).fill(false);
108
+ for (const idx of changes) {
109
+ for (let j = Math.max(0, idx - context); j <= Math.min(ops.length - 1, idx + context); j++) keep[j] = true;
110
+ }
111
+
112
+ const rows = [];
113
+ let oldNo = 1;
114
+ let newNo = 1;
115
+ let pending = [];
116
+ let skipped = false;
117
+
118
+ const flush = () => {
119
+ const dels = pending.filter((p) => p.t === 'del');
120
+ const adds = pending.filter((p) => p.t === 'add');
121
+ const total = Math.max(dels.length, adds.length);
122
+ for (let i = 0; i < total; i++) {
123
+ const d = dels[i] ?? null;
124
+ const a = adds[i] ?? null;
125
+ rows.push({
126
+ type: d && a ? 'mod' : d ? 'del' : 'add',
127
+ left: d ? { no: d.no, text: d.line } : null,
128
+ right: a ? { no: a.no, text: a.line } : null,
129
+ });
130
+ }
131
+ pending = [];
132
+ };
133
+
134
+ for (let idx = 0; idx < ops.length; idx++) {
135
+ const op = ops[idx];
136
+ if (op.t === 'eq') {
137
+ flush();
138
+ if (keep[idx]) {
139
+ rows.push({ type: 'ctx', left: { no: oldNo, text: op.line }, right: { no: newNo, text: op.line } });
140
+ skipped = false;
141
+ } else if (!skipped) {
142
+ rows.push({ type: 'skip', left: null, right: null });
143
+ skipped = true;
144
+ }
145
+ oldNo++;
146
+ newNo++;
147
+ } else if (op.t === 'del') {
148
+ pending.push({ t: 'del', line: op.line, no: oldNo++ });
149
+ } else {
150
+ pending.push({ t: 'add', line: op.line, no: newNo++ });
151
+ }
152
+ }
153
+ flush();
154
+
155
+ if (rows.length > maxRows) {
156
+ return rows.slice(0, maxRows).concat({ type: 'skip', left: null, right: null });
157
+ }
158
+ return rows;
159
+ }
@@ -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: {
@@ -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
+ });
@@ -0,0 +1,18 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { existsSync } from 'node:fs';
5
+ import { findBrowserPath } from '../src/browser/index.js';
6
+
7
+ test('findBrowserPath respeta TAMNORA_BROWSER cuando el archivo existe', () => {
8
+ const self = fileURLToPath(import.meta.url);
9
+ assert.ok(existsSync(self));
10
+ const previous = process.env.TAMNORA_BROWSER;
11
+ process.env.TAMNORA_BROWSER = self;
12
+ try {
13
+ assert.equal(findBrowserPath(), self);
14
+ } finally {
15
+ if (previous === undefined) delete process.env.TAMNORA_BROWSER;
16
+ else process.env.TAMNORA_BROWSER = previous;
17
+ }
18
+ });
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { diffLines } from '../src/index.js';
3
+ import { diffLines, diffRows } from '../src/index.js';
4
4
 
5
5
  test('diffLines reports added and removed lines with context', () => {
6
6
  const diff = diffLines('a\nb\nc\n', 'a\nB\nc\n');
@@ -18,3 +18,26 @@ test('diffLines marks a new file as additions', () => {
18
18
  assert.match(diff, /^\+ l1$/m);
19
19
  assert.match(diff, /^\+ l2$/m);
20
20
  });
21
+
22
+ test('diffRows pairs a removed and an added line side by side', () => {
23
+ const rows = diffRows('a\nb\nc\n', 'a\nB\nc\n');
24
+ const mod = rows.find((r) => r.type === 'mod');
25
+ assert.equal(mod.left.text, 'b');
26
+ assert.equal(mod.left.no, 2);
27
+ assert.equal(mod.right.text, 'B');
28
+ assert.equal(mod.right.no, 2);
29
+ assert.ok(rows.some((r) => r.type === 'ctx' && r.left.no === 1 && r.right.no === 1));
30
+ });
31
+
32
+ test('diffRows numbers additions for a new file', () => {
33
+ const rows = diffRows('', 'l1\nl2\n');
34
+ assert.equal(rows.length, 2);
35
+ assert.equal(rows[0].type, 'add');
36
+ assert.equal(rows[0].left, null);
37
+ assert.equal(rows[0].right.text, 'l1');
38
+ assert.equal(rows[1].right.no, 2);
39
+ });
40
+
41
+ test('diffRows returns an empty list when there are no changes', () => {
42
+ assert.deepEqual(diffRows('x\ny\n', 'x\ny\n'), []);
43
+ });
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { resolveInRoot, toRel } from '../src/tools/paths.js';
7
7
  import { evaluatePermission } from '../src/tools/permissions.js';
8
8
  import { createTools, createToolRegistry } from '../src/tools/index.js';
9
- import { fetchUrlTool } from '../src/tools/executors.js';
9
+ import { fetchUrlTool, previewUrlTool, screenshotTool, consoleTool } from '../src/tools/executors.js';
10
10
 
11
11
  async function makeWorkspace() {
12
12
  const root = await mkdtemp(path.join(tmpdir(), 'tamnora-ws-'));
@@ -169,3 +169,18 @@ test('la tool speak valida el texto y la recorta', async () => {
169
169
  const { definitions } = createTools({ root: await makeWorkspace() });
170
170
  assert.ok(definitions.some((d) => d.function.name === 'speak'));
171
171
  });
172
+
173
+ test('screenshot, console_errors y preview_url validan la URL sin lanzar el navegador', async () => {
174
+ assert.match(previewUrlTool(null, { url: 'https://example.com/x' }), /example\.com/);
175
+ assert.match(previewUrlTool(null, {}), /falta "url"/);
176
+ assert.match(await screenshotTool(null, { url: 'ftp://x' }), /Solo se admite http/);
177
+ assert.match(await screenshotTool(null, {}), /falta "url"/);
178
+ assert.match(await consoleTool(null, { url: 'no-url' }), /URL inválida/);
179
+ assert.equal(evaluatePermission('screenshot', {}, {}), 'allow');
180
+ assert.equal(evaluatePermission('console_errors', {}, {}), 'allow');
181
+ assert.equal(evaluatePermission('preview_url', {}, {}), 'allow');
182
+ const { definitions } = createTools({ root: await makeWorkspace() });
183
+ for (const name of ['screenshot', 'console_errors', 'preview_url']) {
184
+ assert.ok(definitions.some((d) => d.function.name === name), `falta ${name}`);
185
+ }
186
+ });