@danieltmn/tamnora 0.8.0 → 0.9.1

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 (57) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/harness/packages/cli/src/cli.js +1 -1
  3. package/harness/packages/cli/src/commands/bridge.js +180 -119
  4. package/harness/packages/cli/src/commands/server.js +26 -50
  5. package/harness/packages/cli/src/lib/daemon.js +92 -40
  6. package/harness/packages/cli/test/release.test.js +9 -0
  7. package/harness/packages/core/src/agent/modes.js +1 -1
  8. package/harness/packages/core/src/index.js +14 -1
  9. package/harness/packages/core/src/open.js +43 -0
  10. package/harness/packages/core/src/pid.js +35 -0
  11. package/harness/packages/core/src/tools/executors.js +16 -0
  12. package/harness/packages/core/src/tools/labels.js +2 -0
  13. package/harness/packages/core/src/tools/permissions.js +1 -0
  14. package/harness/packages/core/src/tools/schema.js +17 -0
  15. package/harness/packages/core/src/tts/edge.js +232 -0
  16. package/harness/packages/core/src/tunnel.js +180 -19
  17. package/harness/packages/core/test/open.test.js +19 -0
  18. package/harness/packages/core/test/tools.test.js +12 -0
  19. package/harness/packages/core/test/tts.test.js +41 -0
  20. package/harness/packages/server/public/app.js +4665 -3975
  21. package/harness/packages/server/public/index.html +62 -5
  22. package/harness/packages/server/public/reconcile.js +121 -0
  23. package/harness/packages/server/public/relayClient.js +170 -0
  24. package/harness/packages/server/public/runStream.js +207 -0
  25. package/harness/packages/server/public/styles.css +142 -5
  26. package/harness/packages/server/src/auth.js +29 -5
  27. package/harness/packages/server/src/lib/agentRun.js +77 -0
  28. package/harness/packages/server/src/lib/announcer.js +92 -0
  29. package/harness/packages/server/src/lib/approvals.js +25 -3
  30. package/harness/packages/server/src/lib/channels/telegram.js +85 -20
  31. package/harness/packages/server/src/lib/ffmpeg.js +48 -0
  32. package/harness/packages/server/src/lib/procs.js +3 -18
  33. package/harness/packages/server/src/lib/runManager.js +24 -2
  34. package/harness/packages/server/src/lib/sessionView.js +43 -0
  35. package/harness/packages/server/src/lib/tasks.js +4 -9
  36. package/harness/packages/server/src/lib/ttsSettings.js +42 -0
  37. package/harness/packages/server/src/lib/workspace.js +37 -1
  38. package/harness/packages/server/src/routes/chat.js +48 -47
  39. package/harness/packages/server/src/routes/events.js +35 -0
  40. package/harness/packages/server/src/routes/files.js +22 -26
  41. package/harness/packages/server/src/routes/folders.js +9 -17
  42. package/harness/packages/server/src/routes/git.js +6 -14
  43. package/harness/packages/server/src/routes/procs.js +14 -16
  44. package/harness/packages/server/src/routes/sessions.js +12 -19
  45. package/harness/packages/server/src/routes/shell.js +5 -9
  46. package/harness/packages/server/src/routes/state.js +32 -0
  47. package/harness/packages/server/src/routes/tts.js +82 -0
  48. package/harness/packages/server/src/server.js +21 -1
  49. package/harness/packages/server/test/announcer.test.js +51 -0
  50. package/harness/packages/server/test/reconcile.test.js +146 -0
  51. package/harness/packages/server/test/relay.test.js +219 -0
  52. package/harness/packages/server/test/runStream.test.js +245 -0
  53. package/harness/packages/server/test/security.test.js +252 -0
  54. package/harness/packages/server/test/server.test.js +511 -1
  55. package/harness/packages/server/test/telegram-voz.test.js +156 -0
  56. package/harness/packages/server/test/tts.test.js +133 -0
  57. package/package.json +1 -1
@@ -177,6 +177,23 @@ html[data-theme='dark'] #theme-toggle .ico-moon { display: block; }
177
177
  .menu-pop button.active { background: var(--theirs); color: var(--accent); }
178
178
  .settings { grid-column: 3; justify-self: end; display: flex; align-items: center; gap: 8px; }
179
179
  .settings select { max-width: 220px; }
