@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
@@ -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,
@@ -14,7 +24,6 @@ import {
14
24
  resolveProviderFor,
15
25
  toolCallPayload,
16
26
  } from '../agentRun.js';
17
- import { fraseFin } from '../announcer.js';
18
27
  import { mp3AOgg } from '../ffmpeg.js';
19
28
  import { resolveTts } from '../ttsSettings.js';
20
29
  import { transcribeAudio } from '../transcribe.js';
@@ -43,6 +52,72 @@ const HELP = [
43
52
  'Podés mandar texto, imágenes o notas de voz.',
44
53
  ].join('\n');
45
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
+
46
121
  async function tg(token, method, payload) {
47
122
  const res = await fetch(`${API}/bot${token}/${method}`, {
48
123
  method: 'POST',
@@ -145,6 +220,7 @@ export function startTelegramChannel(
145
220
  const modelChoices = new Map();
146
221
  const projectChoices = new Map();
147
222
  const pendingInputs = new Map();
223
+ const askSessions = new Map(); // askId -> sessionId, para "Permitir todo"
148
224
  let running = true;
149
225
  let offset = 0;
150
226
 
@@ -195,11 +271,30 @@ export function startTelegramChannel(
195
271
  }
196
272
  }
197
273
 
198
- /** Resumen hablado del cierre del turno, según los ajustes de voz. */
199
- async function sendResumenDeVoz(chatId, texto) {
200
- const tts = resolveTts(ctx.app);
201
- if (!tts.enabled || tts.announce.end === false) return;
202
- await enviarNotaDeVoz(chatId, texto);
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);
203
298
  }
204
299
 
205
300
  // --- proyectos ------------------------------------------------------------
@@ -359,19 +454,15 @@ export function startTelegramChannel(
359
454
  }
360
455
 
361
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;
362
460
  const askId = randomUUID();
363
- const preview = JSON.stringify(args ?? {}).slice(0, 300);
364
- await send(chatId, `⚠ [${projectLabel(session)}] Permiso para ${name}\n${preview}`, {
365
- reply_markup: {
366
- inline_keyboard: [
367
- [
368
- { text: 'Permitir', callback_data: `perm:${askId}:allow` },
369
- { text: 'Denegar', callback_data: `perm:${askId}:deny` },
370
- ],
371
- ],
372
- },
461
+ askSessions.set(askId, sessionId);
462
+ await send(chatId, permissionMessage(projectLabel(session), name, args), {
463
+ reply_markup: permissionKeyboard(askId),
373
464
  });
374
- return ctx.approvals.ask(askId);
465
+ return ctx.approvals.ask(askId, { sessionId, name, args });
375
466
  };
376
467
 
377
468
  const local = createTools({
@@ -427,6 +518,9 @@ export function startTelegramChannel(
427
518
  }
428
519
  } else if (event.type === 'tool_result') {
429
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
+ }
430
524
  } else {
431
525
  return;
432
526
  }
@@ -458,7 +552,6 @@ export function startTelegramChannel(
458
552
  }
459
553
  const footer = completionFooter(result.stopReason, result.rounds, toolCalls, durationMs);
460
554
  await edit(chatId, message.message_id, `${body}\n\n— ${footer}`);
461
- await sendResumenDeVoz(chatId, fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }));
462
555
  ctx.store.addMessages(sessionId, newMessages);
463
556
  ctx.store.touchSession(sessionId, { provider: providerId, model });
464
557
  } catch (error) {
@@ -665,8 +758,12 @@ export function startTelegramChannel(
665
758
  let toast = '';
666
759
 
667
760
  if (kind === 'perm') {
668
- ctx.approvals.decide(a, b === 'allow');
669
- 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';
670
767
  } else if (kind === 'proj') {
671
768
  if (a === 'root') {
672
769
  await selectProject(chatId, projectRoot());
@@ -764,7 +861,7 @@ export function startTelegramChannel(
764
861
  notify(sessionId, event) {
765
862
  if (ownSessions.has(sessionId)) return;
766
863
 
767
- // La tool `speak` entrega audio en vivo a un chat vinculado.
864
+ // Las tools `speak` y `screenshot` entregan en vivo a un chat vinculado.
768
865
  if (event.type === 'tool_call') {
769
866
  if (event.data?.name !== 'speak') return;
770
867
  const linked = ctx.store.getSetting(reverseKey(sessionId));
@@ -776,6 +873,12 @@ export function startTelegramChannel(
776
873
  }
777
874
  return;
778
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
+ }
779
882
 
780
883
  const targets = notifyTargets(sessionId);
781
884
  if (!targets.length) return;
@@ -784,18 +887,7 @@ export function startTelegramChannel(
784
887
 
785
888
  if (event.type === 'done' && notifyConfig.done) {
786
889
  const text = `[${project}] ✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`;
787
- const decir =
788
- event.data?.say ??
789
- fraseFin({
790
- stopReason: event.data?.stopReason,
791
- rounds: event.data?.rounds,
792
- toolCalls: event.data?.toolCalls,
793
- durationMs: event.data?.durationMs,
794
- });
795
- for (const chatId of targets) {
796
- send(chatId, text).catch(() => {});
797
- sendResumenDeVoz(chatId, `${project}. ${decir}`);
798
- }
890
+ for (const chatId of targets) send(chatId, text).catch(() => {});
799
891
  } else if (event.type === 'error' && notifyConfig.error) {
800
892
  const text = `[${project}] ✗ Error en la sesión: ${event.data?.message ?? ''}`;
801
893
  for (const chatId of targets) send(chatId, text).catch(() => {});
@@ -805,17 +897,10 @@ export function startTelegramChannel(
805
897
  } else if (event.type === 'permission' && notifyConfig.permission) {
806
898
  const askId = event.data?.askId;
807
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);
808
902
  for (const chatId of targets) {
809
- send(chatId, `[${project}] ⚠ Permiso para ${event.data?.name ?? 'una tool'}`, {
810
- reply_markup: {
811
- inline_keyboard: [
812
- [
813
- { text: 'Permitir', callback_data: `perm:${askId}:allow` },
814
- { text: 'Denegar', callback_data: `perm:${askId}:deny` },
815
- ],
816
- ],
817
- },
818
- }).catch(() => {});
903
+ send(chatId, text, { reply_markup: permissionKeyboard(askId) }).catch(() => {});
819
904
  }
820
905
  }
821
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,6 +6,49 @@ import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView
6
6
  const COMPACT_INSTRUCTION =
7
7
  'Resumí la conversación en un texto conciso que preserve decisiones tomadas, archivos tocados, hallazgos y el estado actual, para poder continuar el trabajo sin el historial completo.';
8
8
 
9
+ const SUMMARY_INSTRUCTION =
10
+ 'Resumí la conversación para la persona: qué se pidió, qué se hizo, decisiones tomadas, archivos o comandos relevantes y el estado actual. Sé claro y conciso, en español, sin markdown pesado.';
11
+
12
+ function sessionTranscript(messages) {
13
+ return messages
14
+ .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
15
+ .join('\n');
16
+ }
17
+
18
+ /**
19
+ * Corre un resumen no destructivo de la sesión (no toca el historial).
20
+ * Devuelve `{ summary }` o `{ error, status }`.
21
+ */
22
+ async function summarizeSession(ctx, session, instruction) {
23
+ const messages = ctx.store.getMessages(session.id);
24
+ if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
25
+ const providerId = session.provider || ctx.effectiveProviderId();
26
+ const model = session.model || ctx.effectiveModel();
27
+ if (!model) return { error: 'Falta el modelo para resumir.', status: 400 };
28
+
29
+ try {
30
+ const { provider, config } = ctx.resolveProvider({
31
+ ...ctx.providerSettings(providerId),
32
+ apiKey: ctx.apiKeyFor(providerId),
33
+ });
34
+ const result = await runAgent({
35
+ provider,
36
+ config,
37
+ model,
38
+ messages: [
39
+ { role: 'system', content: instruction },
40
+ { role: 'user', content: sessionTranscript(messages).slice(0, 60000) },
41
+ ],
42
+ tools: [],
43
+ });
44
+ const summary = result.content?.trim();
45
+ if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
46
+ return { summary };
47
+ } catch (error) {
48
+ return { error: `No se pudo resumir: ${error?.message ?? error}`, status: 502 };
49
+ }
50
+ }
51
+
9
52
  export function registerSessions(route, ctx) {
10
53
  route('GET', '/api/sessions', ({ res }) => {
11
54
  const awaiting = awaitingBySession(ctx.approvals.list());
@@ -123,40 +166,19 @@ export function registerSessions(route, ctx) {
123
166
  route('POST', '/api/sessions/:id/compact', async ({ res, params }) => {
124
167
  const session = ctx.store.getSession(params.id);
125
168
  if (!session) return sendError(res, 404, 'Sesión no encontrada.');
126
- const messages = ctx.store.getMessages(params.id);
127
- if (!messages.length) return sendError(res, 400, 'La sesión no tiene mensajes para compactar.');
128
-
129
- const providerId = session.provider || ctx.effectiveProviderId();
130
- const model = session.model || ctx.effectiveModel();
131
- if (!model) return sendError(res, 400, 'Falta el modelo para compactar.');
132
-
133
- const transcript = messages
134
- .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
135
- .join('\n');
169
+ const out = await summarizeSession(ctx, session, COMPACT_INSTRUCTION);
170
+ if (out.error) return sendError(res, out.status, out.error);
171
+ ctx.store.clearMessages(params.id);
172
+ ctx.store.touchSession(params.id, { meta: { summary: out.summary } });
173
+ sendJson(res, 200, { summary: out.summary, messages: 0 });
174
+ });
136
175
 
137
- try {
138
- const { provider, config } = ctx.resolveProvider({
139
- ...ctx.providerSettings(providerId),
140
- apiKey: ctx.apiKeyFor(providerId),
141
- });
142
- const result = await runAgent({
143
- provider,
144
- config,
145
- model,
146
- messages: [
147
- { role: 'system', content: COMPACT_INSTRUCTION },
148
- { role: 'user', content: transcript.slice(0, 60000) },
149
- ],
150
- tools: [],
151
- });
152
- const summary = result.content?.trim();
153
- if (!summary) return sendError(res, 502, 'El modelo no devolvió un resumen.');
154
- ctx.store.clearMessages(params.id);
155
- ctx.store.touchSession(params.id, { meta: { summary } });
156
- sendJson(res, 200, { summary, messages: 0 });
157
- } catch (error) {
158
- sendError(res, 502, `No se pudo compactar: ${error?.message ?? error}`);
159
- }
176
+ route('POST', '/api/sessions/:id/summary', async ({ res, params }) => {
177
+ const session = ctx.store.getSession(params.id);
178
+ if (!session) return sendError(res, 404, 'Sesión no encontrada.');
179
+ const out = await summarizeSession(ctx, session, SUMMARY_INSTRUCTION);
180
+ if (out.error) return sendError(res, out.status, out.error);
181
+ sendJson(res, 200, { summary: out.summary });
160
182
  });
161
183
 
162
184
  route('DELETE', '/api/sessions/:id', ({ res, params }) => {
@@ -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 = {
@@ -1719,6 +1719,40 @@ test('compact summarizes the session and clears its messages', async () => {
1719
1719
  }
1720
1720
  });
1721
1721
 
1722
+ test('the summary endpoint returns a summary without clearing the messages', async () => {
1723
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-summary-'));
1724
+ const provider = {
1725
+ id: 'sum',
1726
+ async streamChat(_config, opts) {
1727
+ const system = opts.messages.find((m) => m.role === 'system')?.content ?? '';
1728
+ return { content: system.includes('Resumí la conversación') ? 'RESUMEN FINAL' : 'hola', reasoning: '', toolCalls: [] };
1729
+ },
1730
+ };
1731
+ const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
1732
+ const json = { 'Content-Type': 'application/json' };
1733
+ try {
1734
+ const created = await (
1735
+ await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'S' }) })
1736
+ ).json();
1737
+ const id = created.session.id;
1738
+ await (await fetch(`${base}/api/chat`, {
1739
+ method: 'POST',
1740
+ headers: json,
1741
+ body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
1742
+ })).text();
1743
+
1744
+ const summary = await (
1745
+ await fetch(`${base}/api/sessions/${id}/summary`, { method: 'POST', headers: json })
1746
+ ).json();
1747
+ assert.equal(summary.summary, 'RESUMEN FINAL');
1748
+
1749
+ const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
1750
+ assert.ok(detail.messages.length > 0, 'no borra el historial');
1751
+ } finally {
1752
+ await instance.close();
1753
+ }
1754
+ });
1755
+
1722
1756
  test('exposes modes and persists the active mode', async () => {
1723
1757
  const { instance, base } = await startServer();
1724
1758
  const json = { 'Content-Type': 'application/json' };
@@ -1758,7 +1792,21 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
1758
1792
  body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
1759
1793
  })
1760
1794
  ).text();
1761
- 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
+ ]);
1762
1810
  assert.match(captured.system, /modo PLAN/);
1763
1811
  } finally {
1764
1812
  await instance.close();
@@ -1992,16 +2040,38 @@ test('truncates a session from a message id', async () => {
1992
2040
  const firstId = detail.messages[0].id;
1993
2041
  assert.ok(firstId);
1994
2042
 
1995
- const res = await fetch(`${base}/api/sessions/${sessionId}/truncate`, {
1996
- method: 'POST',
1997
- headers: { 'Content-Type': 'application/json' },
1998
- body: JSON.stringify({ fromId: firstId }),
1999
- });
2000
- assert.equal(res.status, 200);
2001
-
2002
- detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
2003
- assert.equal(detail.messages.length, 0);
2004
- } finally {
2005
- await instance.close();
2006
- }
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
+ }
2007
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
+ });
@@ -107,7 +107,7 @@ function preselect(ctx, cwd, chatId = '7') {
107
107
  return sessionId;
108
108
  }
109
109
 
110
- test('con la voz activada, el turno de Telegram termina con una nota de voz', async () => {
110
+ test('el cierre del turno de Telegram no manda audio aunque la voz esté activada', async () => {
111
111
  const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgvoz-'));
112
112
  const ctx = makeCtx(cwd, { tts: { enabled: true, voice: 'es-AR-ElenaNeural', rate: '+0%', announce: { end: true } } });
113
113
  preselect(ctx, cwd);
@@ -117,10 +117,8 @@ test('con la voz activada, el turno de Telegram termina con una nota de voz', as
117
117
  const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
118
118
  try {
119
119
  mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
120
- await waitFor(() => mock.multipart.length > 0);
121
- const nota = mock.multipart[0];
122
- assert.equal(nota.chatId, '7');
123
- assert.match(nota.method, /^send(Voice|Audio)$/);
120
+ await waitFor(() => mock.sent.some((m) => /✓ terminado/.test(m.text ?? '')));
121
+ assert.equal(mock.multipart.length, 0);
124
122
  } finally {
125
123
  global.WebSocket = original;
126
124
  channel.stop();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.9.2",
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",