@danieltmn/tamnora 0.9.3 → 0.9.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +51 -16
  3. package/harness/packages/cli/src/commands/doctor.js +14 -0
  4. package/harness/packages/cli/src/commands/update.js +91 -21
  5. package/harness/packages/core/src/agent/modes.js +14 -1
  6. package/harness/packages/core/src/agent/runAgent.js +23 -2
  7. package/harness/packages/core/src/browser/index.js +289 -0
  8. package/harness/packages/core/src/config/home.js +5 -0
  9. package/harness/packages/core/src/index.js +9 -2
  10. package/harness/packages/core/src/tools/executors.js +96 -0
  11. package/harness/packages/core/src/tools/labels.js +4 -0
  12. package/harness/packages/core/src/tools/permissions.js +4 -0
  13. package/harness/packages/core/src/tools/schema.js +47 -0
  14. package/harness/packages/core/src/version.js +81 -7
  15. package/harness/packages/core/test/agent.test.js +30 -1
  16. package/harness/packages/core/test/browser.test.js +18 -0
  17. package/harness/packages/core/test/tools.test.js +16 -1
  18. package/harness/packages/core/test/version.test.js +34 -0
  19. package/harness/packages/server/public/app.js +244 -8
  20. package/harness/packages/server/public/index.html +57 -3
  21. package/harness/packages/server/public/landing/index.html +14 -1
  22. package/harness/packages/server/public/markdown.js +54 -0
  23. package/harness/packages/server/public/reconcile.js +3 -1
  24. package/harness/packages/server/public/styles.css +48 -0
  25. package/harness/packages/server/src/lib/advisor.js +177 -0
  26. package/harness/packages/server/src/lib/agentRun.js +4 -3
  27. package/harness/packages/server/src/lib/channels/telegram.js +130 -25
  28. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  29. package/harness/packages/server/src/lib/update.js +82 -0
  30. package/harness/packages/server/src/routes/advisor.js +76 -0
  31. package/harness/packages/server/src/routes/chat.js +30 -0
  32. package/harness/packages/server/src/routes/files.js +25 -1
  33. package/harness/packages/server/src/routes/tts.js +1 -1
  34. package/harness/packages/server/src/routes/update.js +43 -0
  35. package/harness/packages/server/src/server.js +26 -2
  36. package/harness/packages/server/test/advisor.test.js +205 -0
  37. package/harness/packages/server/test/markdown.test.js +43 -1
  38. package/harness/packages/server/test/server.test.js +50 -14
  39. package/harness/packages/server/test/telegram-features.test.js +25 -4
  40. package/harness/packages/server/test/update.test.js +94 -0
  41. package/harness/scripts/self-update.mjs +123 -0
  42. package/package.json +1 -1
@@ -17,6 +17,7 @@ import {
17
17
  toolResultPayload,
18
18
  } from '../lib/agentRun.js';
19
19
  import { fraseInicio, fraseFin, fraseError, createFiltroTools } from '../lib/announcer.js';
20
+ import { resolveAdvisor, advisorShouldRun, consultAdvisor } from '../lib/advisor.js';
20
21
 