180
+
181
+ /* Nueva sesión en el topbar: texto en desktop, solo el icono en móvil, donde
182
+ el ancho es el recurso escaso. */
183
+ .topbar-new {
184
+ display: inline-flex;
185
+ align-items: center;
186
+ justify-content: center;
187
+ gap: 6px;
188
+ height: 34px;
189
+ padding: 0 13px;
190
+ flex: 0 0 auto;
191
+ }
192
+ .topbar-new svg { width: 18px; height: 18px; display: block; flex: 0 0 auto; }
193
+ .topbar-new-label { display: none; }
194
+ @media (min-width: 821px) {
195
+ .topbar-new-label { display: inline; }
196
+ }
180
197
  .check { color: var(--muted); display: flex; align-items: center; gap: 5px; cursor: pointer; }
181
198
 
182
199
  select, input, textarea {
@@ -204,6 +221,8 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
204
221
  cursor: pointer;
205
222
  font: 600 12.5px var(--sans);
206
223
  transition: 0.15s ease;
224
+ /* Sin esto, en una fila apretada "denegar" se parte en "den egar". */
225
+ white-space: nowrap;
207
226
  }
208
227
  .btn:hover { border-color: var(--accent); color: var(--text); }
209
228
  .btn.primary { background: var(--accent); border-color: var(--accent); color: var(--primary-fg); font-weight: 700; }
@@ -266,6 +285,29 @@ main { flex: 1; min-height: 0; min-width: 0; display: flex; }
266
285
  .session-item.active { background: var(--theirs); color: var(--text); border-color: var(--border); }
267
286
  .session-item .sub { color: var(--muted); font-size: 11px; overflow: hidden; text-overflow: ellipsis; }
268
287
  .session-item .session-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
288
+ .session-live {
289
+ flex: none;
290
+ width: 6px;
291
+ height: 6px;
292
+ border-radius: 50%;
293
+ background: var(--accent);
294
+ animation: live-pulse 1.4s ease-in-out infinite;
295
+ }
296
+ .session-await {
297
+ flex: none;
298
+ padding: 0 5px;
299
+ height: 14px;
300
+ line-height: 14px;
301
+ border-radius: 7px;
302
+ font-size: 10px;
303
+ font-weight: 700;
304
+ color: var(--bg);
305
+ background: var(--warn);
306
+ }
307
+ @keyframes live-pulse {
308
+ 0%, 100% { opacity: 1; }
309
+ 50% { opacity: 0.25; }
310
+ }
269
311
  .session-del,
270
312
  .group-del {
271
313
  flex: 0 0 auto;
@@ -558,6 +600,12 @@ body.resizing * { cursor: col-resize !important; }
558
600
  overflow: auto;
559
601
  }
560
602
  .tool-card > .tool-out:empty { display: none; }
603
+ .tool-card > .tool-audio {
604
+ display: block;
605
+ width: calc(100% - 28px);
606
+ height: 32px;
607
+ margin: 4px 0 2px 18px;
608
+ }
561
609
  .tool-more {
562
610
  display: inline-block;
563
611
  margin: 2px 0 2px 18px;
@@ -632,7 +680,7 @@ body.resizing * { cursor: col-resize !important; }
632
680
  background: rgba(210, 153, 34, 0.06);
633
681
  }
634
682
  .perm-card .perm-head { display: flex; gap: 7px; align-items: center; color: var(--warn); font-weight: 700; }
635
- .perm-card .perm-args { color: var(--muted); margin: 5px 0 9px; white-space: pre-wrap; word-break: break-word; }
683
+ .perm-card .perm-args { color: var(--muted); margin: 5px 0 9px; white-space: pre-wrap; word-break: break-word; max-height: 7.5em; overflow: auto; }
636
684
  .perm-card .perm-actions { display: flex; align-items: center; gap: 8px; }
637
685
  .perm-card .spacer { flex: 1; }
638
686
  .perm-card.approved { border-color: var(--ok); background: rgba(63, 185, 80, 0.06); }
