@danieltmn/tamnora 0.7.1 → 0.9.0

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 (66) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +9 -0
  3. package/SECURITY.md +11 -6
  4. package/harness/packages/cli/src/cli.js +1 -1
  5. package/harness/packages/cli/src/commands/bridge.js +180 -119
  6. package/harness/packages/cli/src/commands/run.js +1 -1
  7. package/harness/packages/cli/src/commands/server.js +26 -50
  8. package/harness/packages/cli/src/commands/tui.js +1 -1
  9. package/harness/packages/cli/src/lib/daemon.js +92 -40
  10. package/harness/packages/cli/test/release.test.js +9 -0
  11. package/harness/packages/core/src/agent/modes.js +1 -1
  12. package/harness/packages/core/src/agent/runAgent.js +3 -0
  13. package/harness/packages/core/src/git/index.js +177 -0
  14. package/harness/packages/core/src/index.js +16 -1
  15. package/harness/packages/core/src/open.js +43 -0
  16. package/harness/packages/core/src/pid.js +35 -0
  17. package/harness/packages/core/src/procs/kill.js +95 -0
  18. package/harness/packages/core/src/tools/executors.js +16 -0
  19. package/harness/packages/core/src/tools/labels.js +2 -0
  20. package/harness/packages/core/src/tools/permissions.js +1 -0
  21. package/harness/packages/core/src/tools/schema.js +17 -0
  22. package/harness/packages/core/src/tts/edge.js +232 -0
  23. package/harness/packages/core/src/tunnel.js +180 -19
  24. package/harness/packages/core/test/open.test.js +19 -0
  25. package/harness/packages/core/test/procs.test.js +50 -0
  26. package/harness/packages/core/test/tools.test.js +12 -0
  27. package/harness/packages/core/test/tts.test.js +41 -0
  28. package/harness/packages/server/public/app.js +4715 -3669
  29. package/harness/packages/server/public/index.html +94 -5
  30. package/harness/packages/server/public/landing/index.html +5 -5
  31. package/harness/packages/server/public/reconcile.js +121 -0
  32. package/harness/packages/server/public/relayClient.js +170 -0
  33. package/harness/packages/server/public/runStream.js +207 -0
  34. package/harness/packages/server/public/styles.css +196 -23
  35. package/harness/packages/server/src/auth.js +29 -5
  36. package/harness/packages/server/src/lib/agentRun.js +77 -0
  37. package/harness/packages/server/src/lib/announcer.js +92 -0
  38. package/harness/packages/server/src/lib/approvals.js +25 -3
  39. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  40. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  41. package/harness/packages/server/src/lib/procs.js +3 -18
  42. package/harness/packages/server/src/lib/runManager.js +24 -2
  43. package/harness/packages/server/src/lib/sessionView.js +43 -0
  44. package/harness/packages/server/src/lib/tasks.js +4 -9
  45. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  46. package/harness/packages/server/src/lib/workspace.js +37 -1
  47. package/harness/packages/server/src/routes/chat.js +51 -47
  48. package/harness/packages/server/src/routes/events.js +35 -0
  49. package/harness/packages/server/src/routes/files.js +22 -26
  50. package/harness/packages/server/src/routes/folders.js +9 -17
  51. package/harness/packages/server/src/routes/git.js +35 -0
  52. package/harness/packages/server/src/routes/procs.js +25 -15
  53. package/harness/packages/server/src/routes/sessions.js +12 -19
  54. package/harness/packages/server/src/routes/shell.js +19 -0
  55. package/harness/packages/server/src/routes/state.js +32 -0
  56. package/harness/packages/server/src/routes/tts.js +82 -0
  57. package/harness/packages/server/src/server.js +25 -1
  58. package/harness/packages/server/test/announcer.test.js +51 -0
  59. package/harness/packages/server/test/reconcile.test.js +146 -0
  60. package/harness/packages/server/test/relay.test.js +219 -0
  61. package/harness/packages/server/test/runStream.test.js +245 -0
  62. package/harness/packages/server/test/security.test.js +252 -0
  63. package/harness/packages/server/test/server.test.js +617 -1
  64. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  65. package/harness/packages/server/test/tts.test.js +133 -0
  66. package/package.json +1 -1
