@danieltmn/tamnora 0.9.3 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ });
@@ -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
+ });
@@ -1158,6 +1158,10 @@ function toolSummary(name, args) {
1158
1158
  return shortText(a.url, 90);
1159
1159
  case 'speak':
1160
1160
  return shortText(a.texto, 90);
1161
+ case 'screenshot':
1162
+ case 'console_errors':
1163
+ case 'preview_url':
1164
+ return shortText(a.url, 90);
1161
1165
  case 'git_diff':
1162
1166
  return [a.ref ? `vs ${shortText(a.ref, 30)}` : a.staged ? 'staged' : 'unstaged', a.path ? shortText(a.path, 40) : '']
1163
1167
  .filter(Boolean)
@@ -1189,6 +1193,26 @@ function isFailedToolText(text) {
1189
1193
  return /^Error|^Permiso denegado/i.test(String(text ?? ''));
1190
1194
  }
1191
1195
 
1196
+ /** Nombre de archivo de una captura, del meta o del texto del resultado. */
1197
+ function screenshotName(data, text) {
1198
+ const fromMeta = data?.meta?.screenshot?.name;
1199
+ if (fromMeta) return String(fromMeta);
1200
+ const match = /capturas\/([a-z0-9][a-z0-9-]*\.png)/i.exec(String(text ?? ''));
1201
+ return match ? match[1] : null;
1202
+ }
1203
+
1204
+ /** Muestra la captura del tool `screenshot` en su tarjeta (clic para ampliar). */
1205
+ function showScreenshot(entry, name) {
1206
+ if (!entry || !name || entry.shot) return;
1207
+ entry.shot = true;
1208
+ const img = el('img', 'tool-shot');
1209
+ img.loading = 'lazy';
1210
+ img.alt = 'captura de pantalla';
1211
+ img.src = `/api/screenshots/${encodeURIComponent(name)}`;
1212
+ img.addEventListener('click', () => window.open(img.src, '_blank'));
1213
+ entry.card.appendChild(img);
1214
+ }
1215
+
1192
1216
  /** Cierra la tarjeta de un tool-call (evento tool_result o mensaje persistido). */
1193
1217
  function finishToolCard(entry, data) {
1194
1218
  if (!entry) return;
@@ -1203,6 +1227,7 @@ function finishToolCard(entry, data) {
1203
1227
  entry.truncated = true;
1204
1228
  entry.more.hidden = false;
1205
1229
  }
1230
+ if (!failed) showScreenshot(entry, screenshotName(data, preview));
1206
1231
  }
1207
1232
 
1208
1233
  function previewText(value, maxLines = 24, maxChars = 4000) {
@@ -2026,6 +2051,10 @@ function handleChatEvent(event, data, turn) {
2026
2051
  const texto = String(data.args?.texto ?? '').trim();
2027
2052
  if (texto) reproducirVoz(texto, { voice: data.args?.voice, rate: data.args?.rate, label: 'voz' }).catch(() => {});
2028
2053
  }
2054
+ if (turn.live && data.name === 'preview_url' && data.args?.url) {
2055
+ setExplorerMode('preview');
2056
+ loadPreview(data.args.url);
2057
+ }
2029
2058
  } else if (event === 'tool_result') {
2030
2059
  setActivity(turn.assistant(), 'thinking');
2031
2060
  finishToolCard(turn.lastTool, data);
@@ -34,7 +34,7 @@
34
34
  </head>
35
35
  <body>
36
36
  <header class="topbar">
37
- <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.3</span></div>
37
+ <div class="brand"><span class="logo"><span class="logo-t">T</span><span class="logo-h">H</span></span> <span class="ver">v0.9.4</span></div>
38
38
  <nav class="tabs">
39
39
  <button class="tab active" data-tab="chat">chat</button>
40
40
  <button class="tab" data-tab="providers">proveedores</button>
@@ -451,7 +451,8 @@
451
451
 
452
452
  <p class="hero-copy">
453
453
  Un harness local para orquestar agentes de IA, ejecutar tools en tu workspace y mantener el
454
- control desde tu máquina o el celular (túnel + QR o Telegram).
454
+ control desde tu máquina o el celular (túnel + QR o Telegram). Renderiza páginas con un
455
+ navegador headless, narra su progreso y hace trazable cada corrida.
455
456
  </p>
456
457
 
457
458
  <div class="actions">
@@ -472,6 +473,18 @@
472
473
  <div class="feature-icon">✓</div>
473
474
  <div><b>Control</b><span>Permisos allow/ask/deny y runs trazables.</span></div>
474
475
  </div>
476
+ <div class="feature">
477
+ <div class="feature-icon">◎</div>
478
+ <div><b>Navegador</b><span>Screenshot y errores de consola de una página.</span></div>
479
+ </div>
480
+ <div class="feature">
481
+ <div class="feature-icon">♪</div>
482
+ <div><b>Voz</b><span>Narra el progreso y manda audio bajo pedido.</span></div>
483
+ </div>
484
+ <div class="feature">
485
+ <div class="feature-icon">⌘</div>
486
+ <div><b>Multi-modelo</b><span>OpenRouter, OpenAI, Claude, Gemini y Ollama.</span></div>
487
+ </div>
475
488
  </div>
476
489
  </section>
477
490
 
@@ -110,7 +110,9 @@ export function summarizeArgs(name, args, max = 240) {
110
110
  if ((name === 'write' || name === 'edit' || name === 'read') && a.path) {
111
111
  return a.content ? `${a.path} · ${humanSize(a.content)}` : String(a.path);
112
112
  }
113
- if ((name === 'fetch_url' || name === 'preview_url') && a.url) return cut(a.url);
113
+ if ((name === 'fetch_url' || name === 'preview_url' || name === 'screenshot' || name === 'console_errors') && a.url) {
114
+ return cut(a.url);
115
+ }
114
116
  if (name === 'task' && (a.description || a.prompt)) return cut(a.description || a.prompt);
115
117
  const keys = Object.keys(a);
116
118
  if (keys.length === 1 && typeof a[keys[0]] === 'string') return cut(a[keys[0]]);
@@ -627,6 +627,17 @@ body.resizing * { cursor: col-resize !important; }
627
627
  font: 600 12px var(--sans);
628
628
  }
629
629
  .tool-card > .tool-audio-btn:hover { background: var(--theirs); }
630
+ .tool-card > .tool-shot {
631
+ display: block;
632
+ max-width: calc(100% - 28px);
633
+ max-height: 420px;
634
+ width: auto;
635
+ margin: 6px 0 4px 18px;
636
+ border: 1px solid var(--border);
637
+ border-radius: 8px;
638
+ cursor: zoom-in;
639
+ background: #fff;
640
+ }
630
641
 
631
642
  /* Reproductor único de audio (narración, speak, resumen): visible y detenible. */
632
643
  .audio-player {
@@ -6,10 +6,10 @@ import { summarizeToolCall, previewToolResult } from './core.js';
6
6
 
7
7
  const FAILED_RE = /^Error|^Permiso denegado/i;
8
8
 
9
- /** Payload estandar de un tool_result para eventos SSE: preview + failed. */
9
+ /** Payload estandar de un tool_result para eventos SSE: preview + failed (+ meta). */
10
10
  export function toolResultPayload(event) {
11
11
  const { preview, truncated } = previewToolResult(event.name, event.result);
12
- return { preview, truncated, failed: FAILED_RE.test(String(event.result)) };
12
+ return { preview, truncated, failed: FAILED_RE.test(String(event.result)), meta: event.meta };
13
13
  }
14
14
 
15
15
  export function toolCallPayload(event) {
@@ -4,8 +4,18 @@
4
4
  // proyecto (una carpeta de la raiz configurada en la consola).
5
5
 
6
6
  import path from 'node:path';
7
+ import { readFile } from 'node:fs/promises';
7
8
  import { randomUUID } from 'node:crypto';
8
- import { runAgent, createTools, resolveMode, listProviderModels, PROVIDER_META, sintetizar, MAX_TEXTO } from '../core.js';
9
+ import {
10
+ runAgent,
11
+ createTools,
12
+ resolveMode,
13
+ listProviderModels,
14
+ PROVIDER_META,
15
+ sintetizar,
16
+ MAX_TEXTO,
17
+ screenshotsDir,
18
+ } from '../core.js';
9
19
  import { resolveExistingDir, listSubdirs } from '../workspace.js';
10
20
  import {
11
21
  combineToolset,
@@ -42,6 +52,72 @@ const HELP = [
42
52
  'Podés mandar texto, imágenes o notas de voz.',
43
53
  ].join('\n');
44
54
 
55
+ function humanSize(bytes) {
56
+ const n = Number(bytes) || 0;
57
+ if (n < 1024) return `${n} B`;
58
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
59
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
60
+ }
61
+
62
+ function oneLine(value, max = 400) {
63
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
64
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
65
+ }
66
+
67
+ /**
68
+ * Explica en una frase qué va a hacer el agente si se aprueba el permiso, en
69
+ * vez de volcar el JSON crudo (que para `write` mostraba medio archivo).
70
+ */
71
+ export function describePermission(name, args = {}) {
72
+ const a = args && typeof args === 'object' ? args : {};
73
+ switch (name) {
74
+ case 'bash':
75
+ return { action: 'ejecutar un comando en el proyecto', detail: oneLine(a.command, 700) };
76
+ case 'write':
77
+ return {
78
+ action: `escribir el archivo ${a.path ?? '(sin ruta)'}`,
79
+ detail: a.content ? `Contenido: ${humanSize(Buffer.byteLength(String(a.content)))}` : '',
80
+ };
81
+ case 'edit':
82
+ return {
83
+ action: `editar el archivo ${a.path ?? '(sin ruta)'}`,
84
+ detail: a.oldString ? `Reemplaza: "${oneLine(a.oldString, 100)}"` : '',
85
+ };
86
+ case 'git_commit':
87
+ return { action: 'hacer un commit', detail: oneLine(a.message, 200) };
88
+ case 'screenshot':
89
+ return { action: 'sacar una captura de la página', detail: oneLine(a.url, 200) };
90
+ case 'console_errors':
91
+ return { action: 'revisar la consola de una página', detail: oneLine(a.url, 200) };
92
+ case 'fetch_url':
93
+ return { action: 'consultar una URL', detail: oneLine(a.url, 200) };
94
+ case 'read':
95
+ return { action: `leer ${oneLine(a.path, 120) || 'un archivo'}`, detail: '' };
96
+ default:
97
+ return { action: `usar la herramienta "${name}"`, detail: oneLine(JSON.stringify(a), 200) };
98
+ }
99
+ }
100
+
101
+ function permissionMessage(project, name, args) {
102
+ const { action, detail } = describePermission(name, args);
103
+ const lines = [`⚠ [${project}] Permiso: el agente quiere ${action}.`];
104
+ if (detail) lines.push('', detail);
105
+ lines.push('', '¿Qué hacemos?');
106
+ return lines.join('\n');
107
+ }
108
+
109
+ function permissionKeyboard(askId) {
110
+ return {
111
+ inline_keyboard: [
112
+ [
113
+ { text: 'Permitir', callback_data: `perm:${askId}:allow` },
114
+ { text: 'Permitir todo', callback_data: `perm:${askId}:all` },
115
+ ],
116
+ [{ text: 'Denegar', callback_data: `perm:${askId}:deny` }],
117
+ ],
118
+ };
119
+ }
120
+
45
121
  async function tg(token, method, payload) {
46
122
  const res = await fetch(`${API}/bot${token}/${method}`, {
47
123
  method: 'POST',
@@ -144,6 +220,7 @@ export function startTelegramChannel(
144
220
  const modelChoices = new Map();
145
221
  const projectChoices = new Map();
146
222
  const pendingInputs = new Map();
223
+ const askSessions = new Map(); // askId -> sessionId, para "Permitir todo"
147
224
  let running = true;
148
225
  let offset = 0;
149
226
 
@@ -194,6 +271,32 @@ export function startTelegramChannel(
194
271
  }
195
272
  }
196
273
 
274
+ /** Manda una imagen (una captura del tool `screenshot`). */
275
+ async function enviarFoto(chatId, buffer, caption) {
276
+ try {
277
+ const form = new FormData();
278
+ form.append('chat_id', chatId);
279
+ if (caption) form.append('caption', String(caption).slice(0, 1000));
280
+ form.append('photo', new Blob([buffer], { type: 'image/png' }), 'captura.png');
281
+ await tgForm(token, 'sendPhoto', form);
282
+ } catch (error) {
283
+ log(`telegram: foto falló: ${error?.message ?? error}`);
284
+ }
285
+ }
286
+
287
+ /** Entrega una captura guardada en la casa (por nombre, sin path traversal). */
288
+ async function enviarCaptura(chatId, name, caption) {
289
+ const safe = String(name ?? '');
290
+ if (!/^[a-z0-9][a-z0-9-]*\.png$/i.test(safe)) return;
291
+ let buffer;
292
+ try {
293
+ buffer = await readFile(path.join(screenshotsDir(), safe));
294
+ } catch {
295
+ return;
296
+ }
297
+ await enviarFoto(chatId, buffer, caption);
298
+ }
299
+
197
300
  // --- proyectos ------------------------------------------------------------
198
301
 
199
302
  /** Carpeta raíz para elegir proyecto: la predeterminada de la consola o el cwd. */
@@ -351,19 +454,15 @@ export function startTelegramChannel(
351
454
  }
352
455
 
353
456
  const onAsk = async (name, args) => {
457
+ // "Permitir todo" activa el auto-approve de la sesion (compartido con la
458
+ // consola): a partir de ahi no se vuelve a preguntar.
459
+ if (ctx.isAutoApprove?.(sessionId)) return true;
354
460
  const askId = randomUUID();
355
- const preview = JSON.stringify(args ?? {}).slice(0, 300);
356
- await send(chatId, `⚠ [${projectLabel(session)}] Permiso para ${name}\n${preview}`, {
357
- reply_markup: {
358
- inline_keyboard: [
359
- [
360
- { text: 'Permitir', callback_data: `perm:${askId}:allow` },
361
- { text: 'Denegar', callback_data: `perm:${askId}:deny` },
362
- ],
363
- ],
364
- },
461
+ askSessions.set(askId, sessionId);
462
+ await send(chatId, permissionMessage(projectLabel(session), name, args), {
463
+ reply_markup: permissionKeyboard(askId),
365
464
  });
366
- return ctx.approvals.ask(askId);
465
+ return ctx.approvals.ask(askId, { sessionId, name, args });
367
466
  };
368
467
 
369
468
  const local = createTools({
@@ -419,6 +518,9 @@ export function startTelegramChannel(
419
518
  }
420
519
  } else if (event.type === 'tool_result') {
421
520
  activity = `${event.name} ✓`;
521
+ if (event.name === 'screenshot' && event.meta?.screenshot?.name) {
522
+ enviarCaptura(chatId, event.meta.screenshot.name, event.meta.screenshot.url);
523
+ }
422
524
  } else {
423
525
  return;
424
526
  }
@@ -656,8 +758,12 @@ export function startTelegramChannel(
656
758
  let toast = '';
657
759
 
658
760
  if (kind === 'perm') {
659
- ctx.approvals.decide(a, b === 'allow');
660
- toast = b === 'allow' ? 'Permitido' : 'Denegado';
761
+ const all = b === 'all';
762
+ ctx.approvals.decide(a, b === 'allow' || all);
763
+ const sessionId = askSessions.get(a);
764
+ if (sessionId) askSessions.delete(a);
765
+ if (all && sessionId) ctx.setAutoApprove?.(sessionId, true);
766
+ toast = all ? 'Permitidos todos' : b === 'allow' ? 'Permitido' : 'Denegado';
661
767
  } else if (kind === 'proj') {
662
768
  if (a === 'root') {
663
769
  await selectProject(chatId, projectRoot());
@@ -755,7 +861,7 @@ export function startTelegramChannel(
755
861
  notify(sessionId, event) {
756
862
  if (ownSessions.has(sessionId)) return;
757
863
 
758
- // La tool `speak` entrega audio en vivo a un chat vinculado.
864
+ // Las tools `speak` y `screenshot` entregan en vivo a un chat vinculado.
759
865
  if (event.type === 'tool_call') {
760
866
  if (event.data?.name !== 'speak') return;
761
867
  const linked = ctx.store.getSetting(reverseKey(sessionId));
@@ -767,6 +873,12 @@ export function startTelegramChannel(
767
873
  }
768
874
  return;
769
875
  }
876
+ if (event.type === 'tool_result' && event.data?.name === 'screenshot') {
877
+ const linked = ctx.store.getSetting(reverseKey(sessionId));
878
+ const shot = event.data?.meta?.screenshot;
879
+ if (linked && shot?.name) enviarCaptura(linked, shot.name, shot.url);
880
+ return;
881
+ }
770
882
 
771
883
  const targets = notifyTargets(sessionId);
772
884
  if (!targets.length) return;
@@ -785,17 +897,10 @@ export function startTelegramChannel(
785
897
  } else if (event.type === 'permission' && notifyConfig.permission) {
786
898
  const askId = event.data?.askId;
787
899
  if (!askId) return;
900
+ if (event.data?.sessionId) askSessions.set(askId, event.data.sessionId);
901
+ const text = permissionMessage(project, event.data?.name ?? 'una tool', event.data?.args);
788
902
  for (const chatId of targets) {
789
- send(chatId, `[${project}] ⚠ Permiso para ${event.data?.name ?? 'una tool'}`, {
790
- reply_markup: {
791
- inline_keyboard: [
792
- [
793
- { text: 'Permitir', callback_data: `perm:${askId}:allow` },
794
- { text: 'Denegar', callback_data: `perm:${askId}:deny` },
795
- ],
796
- ],
797
- },
798
- }).catch(() => {});
903
+ send(chatId, text, { reply_markup: permissionKeyboard(askId) }).catch(() => {});
799
904
  }
800
905
  }
801
906
  },
@@ -2,7 +2,7 @@ import fs from 'node:fs';
2
2
  import fsp from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { readJson, sendJson, sendError } from '../lib/http.js';
5
- import { staticServerChip } from '../lib/core.js';
5
+ import { staticServerChip, screenshotsDir } from '../lib/core.js';
6
6
  import { isInside, localOk, requireLocal, resolveDirOr400 } from '../lib/workspace.js';
7
7
 
8
8
  const MAX_PREVIEW = 200 * 1024;
@@ -144,4 +144,28 @@ export function registerFiles(route, ctx) {
144
144
  port: proc.port,
145
145
  });
146
146
  });
147
+
148
+ // Capturas del tool `screenshot`: viven en la casa, no en el workspace.
149
+ route('GET', '/api/screenshots/:name', ({ req, res, params }) => {
150
+ const local = localOk(req);
151
+ if (!local && !ctx.authEnabled) {
152
+ return sendError(res, 403, 'Las capturas solo están disponibles en conexiones locales.');
153
+ }
154
+ const name = String(params.name ?? '');
155
+ if (!/^[a-z0-9][a-z0-9-]*\.png$/i.test(name)) return sendError(res, 400, 'Nombre de captura inválido.');
156
+ const file = path.join(screenshotsDir(), name);
157
+ let info;
158
+ try {
159
+ info = fs.statSync(file);
160
+ } catch {
161
+ return sendError(res, 404, 'La captura no existe.');
162
+ }
163
+ if (!info.isFile()) return sendError(res, 404, 'La captura no existe.');
164
+ res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': info.size, 'Cache-Control': 'no-store' });
165
+ if (req.method === 'HEAD') {
166
+ res.end();
167
+ return;
168
+ }
169
+ fs.createReadStream(file).pipe(res);
170
+ });
147
171
  }
@@ -6,7 +6,7 @@ import { mkdtemp, mkdir, writeFile, readFile, stat } from 'node:fs/promises';
6
6
  import { tmpdir } from 'node:os';
7
7
  import path from 'node:path';
8
8
  import { createTamnoraServer } from '../src/index.js';
9
- import { setHome, ensureApp, readApp, writeApp, makeUser } from '../../core/src/index.js';
9
+ import { setHome, ensureApp, readApp, writeApp, makeUser, screenshotsDir } from '../../core/src/index.js';
10
10
  import { completionFooter } from '../src/lib/channels/telegram.js';
11
11
 
12
12
  const fakeProvider = {
@@ -1792,7 +1792,21 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
1792
1792
  body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
1793
1793
  })
1794
1794
  ).text();
1795
- assert.deepEqual(captured.tools, ['fetch_url', 'git_diff', 'git_log', 'git_status', 'glob', 'grep', 'list', 'read', 'speak', 'task']);
1795
+ assert.deepEqual(captured.tools, [
1796
+ 'console_errors',
1797
+ 'fetch_url',
1798
+ 'git_diff',
1799
+ 'git_log',
1800
+ 'git_status',
1801
+ 'glob',
1802
+ 'grep',
1803
+ 'list',
1804
+ 'preview_url',
1805
+ 'read',
1806
+ 'screenshot',
1807
+ 'speak',
1808
+ 'task',
1809
+ ]);
1796
1810
  assert.match(captured.system, /modo PLAN/);
1797
1811
  } finally {
1798
1812
  await instance.close();
@@ -2026,16 +2040,38 @@ test('truncates a session from a message id', async () => {
2026
2040
  const firstId = detail.messages[0].id;
2027
2041
  assert.ok(firstId);
2028
2042
 
2029
- const res = await fetch(`${base}/api/sessions/${sessionId}/truncate`, {
2030
- method: 'POST',
2031
- headers: { 'Content-Type': 'application/json' },
2032
- body: JSON.stringify({ fromId: firstId }),
2033
- });
2034
- assert.equal(res.status, 200);
2035
-
2036
- detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
2037
- assert.equal(detail.messages.length, 0);
2038
- } finally {
2039
- await instance.close();
2040
- }
2043
+ const res = await fetch(`${base}/api/sessions/${sessionId}/truncate`, {
2044
+ method: 'POST',
2045
+ headers: { 'Content-Type': 'application/json' },
2046
+ body: JSON.stringify({ fromId: firstId }),
2047
+ });
2048
+ assert.equal(res.status, 200);
2049
+
2050
+ detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
2051
+ assert.equal(detail.messages.length, 0);
2052
+ } finally {
2053
+ await instance.close();
2054
+ }
2055
+ });
2056
+
2057
+ test('serves browser screenshots from the home dir, confined to png names', async () => {
2058
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-shots-'));
2059
+ const { instance, base } = await startServer({ cwd });
2060
+ try {
2061
+ const dir = screenshotsDir();
2062
+ await mkdir(dir, { recursive: true });
2063
+ const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
2064
+ await writeFile(path.join(dir, 'abc-123.png'), png);
2065
+
2066
+ const ok = await fetch(`${base}/api/screenshots/abc-123.png`);
2067
+ assert.equal(ok.status, 200);
2068
+ assert.equal(ok.headers.get('content-type'), 'image/png');
2069
+ assert.equal((await ok.arrayBuffer()).byteLength, png.length);
2070
+
2071
+ assert.equal((await fetch(`${base}/api/screenshots/bad.txt`)).status, 400);
2072
+ assert.equal((await fetch(`${base}/api/screenshots/missing.png`)).status, 404);
2073
+ } finally {
2074
+ await instance.close();
2075
+ }
2041
2076
  });
2077
+
@@ -6,7 +6,7 @@ import path from 'node:path';
6
6
  import { Store } from '../../core/src/index.js';
7
7
  import { createApprovalBroker } from '../src/lib/approvals.js';
8
8
  import { createPresence } from '../src/lib/presence.js';
9
- import { startTelegramChannel } from '../src/lib/channels/telegram.js';
9
+ import { startTelegramChannel, describePermission } from '../src/lib/channels/telegram.js';
10
10
 
11
11
  const reply = (data) => ({ ok: true, json: async () => data });
12
12
 
@@ -227,15 +227,36 @@ test('notify de permiso incluye el proyecto y los botones', async () => {
227
227
  log: () => {},
228
228
  });
229
229
  try {
230
- channel.notify(sessionId, { type: 'permission', data: { askId: 'ask-1', name: 'bash' } });
231
- await waitFor(() => mock.sent.some((m) => /Permiso para bash/.test(m.text ?? '')));
232
- const msg = mock.sent.find((m) => /Permiso para bash/.test(m.text ?? ''));
230
+ channel.notify(sessionId, {
231
+ type: 'permission',
232
+ data: { askId: 'ask-1', sessionId, name: 'bash', args: { command: 'npm test' } },
233
+ });
234
+ await waitFor(() => mock.sent.some((m) => /Permiso:/.test(m.text ?? '')));
235
+ const msg = mock.sent.find((m) => /Permiso:/.test(m.text ?? ''));
233
236
  assert.match(msg.text, new RegExp(`\\[${path.basename(cwd)}\\]`));
237
+ assert.match(msg.text, /ejecutar un comando/);
238
+ assert.match(msg.text, /npm test/);
234
239
  const datas = msg.reply_markup.inline_keyboard.flat().map((b) => b.callback_data);
235
240
  assert.ok(datas.includes('perm:ask-1:allow'));
241
+ assert.ok(datas.includes('perm:ask-1:all'));
242
+ assert.ok(datas.includes('perm:ask-1:deny'));
236
243
  } finally {
237
244
  channel.stop();
238
245
  mock.restore();
239
246
  ctx.store.close();
240
247
  }
241
248
  });
249
+
250
+ test('describePermission explica la accion sin volcar el JSON', () => {
251
+ assert.deepEqual(describePermission('bash', { command: 'npm test' }), {
252
+ action: 'ejecutar un comando en el proyecto',
253
+ detail: 'npm test',
254
+ });
255
+ const write = describePermission('write', { path: 'nota.txt', content: 'hola mundo' });
256
+ assert.match(write.action, /escribir el archivo nota\.txt/);
257
+ assert.match(write.detail, /Contenido: 10 B/);
258
+ assert.doesNotMatch(write.detail, /hola mundo/);
259
+ assert.match(describePermission('edit', { path: 'a.js', oldString: 'const a = 1;' }).detail, /const a = 1;/);
260
+ assert.match(describePermission('screenshot', { url: 'http://127.0.0.1:5173' }).detail, /127\.0\.0\.1/);
261
+ assert.match(describePermission('desconocida', { x: 1 }).action, /desconocida/);
262
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.9.3",
3
+ "version": "0.9.4",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",