@@ -727,6 +775,71 @@ details.reasoning .body { color: var(--muted); white-space: pre-wrap; margin-top
727
775
  .queue-remove:hover { color: var(--danger); background: var(--theirs); }
728
776
  .shell-out { margin: 0; padding: 8px 10px; max-height: 320px; overflow: auto; background: var(--theirs); border: 1px solid var(--border); border-radius: 8px; font: 11.5px/1.5 var(--mono); white-space: pre-wrap; word-break: break-word; }
729
777
  .attach-preview { display: flex; gap: 8px; flex-wrap: wrap; padding-bottom: 2px; }
778
+ .sb-channel {
779
+ font-size: 11px;
780
+ color: var(--warn);
781
+ border: 1px solid currentColor;
782
+ border-radius: 9px;
783
+ padding: 0 6px;
784
+ cursor: help;
785
+ }
786
+ .sb-channel.retrying { animation: live-pulse 1.4s ease-in-out infinite; }
787
+ .toasts {
788
+ /* Encima del composer, no sobre el: al borde inferior tapaba el banner y los
789
+ botones, y cada toast (con `pointer-events: auto`) les robaba los clics. */
790
+ position: absolute;
791
+ bottom: calc(100% + 6px);
792
+ left: 10px;
793
+ right: 10px;
794
+ display: flex;
795
+ flex-direction: column;
796
+ gap: 5px;
797
+ pointer-events: none;
798
+ z-index: 5;
799
+ }
800
+ .toast {
801
+ pointer-events: auto;
802
+ cursor: pointer;
803
+ padding: 7px 11px;
804
+ border: 1px solid var(--border);
805
+ border-left: 3px solid var(--warn);
806
+ border-radius: 8px;
807
+ background: var(--panel);
808
+ color: var(--text);
809
+ font-size: 12.5px;
810
+ box-shadow: 0 6px 18px rgba(0, 0, 0, 0.18);
811
+ }
812
+
813
+ /* Banner de permisos: se decide desde cualquier pestana o sesion, asi que vive
814
+ en el composer y no dentro del mensaje de una sesion. */
815
+ .perm-banner { display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; }
816
+ .perm-banner[hidden] { display: none; }
817
+ .perm-banner-row {
818
+ display: flex;
819
+ flex-direction: column;
820
+ gap: 5px;
821
+ padding: 9px 11px;
822
+ border: 1px solid var(--warn);
823
+ border-radius: 9px;
824
+ background: rgba(217, 112, 74, 0.08);
825
+ }
826
+ .perm-banner-label { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
827
+ .perm-banner-icon { color: var(--warn); font-weight: 700; }
828
+ .perm-banner-title { font-weight: 700; color: var(--text); }
829
+ .perm-banner-session { font-size: 11.5px; color: var(--muted); border: 1px solid var(--border); border-radius: 8px; padding: 0 6px; }
830
+ .perm-banner-args {
831
+ color: var(--muted);
832
+ font-family: var(--mono, monospace);
833
+ font-size: 11.5px;
834
+ /* Recorte limpio: `overflow` a secas cortaba la linea por la mitad y se
835
+ leia como texto roto. */
836
+ display: -webkit-box;
837
+ -webkit-line-clamp: 2;
838
+ -webkit-box-orient: vertical;
839
+ overflow: hidden;
840
+ word-break: break-word;
841
+ }
842
+ .perm-banner-row > .btn { align-self: flex-start; }
730
843
  .attach-thumb { position: relative; }
731
844
  .attach-thumb img { height: 64px; border-radius: 6px; border: 1px solid var(--border); display: block; }
732
845
  .attach-remove {
@@ -862,6 +975,9 @@ pre.code code { background: none; border: 0; padding: 0; font-size: 0.92em; }
862
975
 
863
976
  .card { border: 1px solid var(--border); background: var(--panel); backdrop-filter: blur(14px); border-radius: 14px; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
864
977
 
978
+ /* ajustes: voz (TTS) -------------------------------------------------------- */
979
+ .voz-announce { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); padding: 8px 0; }
980
+
865
981
  /* ajustes: pestañas internas ---------------------------------------------- */
866
982
  #view-ajustes.active { display: block; overflow-y: auto; }
867
983
  .ajustes-wrap { width: 100%; max-width: 780px; margin: 0 auto; padding: 18px; display: flex; flex-direction: column; gap: 12px; min-height: 100%; }
@@ -1202,10 +1318,13 @@ dialog.file-modal.preview-maximized {
1202
1318
  .topbar {
1203
1319
  position: relative;
1204
1320
  display: flex;
1205
- flex-wrap: wrap;
1321
+ /* Una sola fila: con `wrap` los botones de accion caian a una segunda
1322
+ linea y empujaban el chat hacia abajo. */
1323
+ flex-wrap: nowrap;
1206
1324
  gap: 8px 10px;
1207
1325
  padding: 8px 12px;
1208
1326
  }
1327
+ .brand { flex: 0 0 auto; }
1209
1328
  .brand .ver {
1210
1329
  display: none;
1211
1330
  }
@@ -1232,13 +1351,20 @@ dialog.file-modal.preview-maximized {
1232
1351
  .settings {
1233
1352
  grid-column: auto;
1234
1353
  margin-left: 0;
1235
- flex: 1 1 100%;
1236
- flex-wrap: wrap;
1237
- gap: 8px;
1354
+ /* Antes `1 1 100%`: eso ocupaba toda la fila y mandaba los botones a una
1355
+ segunda linea. Ahora se quedan en la misma linea que TH. */
1356
+ flex: 0 0 auto;
1357
+ flex-wrap: nowrap;
1358
+ gap: 6px;
1238
1359
  }
1239
1360
  .settings .btn.icon {
1240
1361
  flex: 0 0 auto;
1241
1362
  }
1363
+ /* Cuadrado como los demas iconos: el texto no entra en un movil angosto. */
1364
+ .topbar-new {
1365
+ width: 34px;
1366
+ padding: 0;
1367
+ }
1242
1368
 
1243
1369
  /* sidebar como drawer deslizante */
1244
1370
  .sidebar {
@@ -1296,6 +1422,17 @@ dialog.file-modal.preview-maximized {
1296
1422
  }
1297
1423
 
1298
1424
  @media (max-width: 480px) {
1425
+ /* Cinco elementos en una fila no entran en un movil angosto: el nombre del
1426
+ tab activo es lo unico que sobra, y el menu de puntos ya lo deja claro. */
1427
+ .topbar {
1428
+ gap: 8px 8px;
1429
+ }
1430
+ #more-tab-label {
1431
+ display: none;
1432
+ }
1433
+ .settings {
1434
+ gap: 5px;
1435
+ }
1299
1436
  .settings select {
1300
1437
  flex: 1 1 100%;
1301
1438
  }
@@ -1,5 +1,7 @@
1
1
  import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
2
2
  import { sendError } from './lib/http.js';
3
+ import { tunnelState } from './lib/core.js';
4
+ import { isLoopback } from './lib/workspace.js';
3
5
 
4
6
  /**
5
7
  * Autenticacion web con cookie de sesion firmada (stateless), CSRF y bloqueo
@@ -37,14 +39,25 @@ export function parseCookies(header) {
37
39
  }
38
40
 
39
41
  // Detras del tunel el server recibe desde loopback: solo ahi confiamos en
40
- // X-Forwarded-Proto para marcar la cookie Secure.
42
+ // X-Forwarded-Proto para marcar la cookie Secure. Como el cliente del tunel
43
+ // corre en la misma maquina, un request por el tunel es indistinguible de uno
44
+ // local por direccion: usamos el Host (localhost/127.0.0.1 = local) y el
45
+ // estado del bridge para no mandar la cookie sin Secure por el tunel.
46
+ function isLocalHost(req) {
47
+ const host = String(req.headers.host || '').split(':')[0].toLowerCase();
48
+ return !host || host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]';
49
+ }
50
+
41
51
  function isSecure(req) {
42
52
  if (req.socket && req.socket.encrypted) return true;
43
53
  const remote = req.socket && req.socket.remoteAddress;
44
- const loopback = !remote || remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1';
54
+ const loopback = !remote || isLoopback(remote);
45
55
  if (!loopback) return false;
46
56
  const proto = String(req.headers['x-forwarded-proto'] || '').split(',')[0].trim().toLowerCase();
47
- return proto === 'https';
57
+ if (proto === 'https') return true;
58
+ // Peticion por el tunel (host publico + bridge activo): la URL publica es
59
+ // HTTPS, la cookie no tiene que poder viajar por la variante http.
60
+ return Boolean(tunnelState()) && !isLocalHost(req);
48
61
  }
49
62
 
50
63
  function serializeCookie(value, req, maxAge) {
@@ -112,17 +125,27 @@ export function requireCsrf(app, req, res) {
112
125
  // ---------------------------------------------------------------------------
113
126
  // Bloqueo por intentos fallidos (en memoria: el server es un proceso).
114
127
  // ---------------------------------------------------------------------------
128
+ // La clave usa remoteAddress: X-Forwarded-For lo controla el cliente y con un
129
+ // header falsificado se rotaba de IP y se esquivaba el bloqueo.
115
130
  const MAX_ATTEMPTS = 5;
116
131
  const WINDOW_MS = 15 * 60 * 1000;
117
132
  const LOCK_MS = 15 * 60 * 1000;
133
+ const MAX_KEYS = 256;
118
134
  const attempts = new Map();
119
135
 
120
136
  function clientKey(req, user) {
121
- const xff = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
122
- const ip = xff || (req.socket && req.socket.remoteAddress) || 'desconocido';
137
+ const ip = (req.socket && req.socket.remoteAddress) || 'desconocido';
123
138
  return `${ip}|${String(user || '').trim().toLowerCase()}`;
124
139
  }
125
140
 
141
+ function sweepStale(now) {
142
+ if (attempts.size < MAX_KEYS) return;
143
+ for (const [key, st] of attempts) {
144
+ const expired = (!st.lockUntil || st.lockUntil < now) && now - st.first > WINDOW_MS;
145
+ if (expired) attempts.delete(key);
146
+ }
147
+ }
148
+
126
149
  export function loginLockRemaining(req, user) {
127
150
  const now = Date.now();
128
151
  const st = attempts.get(clientKey(req, user));
@@ -133,6 +156,7 @@ export function loginLockRemaining(req, user) {
133
156
 
134
157
  export function loginRecordFailure(req, user) {
135
158
  const now = Date.now();
159
+ sweepStale(now);
136
160
  const key = clientKey(req, user);
137
161
  let st = attempts.get(key);
138
162
  if (!st || (!st.lockUntil && now - st.first > WINDOW_MS)) st = { count: 0, first: now, lockUntil: 0 };
@@ -0,0 +1,77 @@
1
+ // Pedazos compartidos del scaffolding "correr un agente" (chat HTTP, Telegram,
2
+ // subagentes). La creacion de tools y el onEvent de cada canal siguen siendo
3
+ // propios (los canales de salida son distintos); lo combinable vive aca.
4
+
5
+ import { summarizeToolCall, previewToolResult } from './core.js';
6
+
7
+ const FAILED_RE = /^Error|^Permiso denegado/i;
8
+
9
+ /** Payload estandar de un tool_result para eventos SSE: preview + failed. */
10
+ export function toolResultPayload(event) {
11
+ const { preview, truncated } = previewToolResult(event.name, event.result);
12
+ return { preview, truncated, failed: FAILED_RE.test(String(event.result)) };
13
+ }
14
+
15
+ export function toolCallPayload(event) {
16
+ return { id: event.id, name: event.name, args: event.args, summary: summarizeToolCall(event.name, event.args) };
17
+ }
18
+
19
+ /**
20
+ * Combina tools locales + admin + subagentes en un toolset unificado.
21
+ * `approve` es el callback de aprobacion de las tools de administraación.
22
+ */
23
+ export function combineToolset({ local, admin = null, tasks = null, exposeAdmin = false, approve = null }) {
24
+ const definitions = [
25
+ ...local.definitions,
26
+ ...(exposeAdmin && admin ? admin.definitions : []),
27
+ ...(tasks ? tasks.definitions : []),
28
+ ];
29
+ const execute = (name, args, context) => {
30
+ if (tasks?.handles(name)) return tasks.execute(name, args);
31
+ if (exposeAdmin && admin?.handles(name)) {
32
+ return admin.execute(name, args, approve ? { approve } : undefined);
33
+ }
34
+ return local.execute(name, args, context);
35
+ };
36
+ return { definitions, execute };
37
+ }
38
+
39
+ /** Resuelve proveedor + config con la API key almacenada. */
40
+ export function resolveProviderFor(ctx, providerId, extra = {}) {
41
+ return ctx.resolveProvider({
42
+ ...ctx.providerSettings(providerId),
43
+ apiKey: ctx.apiKeyFor(providerId),
44
+ ...extra,
45
+ });
46
+ }
47
+
48
+ /** Mensaje del usuario, multinodal si hay imagenes (data URLs). */
49
+ export function buildUserMessage(input, images = []) {
50
+ if (images.length) {
51
+ return {
52
+ role: 'user',
53
+ content: [
54
+ { type: 'text', text: input },
55
+ ...images.map((url) => ({ type: 'image_url', image_url: { url } })),
56
+ ],
57
+ };
58
+ }
59
+ return { role: 'user', content: input };
60
+ }
61
+
62
+ /**
63
+ * Arma el array de mensajes del turno: system prompt (con el del modo) +
64
+ * objetivo/resumen de la sesion + historial + mensaje del usuario.
65
+ */
66
+ export async function composeAgentMessages({ ctx, cwd, mode, history = [], userMessage, sessionRecord = null }) {
67
+ const basePrompt = await ctx.systemPromptFor(cwd);
68
+ const goal = sessionRecord?.meta?.goal;
69
+ const summary = sessionRecord?.meta?.summary;
70
+ return [
71
+ { role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
72
+ ...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
73
+ ...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
74
+ ...history,
75
+ userMessage,
76
+ ];
77
+ }
@@ -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') {