21
22
  /**
22
23
  * Emit streaming partials at most once per `ms` window, keeping only the
@@ -318,6 +319,35 @@ export function registerChat(route, ctx) {
318
319
  durationMs,
319
320
  say: fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }),
320
321
  });
322
+
323
+ // El asesor va despues del `done` pero antes del `finally`, asi el run
324
+ // sigue vivo y el evento llega/persiste. Puede quedarse callado.
325
+ const advisorOptions = resolveAdvisor(ctx.app);
326
+ if (
327
+ result.stopReason !== 'aborted' &&
328
+ advisorOptions.enabled &&
329
+ advisorShouldRun({ toolCalls, answer: result.content })
330
+ ) {
331
+ const advice = await consultAdvisor({
332
+ provider,
333
+ config,
334
+ model,
335
+ signal: ctx.runs.signal(sessionId),
336
+ userInput: input,
337
+ answer: result.content,
338
+ messages: newMessages,
339
+ options: advisorOptions,
340
+ });
341
+ if (advice?.speak) {
342
+ ctx.store.addMessage(sessionId, {
343
+ role: 'assistant',
344
+ name: 'advisor',
345
+ content: advice.text,
346
+ mode: advice.kind,
347
+ });
348
+ ctx.runs.emit(sessionId, 'advice', { text: advice.text, kind: advice.kind, say: advice.text });
349
+ }
350
+ }
321
351
  } catch (error) {
322
352
  partials.flush();
323
353
  if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', { say: fraseFin({ stopReason: 'aborted' }) });
@@ -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
  }
@@ -39,7 +39,7 @@ export function registerTts(route, ctx) {
39
39
  if ('rate' in body) next.rate = sanearRate(body.rate, actual.rate);
40
40
  if (body.announce && typeof body.announce === 'object') {
41
41
  const announce = { ...actual.announce };
42
- for (const key of ['start', 'tools', 'end']) {
42
+ for (const key of ['start', 'tools', 'end', 'advice', 'answer']) {
43
43
  if (key in body.announce) announce[key] = body.announce[key] === true;
44
44
  }
45
45
  next.announce = announce;
@@ -0,0 +1,43 @@
1
+ // Alerta de version nueva y actualizacion con un click desde la consola.
2
+ //
3
+ // `GET /api/update` es solo informacion (cacheada). `POST /api/update` es
4
+ // privilegiado: **solo loopback y sin tunel** (`requireLocal`), igual que
5
+ // shell/git/carpetas. El server responde y se apaga; un script detached espera
6
+ // su muerte, instala la version nueva y lo vuelve a levantar.
7
+
8
+ import { sendJson, sendError } from '../lib/http.js';
9
+ import { requireLocal } from '../lib/workspace.js';
10
+ import { checkUpdate, spawnSelfUpdate } from '../lib/update.js';
11
+
12
+ export function registerUpdate(route, ctx) {
13
+ route('GET', '/api/update', async ({ res, query }) => {
14
+ const force = query.get('force') === '1';
15
+ sendJson(res, 200, await checkUpdate({ force }));
16
+ });
17
+
18
+ route('POST', '/api/update', async ({ req, res }) => {
19
+ if (!requireLocal(req, res, 'Actualizar solo esta permitido en la conexion local.')) return;
20
+
21
+ const info = await checkUpdate({ force: true });
22
+ if (!info.latest) return sendError(res, 502, info.error || 'No pude consultar la ultima version.');
23
+ if (!info.updateAvailable) {
24
+ return sendJson(res, 200, { ok: true, upToDate: true, current: info.current, latest: info.latest });
25
+ }
26
+
27
+ const spawnUpdater = ctx.spawnUpdater ?? spawnSelfUpdate;
28
+ const started = spawnUpdater({
29
+ pid: process.pid,
30
+ port: ctx.serverInfo?.port,
31
+ host: ctx.serverInfo?.host,
32
+ cwd: ctx.cwd,
33
+ target: info.latest,
34
+ });
35
+ if (!started.ok) return sendError(res, 500, started.error);
36
+
37
+ sendJson(res, 202, { ok: true, updating: true, from: info.current, to: info.latest });
38
+ // Soltar los archivos antes de que npm los reemplace.
39
+ setTimeout(() => {
40
+ Promise.resolve(ctx.shutdown?.()).catch(() => process.exit(0));
41
+ }, 800);
42
+ });
43
+ }
@@ -39,6 +39,8 @@ import { registerChannels } from './routes/channels.js';
39
39
  import { registerProfile } from './routes/profile.js';
40
40
  import { registerTts } from './routes/tts.js';
41
41
  import { registerTranscribe } from './routes/transcribe.js';
42
+ import { registerUpdate } from './routes/update.js';
43
+ import { registerAdvisor } from './routes/advisor.js';
42
44
  import { registerProcs } from './routes/procs.js';
43
45
  import { registerShell } from './routes/shell.js';
44
46
  import { registerPreview } from './routes/preview.js';
@@ -186,6 +188,13 @@ export async function createTamnoraServer({
186
188
  mergedMcpServers,
187
189
  procs,
188
190
  procsEnabled,
191
+ // Se completa en `listen()` con el puerto/host reales (util para reiniciar
192
+ // el server tras una actualizacion).
193
+ serverInfo: {
194
+ port: port ?? config.server?.port ?? 4517,
195
+ host: host ?? config.server?.host ?? '127.0.0.1',
196
+ url: '',
197
+ },
189
198
  };
190
199
 
191
200
  ctx.systemPromptFor = createPromptBuilder({
@@ -234,6 +243,8 @@ export async function createTamnoraServer({
234
243
  registerProfile(route, ctx);
235
244
  registerTts(route, ctx);
236
245
  registerTranscribe(route, ctx);
246
+ registerAdvisor(route, ctx);
247
+ registerUpdate(route, ctx);
237
248
  registerProcs(route, ctx);
238
249
  registerShell(route, ctx);
239
250
  const preview = registerPreview(route, ctx);
@@ -320,11 +331,13 @@ export async function createTamnoraServer({
320
331
  options.host ?? host ?? config.server?.host ?? '127.0.0.1',
321
332
  () => {
322
333
  const address = server.address();
323
- resolve({
334
+ const info = {
324
335
  host: address.address,
325
336
  port: address.port,
326
337
  url: `http://${address.address === '::' ? '127.0.0.1' : address.address}:${address.port}`,
327
- });
338
+ };
339
+ ctx.serverInfo = info;
340
+ resolve(info);
328
341
  }
329
342
  );
330
343
  });
@@ -354,5 +367,16 @@ export async function createTamnoraServer({
354
367
  }
355
368
  };
356
369
 
370
+ // Apagado ordenado pedido desde una ruta (actualizacion con un click): cierra
371
+ // el server y termina el proceso para que npm pueda reemplazar los archivos.
372
+ ctx.shutdown = async () => {
373
+ try {
374
+ await close();
375
+ } catch {
376
+ /* ya cerrado */
377
+ }
378
+ process.exit(0);
379
+ };
380
+
357
381
  return { server, config, store, ctx, listen, close, staticDir: staticDir ?? DEFAULT_PUBLIC_DIR };