@@ -0,0 +1,92 @@
1
+ // Frases habladas del agente: un resumen corto de lo que esta haciendo, armado
2
+ // por plantillas a partir de los eventos del run (sin llamar al modelo). Se
3
+ // adjuntan como `say` a los eventos del turno (start/tool_call/done) para que
4
+ // la consola las sintetice con el TTS, y las usa Telegram para la nota de voz
5
+ // final. La idea es narrar el progreso, no leer la respuesta entera.
6
+
7
+ const MAX_FRASE = 200;
8
+
9
+ /** Recorta una frase al tope, sin cortar una palabra a la mitad. */
10
+ function recortar(frase) {
11
+ const value = String(frase ?? '').replace(/\s+/g, ' ').trim();
12
+ if (value.length <= MAX_FRASE) return value;
13
+ return `${value.slice(0, MAX_FRASE - 1).replace(/\s+\S*$/, '')}…`;
14
+ }
15
+
16
+ const VERBOS = {
17
+ read: 'Leyendo',
18
+ list: 'Explorando',
19
+ glob: 'Buscando',
20
+ grep: 'Buscando',
21
+ write: 'Escribiendo',
22
+ edit: 'Editando',
23
+ bash: 'Ejecutando',
24
+ fetch_url: 'Consultando',
25
+ git_status: 'Mirando el estado de git',
26
+ git_diff: 'Mirando los cambios de git',
27
+ git_log: 'Mirando el historial de git',
28
+ git_commit: 'Commiteando',
29
+ task: 'Delegando en un subagente',
30
+ spawn_agent: 'Delegando en un subagente',
31
+ };
32
+
33
+ /**
34
+ * Frase para un tool-call: verbo + resumen de la tool. Devuelve '' para tools
35
+ * mudas (la consola ya las colapsa por ser ruido de lectura).
36
+ */
37
+ export function fraseTool(name, summary) {
38
+ const tool = String(name ?? '');
39
+ if (tool.startsWith('mcp__')) {
40
+ const server = tool.split('__')[1] ?? '';
41
+ return server ? `Usando una herramienta de ${server}` : '';
42
+ }
43
+ const verbo = VERBOS[tool];
44
+ if (!verbo) return '';
45
+ if (tool === 'git_status' || tool === 'git_diff' || tool === 'git_log' || tool === 'git_commit') {
46
+ return summary ? `${verbo}: ${summary}` : verbo;
47
+ }
48
+ if (!summary) return verbo;
49
+ return recortar(`${verbo} ${summary}`);
50
+ }
51
+
52
+ /** Frase de arranque del turno. */
53
+ export function fraseInicio() {
54
+ return 'Empecé a trabajar en tu pedido.';
55
+ }
56
+
57
+ /** Frase de cierre: rondas, herramientas y duracion, o el motivo del corte. */
58
+ export function fraseFin({ stopReason, rounds, toolCalls, durationMs } = {}) {
59
+ const segundos = Math.round((Number(durationMs) || 0) / 1000);
60
+ const tiempo = segundos >= 1 ? `, ${segundos} segundo${segundos === 1 ? '' : 's'}` : '';
61
+ if (stopReason === 'aborted') return 'Cancelado.';
62
+ if (['max_rounds', 'no_progress', 'budget'].includes(stopReason)) {
63
+ return `Terminé por un corte de seguridad tras ${rounds ?? '?'} ronda${rounds === 1 ? '' : 's'}${tiempo}.`;
64
+ }
65
+ const rondas = Number(rounds) || 0;
66
+ const herramientas = Number(toolCalls) || 0;
67
+ if (!herramientas) return 'Listo. Respondí sin usar herramientas.';
68
+ return `Listo. ${rondas} ronda${rondas === 1 ? '' : 's'}, ${herramientas} herramienta${herramientas === 1 ? '' : 's'}${tiempo}.`;
69
+ }
70
+
71
+ /** Frase para un error del turno. */
72
+ export function fraseError() {
73
+ return 'Ocurrió un error.';
74
+ }
75
+
76
+ /**
77
+ * Filtro con memoria para no hablar cada tool-call: descarta la misma frase
78
+ * consecutiva y aplica una ventana minima entre frases de tools.
79
+ */
80
+ export function createFiltroTools({ minGapMs = 20000 } = {}) {
81
+ let ultima = 0;
82
+ let ultimaFrase = '';
83
+ return (name, summary) => {
84
+ const frase = fraseTool(name, summary);
85
+ if (!frase || frase === ultimaFrase) return '';
86
+ const ahora = Date.now();
87
+ if (ahora - ultima < minGapMs) return '';
88
+ ultima = ahora;
89
+ ultimaFrase = frase;
90
+ return frase;
91
+ };
92
+ }
@@ -4,6 +4,11 @@
4
4
  * event; the console answers by POSTing to `/api/permissions/:id`, which calls
