@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.
- package/CHANGELOG.md +6 -0
- package/README.md +30 -10
- 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 +2 -1
- package/harness/packages/core/src/tools/executors.js +96 -0
- package/harness/packages/core/src/tools/labels.js +4 -0
- package/harness/packages/core/src/tools/permissions.js +4 -0
- package/harness/packages/core/src/tools/schema.js +47 -0
- package/harness/packages/core/test/agent.test.js +30 -1
- package/harness/packages/core/test/browser.test.js +18 -0
- package/harness/packages/core/test/tools.test.js +16 -1
- package/harness/packages/server/public/app.js +29 -0
- package/harness/packages/server/public/index.html +1 -1
- package/harness/packages/server/public/landing/index.html +14 -1
- package/harness/packages/server/public/reconcile.js +3 -1
- package/harness/packages/server/public/styles.css +11 -0
- package/harness/packages/server/src/lib/agentRun.js +2 -2
- package/harness/packages/server/src/lib/channels/telegram.js +130 -25
- package/harness/packages/server/src/routes/files.js +25 -1
- package/harness/packages/server/test/server.test.js +50 -14
- package/harness/packages/server/test/telegram-features.test.js +25 -4
- package/package.json +1 -1
|
@@ -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.
|
|
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)
|
|
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 {
|
|
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
|
-
|
|
356
|
-
await send(chatId,
|
|
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
|
-
|
|
660
|
-
|
|
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
|
-
//
|
|
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,
|
|
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, [
|
|
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, {
|
|
231
|
-
|
|
232
|
-
|
|
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