358
382
  }
@@ -0,0 +1,205 @@
1
+ import { test } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import { mkdtemp } from 'node:fs/promises';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import {
7
+ resolveAdvisor,
8
+ advisorShouldRun,
9
+ collectChangedFiles,
10
+ collectToolSummary,
11
+ buildAdvisorPrompt,
12
+ consultAdvisor,
13
+ } from '../src/lib/advisor.js';
14
+ import { createTamnoraServer } from '../src/index.js';
15
+ import { setHome } from '../../core/src/index.js';
16
+
17
+ const editMessages = () => [
18
+ {
19
+ role: 'assistant',
20
+ content: '',
21
+ tool_calls: [
22
+ { id: 'c1', function: { name: 'edit', arguments: JSON.stringify({ path: 'src/a.js' }) } },
23
+ ],
24
+ },
25
+ { role: 'tool', tool_call_id: 'c1', name: 'edit', content: '- const token = "secreto"\n+ const token = process.env.TOKEN' },
26
+ ];
27
+
28
+ test('resolveAdvisor mezcla defaults y sanitiza', () => {
29
+ assert.equal(resolveAdvisor({}).enabled, false);
30
+ const saved = resolveAdvisor({ advisor: { enabled: true, notes: 'x', maxWords: 999 } });
31
+ assert.equal(saved.enabled, true);
32
+ assert.equal(saved.code, true);
33
+ assert.equal(saved.maxWords, 120);
34
+ const off = resolveAdvisor({ advisor: { code: false, explain: false, advice: false, maxWords: 5 } });
35
+ assert.equal(off.maxWords, 10);
36
+ assert.equal(off.code, false);
37
+ });
38
+
39
+ test('advisorShouldRun exige tools o respuesta larga', () => {
40
+ assert.equal(advisorShouldRun({ toolCalls: 0, answer: 'corto' }), false);
41
+ assert.equal(advisorShouldRun({ toolCalls: 1, answer: '' }), true);
42
+ assert.equal(advisorShouldRun({ toolCalls: 0, answer: 'x'.repeat(201) }), true);
43
+ });
44
+
45
+ test('collectChangedFiles arma path + diff de write/edit', () => {
46
+ const files = collectChangedFiles(editMessages());
47
+ assert.equal(files.length, 1);
48
+ assert.equal(files[0].path, 'src/a.js');
49
+ assert.match(files[0].diff, /secreto/);
50
+ });
51
+
52
+ test('collectToolSummary lista las tools del turno', () => {
53
+ const summary = collectToolSummary(editMessages());
54
+ assert.equal(summary.length, 1);
55
+ assert.match(summary[0], /^edit:/);
56
+ });
57
+
58
+ test('buildAdvisorPrompt respeta los toggles y las notas', () => {
59
+ const { system, user } = buildAdvisorPrompt({
60
+ userInput: 'hola',
61
+ answer: 'listo',
62
+ toolSummary: ['edit: src/a.js'],
63
+ changedFiles: [{ path: 'src/a.js', diff: '- x\n+ y' }],
64
+ options: { code: false, explain: true, advice: true, notes: 'avisame de secretos', maxWords: 20 },
65
+ });
66
+ assert.doesNotMatch(system, /problema real en el codigo/);
67
+ assert.match(system, /explicar un proceso/);
68
+ assert.match(system, /avisame de secretos/);
69
+ assert.match(user, /PEDIDO DEL USUARIO/);
70
+ assert.match(user, /src\/a\.js/);
71
+ });
72
+
73
+ const fakeProvider = (content) => ({ streamChat: async () => ({ content }) });
74
+
75
+ test('consultAdvisor interpreta speak:true', async () => {
76
+ const advice = await consultAdvisor({
77
+ provider: fakeProvider('{"speak":true,"kind":"code","text":"Ojo con el token en src/a.js."}'),
78
+ config: {},
79
+ model: 'x',
80
+ messages: editMessages(),
81
+ options: { code: true, explain: true, advice: true, maxWords: 40 },
82
+ });
83
+ assert.equal(advice.speak, true);
84
+ assert.equal(advice.kind, 'code');
85
+ assert.match(advice.text, /token/);
86
+ });
87
+
88
+ test('consultAdvisor respeta speak:false, JSON invalido y errores', async () => {
89
+ const off = await consultAdvisor({
90
+ provider: fakeProvider('{"speak":false,"text":""}'),
91
+ config: {},
92
+ model: 'x',
93
+ messages: [],
94
+ options: {},
95
+ });
96
+ assert.equal(off.speak, false);
97
+ assert.equal(
98
+ await consultAdvisor({ provider: fakeProvider('no json'), config: {}, model: 'x', messages: [], options: {} }),
99
+ null
100
+ );
101
+ const boom = {
102
+ streamChat: async () => {
103
+ throw new Error('boom');
104
+ },
105
+ };
106
+ assert.equal(await consultAdvisor({ provider: boom, config: {}, model: 'x', messages: [], options: {} }), null);
107
+ });
108
+
109
+ test('consultAdvisor recorta a maxWords', async () => {
110
+ const long = 'palabra '.repeat(50).trim();
111
+ const advice = await consultAdvisor({
112
+ provider: fakeProvider(JSON.stringify({ speak: true, kind: 'advice', text: long })),
113
+ config: {},
114
+ model: 'x',
115
+ messages: [],
116
+ options: { maxWords: 10, code: true, explain: true, advice: true },
117
+ });
118
+ assert.ok(advice.text.split(' ').length <= 11);
119
+ assert.match(advice.text, /…$/);
120
+ });
121
+
122
+ test('GET/PUT /api/advisor/settings y POST /api/advisor/test con proveedor inyectado', async () => {
123
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-advisor-'));
124
+ setHome(cwd);
125
+ let reply = '{"speak":true,"kind":"code","text":"Cuidado con el secreto."}';
126
+ const provider = { streamChat: async () => ({ content: reply }) };
127
+ const instance = await createTamnoraServer({
128
+ cwd,
129
+ dbPath: path.join(cwd, 'tamnora.db'),
130
+ loadMcp: false,
131
+ resolveProvider: () => ({ provider, config: {} }),
132
+ });
133
+ instance.ctx.store.setSetting('activeProvider', 'fake');
134
+ instance.ctx.store.setSetting('activeModel', 'test-model');
135
+ const info = await instance.listen({ port: 0 });
136
+ const base = info.url;
137
+ try {
138
+ const initial = await (await fetch(`${base}/api/advisor/settings`)).json();
139
+ assert.equal(initial.enabled, false);
140
+
141
+ const put = await fetch(`${base}/api/advisor/settings`, {
142
+ method: 'PUT',
143
+ headers: { 'Content-Type': 'application/json' },
144
+ body: JSON.stringify({ enabled: true, code: false, maxWords: 500 }),
145
+ });
146
+ const saved = await put.json();
147
+ assert.equal(saved.enabled, true);
148
+ assert.equal(saved.code, false);
149
+ assert.equal(saved.maxWords, 120);
150
+
151
+ const on = await (await fetch(`${base}/api/advisor/test`, { method: 'POST' })).json();
152
+ assert.equal(on.speak, true);
153
+ assert.match(on.text, /secreto/);
154
+
155
+ reply = '{"speak":false,"text":""}';
156
+ const off = await (await fetch(`${base}/api/advisor/test`, { method: 'POST' })).json();
157
+ assert.equal(off.speak, false);
158
+ } finally {
159
+ await instance.close();
160
+ }
161
+ });
162
+
163
+ test('el asesor emite el evento advice al cerrar el turno', async () => {
164
+ const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-advice-'));
165
+ setHome(cwd);
166
+ let calls = 0;
167
+ const provider = {
168
+ id: 'fake',
169
+ streamChat: async (_config, { onEvent } = {}) => {
170
+ calls += 1;
171
+ if (calls === 1) {
172
+ const text = `Listo. ${'x'.repeat(220)}`;
173
+ onEvent?.({ type: 'text', delta: text, full: text });
174
+ return { content: text, reasoning: '', toolCalls: [] };
175
+ }
176
+ return { content: '{"speak":true,"kind":"code","text":"Cuidado con el token."}', reasoning: '', toolCalls: [] };
177
+ },
178
+ };
179
+ const instance = await createTamnoraServer({
180
+ cwd,
181
+ dbPath: path.join(cwd, 'tamnora.db'),
182
+ loadMcp: false,
183
+ resolveProvider: () => ({ provider, config: {} }),
184
+ });
185
+ instance.ctx.app = {
186
+ ...instance.ctx.app,
187
+ advisor: { enabled: true, code: true, explain: true, advice: true, notes: '', maxWords: 40 },
188
+ };
189
+ instance.ctx.store.setSetting('activeProvider', 'fake');
190
+ instance.ctx.store.setSetting('activeModel', 'test-model');
191
+ const info = await instance.listen({ port: 0 });
192
+ try {
193
+ const res = await fetch(`${info.url}/api/chat`, {
194
+ method: 'POST',
195
+ headers: { 'Content-Type': 'application/json' },
196
+ body: JSON.stringify({ input: 'quién soy', model: 'test-model', provider: 'fake' }),
197
+ });
198
+ const body = await res.text();
199
+ assert.equal(calls, 2);
200
+ assert.match(body, /event: advice/);
201
+ assert.match(body, /Cuidado con el token/);
202
+ } finally {
203
+ await instance.close();
204
+ }
205
+ });
@@ -1,6 +1,6 @@
1
1
  import { test } from 'node:test';