5
5
  * `decide`. Requests that nobody answers expire to `false` (deny).
6
6
  *
7
+ * `ask` guarda tambien los `meta` de que se pidio. Eso es lo que permite que un
8
+ * cliente que **no** esta suscrito al stream de la sesion sepa igual que hay un
9
+ * permiso esperando: sin esto el permiso solo existe dentro del SSE y un celular
10
+ * sin stream no tiene forma de enterarse.
11
+ *
7
12
  * @param {object} [opts]
8
13
  * @param {number} [opts.timeoutMs] - how long to wait before denying
9
14
  */
@@ -20,12 +25,16 @@ export function createApprovalBroker({ timeoutMs = 300000 } = {}) {
20
25
  }
21
26
 
22
27
  return {
23
- /** Register a pending request; resolves to a boolean decision. */
24
- ask(id) {
28
+ /**
29
+ * Register a pending request; resolves to a boolean decision.
30
+ * `meta` (sessionId, name, args...) es opcional y solo sirve para poder
31
+ * listar lo pendiente.
32
+ */
33
+ ask(id, meta = {}) {
25
34
  return new Promise((resolve) => {
26
35
  const timer = setTimeout(() => settle(id, false), timeoutMs);
27
36
  timer.unref?.();
28
- pending.set(id, { resolve, timer });
37
+ pending.set(id, { resolve, timer, requestedAt: Date.now(), ...meta });
29
38
  });
30
39
  },
31
40
 
@@ -44,6 +53,19 @@ export function createApprovalBroker({ timeoutMs = 300000 } = {}) {
44
53
  for (const id of [...pending.keys()]) settle(id, false);
45
54
  },
46
55
 
56
+ /**
57
+ * Lo que sigue esperando, para que cualquier cliente pueda mostrarlo sin
58
+ * depender de tener un stream abierto. `sessionId` filtra por sesion.
59
+ */
60
+ list({ sessionId } = {}) {
61
+ const items = [];
62
+ for (const [askId, entry] of pending) {
63
+ if (sessionId !== undefined && entry.sessionId !== sessionId) continue;
64
+ items.push({ askId, sessionId: entry.sessionId ?? null, name: entry.name ?? '', args: entry.args ?? {}, requestedAt: entry.requestedAt });
65
+ }
66
+ return items.sort((a, b) => a.requestedAt - b.requestedAt);
67
+ },
68
+
47
69
  get pending() {
48
70
  return pending.size;
49
71
  },
@@ -3,8 +3,18 @@
3
3
  // inline. Cada chat mapea a una sesion del harness.
4
4
 
5
5
  import { randomUUID } from 'node:crypto';
6
- import { runAgent, createTools, resolveMode, summarizeToolCall, listProviderModels, PROVIDER_META } from '../core.js';
6
+ import { runAgent, createTools, resolveMode, listProviderModels, PROVIDER_META, sintetizar, MAX_TEXTO } from '../core.js';
7
7
  import { resolveExistingDir } from '../workspace.js';
8
+ import {
9
+ combineToolset,
10
+ composeAgentMessages,
11
+ buildUserMessage,
12
+ resolveProviderFor,
13
+ toolCallPayload,
14
+ } from '../agentRun.js';
15
+ import { fraseFin } from '../announcer.js';
16
+ import { mp3AOgg } from '../ffmpeg.js';
17
+ import { resolveTts } from '../ttsSettings.js';
8
18
 
9
19
  const API = 'https://api.telegram.org';
10
20
 
@@ -61,6 +71,23 @@ async function downloadFile(token, fileId) {
61
71
  return Buffer.from(await res.arrayBuffer());
62
72
  }
63
73
 
74
+ /** Variante multipart de `tg` (sendVoice/sendAudio llevan el archivo adjunto). */
75
+ async function tgForm(token, method, form) {
76
+ const res = await fetch(`${API}/bot${token}/${method}`, { method: 'POST', body: form });
77
+ let data = null;
78
+ try {
79
+ data = await res.json();
80
+ } catch {
81
+ data = null;
82
+ }
83
+ if (!data?.ok) {
84
+ const error = new Error(data?.description || `HTTP ${res.status}`);
85
+ error.status = res.status;
86
+ throw error;
87
+ }
88
+ return data.result;
89
+ }
90
+
64
91
  function splitMessage(text, max = 3900) {
65
92
  const value = String(text ?? '').trim() || '(sin texto)';
66
93
  const parts = [];
@@ -115,6 +142,39 @@ export function startTelegramChannel(
115
142
  }
116
143
  }
117
144
 
145
+ /**
146
+ * Nota de voz con un texto cualquiera. Con ffmpeg manda OGG/Opus
147
+ * (`sendVoice`, aparece como nota de voz); sin ffmpeg cae a audio MP3.
148
+ * Un fallo del TTS no debe romper el run: se loguea y sigue.
149
+ */
150
+ async function enviarNotaDeVoz(chatId, texto, { voice, rate } = {}) {
151
+ const tts = resolveTts(ctx.app);
152
+ const frase = String(texto ?? '').trim().slice(0, MAX_TEXTO);
153
+ if (!frase) return;
154
+ try {
155
+ const mp3 = await sintetizar(frase, { voice: voice || tts.voice, rate: rate || tts.rate });
156
+ const ogg = await mp3AOgg(mp3);
157
+ const form = new FormData();
158
+ form.append('chat_id', chatId);
159
+ if (ogg) {
160
+ form.append('voice', new Blob([ogg], { type: 'audio/ogg' }), 'resumen.ogg');
161
+ await tgForm(token, 'sendVoice', form);
162
+ } else {
163
+ form.append('audio', new Blob([mp3], { type: 'audio/mpeg' }), 'resumen.mp3');
164
+ await tgForm(token, 'sendAudio', form);
165
+ }
166
+ } catch (error) {
167
+ log(`telegram: nota de voz falló: ${error?.message ?? error}`);
168
+ }
169
+ }
170
+
171
+ /** Resumen hablado del cierre del turno, según los ajustes de voz. */
172
+ async function sendResumenDeVoz(chatId, texto) {
173
+ const tts = resolveTts(ctx.app);
174
+ if (!tts.enabled || tts.announce.end === false) return;
175
+ await enviarNotaDeVoz(chatId, texto);
176
+ }
177
+
118
178
  function ensureSession(chatId) {
119
179
  const key = sessionKey(chatId);
120
180
  let sessionId = ctx.store.getSetting(key);
@@ -205,24 +265,10 @@ export function startTelegramChannel(
205
265
  });
206
266
  const exposeAdmin = !mode.tools;
207
267
  const admin = ctx.agentTools;
208
- const definitions = exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions;
209
- const execute = (name, args, context) =>
210
- exposeAdmin && admin.handles(name)
211
- ? admin.execute(name, args, { approve: onAsk })
212
- : local.execute(name, args, context);
213
- const { provider, config } = ctx.resolveProvider({
214
- ...ctx.providerSettings(providerId),
215
- apiKey: ctx.apiKeyFor(providerId),
216
- });
217
- const basePrompt = await ctx.systemPromptFor(cwd);
218
- const userMessage = images.length
219
- ? { role: 'user', content: [{ type: 'text', text: input }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] }
220
- : { role: 'user', content: input };
221
- const messages = [
222
- { role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
223
- ...history,
224
- userMessage,
225
- ];
268
+ const { definitions, execute } = combineToolset({ local, admin, exposeAdmin, approve: onAsk });
269
+ const { provider, config } = resolveProviderFor(ctx, providerId);
270
+ const userMessage = buildUserMessage(input, images);
271
+ const messages = await composeAgentMessages({ ctx, cwd, mode, history, userMessage });
226
272
 
227
273
  const controller = new AbortController();
228
274
  controllers.set(String(chatId), controller);
@@ -256,7 +302,10 @@ export function startTelegramChannel(
256
302
  if (event.type === 'text') {
257
303
  full = event.full;
258
304
  } else if (event.type === 'tool_call') {
259
- activity = `${event.name} ${summarizeToolCall(event.name, event.args)}`.trim();
305
+ activity = `${event.name} ${toolCallPayload(event).summary}`.trim();
306
+ if (event.name === 'speak') {
307
+ enviarNotaDeVoz(chatId, event.args?.texto, { voice: event.args?.voice, rate: event.args?.rate });
308
+ }
260
309
  } else if (event.type === 'tool_result') {
261
310
  activity = `${event.name} ✓`;
262
311
  } else {
@@ -290,6 +339,7 @@ export function startTelegramChannel(
290
339
  }
291
340
  const footer = completionFooter(result.stopReason, result.rounds, toolCalls, durationMs);
292
341
  await edit(chatId, message.message_id, `${body}\n\n— ${footer}`);
342
+ await sendResumenDeVoz(chatId, fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }));
293
343
  ctx.store.addMessages(sessionId, newMessages);
294
344
  ctx.store.touchSession(sessionId, { provider: providerId, model });
295
345
  } catch (error) {
@@ -527,8 +577,23 @@ export function startTelegramChannel(
527
577
  if (!chatId) return;
528
578
  if (event.type === 'done') {
529
579
  send(chatId, `✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`).catch(() => {});
580
+ sendResumenDeVoz(
581
+ chatId,
582
+ event.data?.say ??
583
+ fraseFin({
584
+ stopReason: event.data?.stopReason,
585
+ rounds: event.data?.rounds,
586
+ toolCalls: event.data?.toolCalls,
587
+ durationMs: event.data?.durationMs,
588
+ })
589
+ );
530
590
  } else if (event.type === 'error') {
531
591
  send(chatId, `✗ Error en la sesión: ${event.data?.message ?? ''}`).catch(() => {});
592
+ } else if (event.type === 'tool_call' && event.data?.name === 'speak') {
593
+ enviarNotaDeVoz(chatId, event.data?.args?.texto, {
594
+ voice: event.data?.args?.voice,
595
+ rate: event.data?.args?.rate,
596
+ });
532
597
  } else if (event.type === 'agent_end') {
533
598
  send(chatId, `🤖 Subagente ${event.data?.status}: ${String(event.data?.content ?? '').slice(0, 1500)}`).catch(() => {});
534
599
  } else if (event.type === 'permission') {
@@ -0,0 +1,48 @@
1
+ // Conversion MP3 -> OGG/Opus para las notas de voz de Telegram (sendVoice
2
+ // exige OGG con Opus). Telegram acepta MP3 solo como audio normal, asi que sin
3
+ // ffmpeg caemos a ese formato. Se detecta una sola vez por proceso.
4
+
5
+ import { spawn } from 'node:child_process';
6
+
7
+ let disponible = null;
8
+
9
+ /** `true` si hay un ffmpeg ejecutable en el PATH (se cachea). */
10
+ export function ffmpegDisponible() {
11
+ if (disponible === null) {
12
+ disponible = new Promise((resolve) => {
13
+ const hijo = spawn('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true });
14
+ hijo.on('error', () => resolve(false));
15
+ hijo.on('close', (code) => resolve(code === 0));
16
+ });
17
+ }
18
+ return disponible;
19
+ }
20
+
21
+ /**
22
+ * Convierte un buffer de MP3 a OGG/Opus por stdin/stdout.
23
+ * Devuelve el buffer o `null` si no hay ffmpeg o la conversion falla.
24
+ */
25
+ export async function mp3AOgg(mp3) {
26
+ if (!Buffer.isBuffer(mp3) || !mp3.length) return null;
27
+ if (!(await ffmpegDisponible())) return null;
28
+ return new Promise((resolve) => {
29
+ const hijo = spawn(
30
+ 'ffmpeg',
31
+ ['-hide_banner', '-loglevel', 'error', '-i', 'pipe:0', '-c:a', 'libopus', '-b:a', '32k', '-f', 'ogg', 'pipe:1'],
32
+ { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true }
33
+ );
34
+ const chunks = [];
35
+ let fallo = false;
36
+ hijo.stdout.on('data', (chunk) => chunks.push(chunk));
37
+ hijo.on('error', () => {
38
+ fallo = true;
39
+ resolve(null);
40
+ });
41
+ hijo.on('close', (code) => {
42
+ if (fallo) return;
43
+ resolve(code === 0 && chunks.length ? Buffer.concat(chunks) : null);
44
+ });
45
+ hijo.stdin.on('error', () => {});
46
+ hijo.stdin.end(mp3);
47
+ });
48
+ }
@@ -1,30 +1,15 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import path from 'node:path';
4
- import { DEFAULT_PROCESS_ALLOW } from './core.js';
4
+ import { DEFAULT_PROCESS_ALLOW, killPidTree } from './core.js';
5
5
 
6
6
  const MAX_LOG_LINES = 400;
7
7
 
8
8
  function killTree(child) {
9
9
  if (!child || child.killed) return;
10
- const pid = child.pid;
10
+ killPidTree(child.pid);
11
11
  try {
12
- if (process.platform === 'win32' && pid) {
13
- spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
14
- } else {
15
- if (pid) {
16
- try {
17
- process.kill(-pid, 'SIGTERM');
18
- } catch {
19
- /* sin grupo */
20
- }
21
- }
22
- try {
23
- child.kill('SIGTERM');
24
- } catch {
25
- /* ya murio */
26
- }
27
- }
12
+ child.kill('SIGTERM');
28
13
  } catch {
29
14
  /* ya murio */
30
15
  }
@@ -38,6 +38,25 @@ export function createRunManager({ store }) {
38
38
  }
39
39
  }
40
40
  }
41
+ notify(sessionId, type, data, event.id);
42
+ return event;
43
+ }
44
+
45
+ /**
46
+ * Ciclo de vida del run hacia los suscriptores globales (`subscribeAll`).
47
+ *
48
+ * A diferencia de `emit`, esto NO se persiste: es una notificacion, no parte
49
+ * del log del run. Persistirla desalinearia el cursor de `?from=` y ademas la
50
+ * borraria `clearSessionEvents` al empezar el siguiente run. El `id: 0` marca
51
+ * "esto no viene del log".
52
+ */
53
+ function announce(sessionId, phase, extra = {}) {
54
+ const type = phase === 'start' ? 'run_start' : 'run_end';
55
+ notify(sessionId, type, { sessionId, ...extra }, 0);
56
+ }
57
+
58
+ function notify(sessionId, type, data, id) {
59
+ const event = { id, type, data, agentId: null };
41
60
  for (const listener of [...listeners]) {
42
61
  try {
43
62
  listener(sessionId, event);
@@ -45,7 +64,6 @@ export function createRunManager({ store }) {
45
64
  // un listener caido no debe frenar el run
46
65
  }
47
66
  }
48
- return event;
49
67
  }
50
68
 
51
69
  function start(sessionId) {
@@ -54,6 +72,7 @@ export function createRunManager({ store }) {
54
72
  const run = { sessionId, controller, subscribers: new Set(), lastEvent: null, startedAt: Date.now() };
55
73
  runs.set(sessionId, run);
56
74
  store.clearSessionEvents(sessionId);
75
+ announce(sessionId, 'start', { at: run.startedAt });
57
76
  return run;
58
77
  }
59
78
 
@@ -62,6 +81,7 @@ export function createRunManager({ store }) {
62
81
  if (!run) return;
63
82
  run.subscribers.clear();
64
83
  runs.delete(sessionId);
84
+ announce(sessionId, 'end', { durationMs: Date.now() - run.startedAt });
65
85
  }
66
86
 
67
87
  function cancel(sessionId) {
@@ -69,8 +89,10 @@ export function createRunManager({ store }) {
69
89
  }
70
90
 
71
91
  function cancelAll() {
72
- for (const run of runs.values()) run.controller.abort();
92
+ const active = [...runs.values()];
93
+ for (const run of active) run.controller.abort();
73
94
  runs.clear();
95
+ for (const run of active) announce(run.sessionId, 'end', { durationMs: Date.now() - run.startedAt, reason: 'shutdown' });
74
96
  }
75
97
 
76
98
  function isActive(sessionId) {
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Vista publica de una sesion, compartida por `/api/sessions` y `/api/state`.
3
+ *
4
+ * Vive aparte para que las dos rutas no diverjan: el cliente reconcilia contra
5
+ * `/api/state` y dibuja el sidebar con esa misma forma, asi que un campo nuevo
6
+ * tiene que salir de un solo lugar.
7
+ */
8
+ export function sessionView(session) {
9
+ return {
10
+ id: session.id,
11
+ title: session.title,
12
+ provider: session.provider,
13
+ model: session.model,
14
+ cwd: session.cwd,
15
+ goal: session.meta?.goal ?? null,
16
+ hasSummary: Boolean(session.meta?.summary),
17
+ createdAt: session.created_at,
18
+ updatedAt: session.updated_at,
19
+ };
20
+ }
21
+
22
+ /** La sesion + su estado de vida, tal como la ve el cliente. */
23
+ export function sessionState(session, ctx, awaiting = 0) {
24
+ return {
25
+ ...sessionView(session),
26
+ running: ctx.runs?.isActive(session.id) ?? false,
27
+ awaiting,
28
+ };
29
+ }
30
+
31
+ /**
32
+ * Cuenta los permisos pendientes por sesion.
33
+ *
34
+ * "Trabajando" y "esperandote a vos" son cosas distintas: un run que avanza solo
35
+ * no necesita tu atencion, un permiso si.
36
+ */
37
+ export function awaitingBySession(pending) {
38
+ const counts = new Map();
39
+ for (const ask of pending) {
40
+ counts.set(ask.sessionId, (counts.get(ask.sessionId) ?? 0) + 1);
41
+ }
42
+ return counts;
43
+ }
@@ -3,7 +3,8 @@
3
3
  // del subagente se emiten con `agentId` para que la consola los anide, y su
4
4
  // resumen final se devuelve como resultado de la tool.
5
5
 
6
- import { runAgent, createTools, resolveMode, summarizeToolCall, previewToolResult } from './core.js';
6
+ import { runAgent, createTools, resolveMode } from './core.js';
7
+ import { toolCallPayload, toolResultPayload } from './agentRun.js';
7
8
 
8
9
  const SUBAGENT_PROMPT = [
9
10
  'Sos un subagente de Tamnora. Ejecutá de forma autónoma la tarea encomendada',
@@ -91,15 +92,9 @@ export function createSubagentTool(ctx, options) {
91
92
  if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
92
93
  else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
93
94
  else if (event.type === 'tool_call') {
94
- emit(
95
- 'tool_call',
96
- { id: event.id, name: event.name, args: event.args, summary: summarizeToolCall(event.name, event.args), agentId },
97
- agentId
98
- );
95
+ emit('tool_call', { ...toolCallPayload(event), agentId }, agentId);
99
96
  } else if (event.type === 'tool_result') {
100
- const { preview, truncated } = previewToolResult(event.name, event.result);
101
- const failed = /^Error|^Permiso denegado/i.test(String(event.result));
102
- emit('tool_result', { id: event.id, name: event.name, preview, truncated, failed, agentId }, agentId);
97
+ emit('tool_result', { id: event.id, name: event.name, ...toolResultPayload(event), agentId }, agentId);
103
98
  }
104
99
  },
105
100
  });
@@ -0,0 +1,42 @@
1
+ // Config de voz (TTS) efectiva: lo guardado en app.json (la casa) sobre los
2
+ // defaults. La leen la ruta de ajustes y el canal de Telegram en el momento de
3
+ // hablar, asi un cambio no necesita recargar nada.
4
+
5
+ import { VOZ_POR_DEFECTO } from './core.js';
6
+
7
+ export const DEFAULT_TTS = {
8
+ enabled: false,
9
+ voice: VOZ_POR_DEFECTO,
10
+ rate: '+0%',
11
+ announce: { start: true, tools: true, end: true },
12
+ };
13
+
14
+ /** Merge de defaults + lo guardado (con `announce` aplanado a booleans). */
15
+ export function resolveTts(app) {
16
+ const saved = app?.tts && typeof app.tts === 'object' ? app.tts : {};
17
+ const announce = { ...DEFAULT_TTS.announce, ...(saved.announce ?? {}) };
18
+ for (const key of ['start', 'tools', 'end']) announce[key] = announce[key] === true;
19
+ return {
20
+ ...DEFAULT_TTS,
21
+ ...saved,
22
+ announce,
23
+ enabled: saved.enabled === true,
24
+ voice: typeof saved.voice === 'string' && saved.voice ? saved.voice : DEFAULT_TTS.voice,
25
+ rate: typeof saved.rate === 'string' && saved.rate ? saved.rate : DEFAULT_TTS.rate,
26
+ };
27
+ }
28
+
29
+ /** Sanitiza un nombre de voz (ej. "es-AR-ElenaNeural"). */
30
+ export function sanearVoz(value, fallback = VOZ_POR_DEFECTO) {
31
+ const voz = String(value ?? '').trim();
32
+ return /^[a-zA-Z]{2}-[a-zA-Z]{2}-[a-zA-Z]+$/.test(voz) ? voz : fallback;
33
+ }
34
+
35
+ /** Sanitiza una velocidad: "+20%", "-15%", "20%" -> "+20%". */
36
+ export function sanearRate(value, fallback = '+0%') {
37
+ const match = /^([+-]?)(\d+)%$/.exec(String(value ?? '').trim());
38
+ if (!match) return fallback;
39
+ const numero = Math.min(100, Number(match[2]));
40
+ if (numero === 0) return '+0%';
41
+ return `${match[1] === '-' ? '-' : '+'}${numero}%`;
42
+ }
@@ -1,5 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
+ import { sendError } from './http.js';
4
+ import { tunnelState } from './core.js';
3
5
 
4
6
  /** True for 127.x / ::1 / ::ffff:127.x addresses. */
5
7
  export function isLoopback(address) {
@@ -7,6 +9,24 @@ export function isLoopback(address) {
7
9
  return addr === '::1' || addr === '0:0:0:0:0:0:0:1' || addr.startsWith('127.') || addr === '::ffff:127.0.0.1';
8
10
  }
9
11
 
12
+ /**
13
+ * "Local de verdad": el par TCP es loopback (remoteAddress, no localAddress:
14
+ * esa es la direccion donde bindea el server, siempre 127.0.0.1) y no hay un
15
+ * tunel publico activo. Con el tunel abierto las peticiones remotas TAMBIEN
16
+ * llegan desde loopback (el cliente del tunel corre en la misma maquina), asi
17
+ * que la ausencia de tunel es parte del check.
18
+ */
19
+ export function localOk(req) {
20
+ return isLoopback(req?.socket?.remoteAddress) && !tunnelState();
21
+ }
22
+
23
+ /** `localOk` o 403. Devuelve true si la request puede seguir. */
24
+ export function requireLocal(req, res, mensaje = 'Solo disponible en conexiones locales.') {
25
+ if (localOk(req)) return true;
26
+ sendError(res, 403, tunnelState() ? `${mensaje} Hay un túnel público activo.` : mensaje);
27
+ return false;
28
+ }
29
+
10
30
  /** Filesystem roots (drive letters on Windows, `/` elsewhere). */
11
31
  export function rootDirs() {
12
32
  if (process.platform !== 'win32') return ['/'];
@@ -40,6 +60,22 @@ export function resolveExistingDir(input) {
40
60
  return target;
41
61
  }
42
62
 
63
+ /** `resolveExistingDir` con 400 en vez de throw (patron comun en las routes). */
64
+ export function resolveDirOr400(res, raw, fallback) {
65
+ try {
66
+ return resolveExistingDir(raw || fallback);
67
+ } catch (error) {
68
+ sendError(res, 400, error.message);
69
+ return null;
70
+ }
71
+ }
72
+
73
+ /** True si `target` es `root` o vive dentro de el (sin salirse por `..`). */
74
+ export function isInside(root, target) {
75
+ const rel = path.relative(root, target);
76
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
77
+ }
78
+
43
79
  /**
44
80
  * Workspace for an HTTP request: the requested `cwd` (loopback-only) or the
45
81
  * server's default. Throws on invalid input.
@@ -47,7 +83,7 @@ export function resolveExistingDir(input) {
47
83
  export function workspaceForRequest({ req, raw, fallback }) {
48
84
  const value = String(raw ?? '').trim();
49
85
  if (!value) return fallback;
50
- if (!isLoopback(req.socket.localAddress)) {
86
+ if (!localOk(req)) {
51
87
  throw new Error('Cambiar la carpeta de trabajo solo está permitido en conexiones locales.');
52
88
  }
53
89
  return resolveExistingDir(value);