2
2
  import assert from 'node:assert/strict';
3
- import { renderMarkdown, highlight } from '../public/markdown.js';
3
+ import { renderMarkdown, highlight, speechText } from '../public/markdown.js';
4
4
 
5
5
  test('escapes raw HTML from model output', () => {
6
6
  const html = renderMarkdown('<script>alert(1)</script>');
@@ -67,3 +67,45 @@ test('a lone pipe line without a delimiter row stays a paragraph', () => {
67
67
  test('highlight escapes code content', () => {
68
68
  assert.equal(highlight('a < b', 'bash'), 'a &lt; b');
69
69
  });
70
+
71
+ test('speechText saca bloques de codigo y tablas', () => {
72
+ const md = [
73
+ 'Te dejo el resumen.',
74
+ '',
75
+ '```js',
76
+ 'const x = 1;',
77
+ '```',
78
+ '',
79
+ 'Y una tabla:',
80
+ '',
81
+ '| a | b |',
82
+ '| - | - |',
83
+ '| 1 | 2 |',
84
+ '',
85
+ 'Fin.',
86
+ ].join('\n');
87
+ const spoken = speechText(md);
88
+ assert.doesNotMatch(spoken, /const x/);
89
+ assert.doesNotMatch(spoken, /1 \| 2/);
90
+ assert.match(spoken, /Te dejo el resumen\./);
91
+ assert.match(spoken, /Fin\./);
92
+ });
93
+
94
+ test('speechText limpia marcas de markdown y aplasta espacios', () => {
95
+ const spoken = speechText('# Título\n\n**Nombre:** Daniel\n\n- uno\n- dos');
96
+ assert.equal(spoken, 'Título Nombre: Daniel uno dos');
97
+ });
98
+
99
+ test('speechText mantiene el texto de inline code', () => {
100
+ assert.equal(speechText('Corré `npm test` y listo.'), 'Corré npm test y listo.');
101
+ });
102
+
103
+ test('speechText recorta en un limite con maxChars', () => {
104
+ const spoken = speechText(`${'hola '.repeat(40)}`, { maxChars: 50 });
105
+ assert.ok(spoken.length <= 51);
106
+ assert.match(spoken, /…$/);
107
+ });
108
+
109
+ test('speechText devuelve vacio si solo hay codigo', () => {
110
+ assert.equal(speechText('```\nsolo codigo\n```'), '');
111
+ });
@@ -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
+ });