@danieltmn/tamnora 0.8.0 → 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.
- package/CHANGELOG.md +83 -0
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +180 -119
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/lib/daemon.js +92 -40
- package/harness/packages/cli/test/release.test.js +9 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/index.js +14 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/tools/executors.js +16 -0
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/permissions.js +1 -0
- package/harness/packages/core/src/tools/schema.js +17 -0
- package/harness/packages/core/src/tts/edge.js +232 -0
- package/harness/packages/core/src/tunnel.js +180 -19
- package/harness/packages/core/test/open.test.js +19 -0
- package/harness/packages/core/test/tools.test.js +12 -0
- package/harness/packages/core/test/tts.test.js +41 -0
- package/harness/packages/server/public/app.js +4715 -3975
- package/harness/packages/server/public/index.html +63 -5
- package/harness/packages/server/public/reconcile.js +121 -0
- package/harness/packages/server/public/relayClient.js +170 -0
- package/harness/packages/server/public/runStream.js +207 -0
- package/harness/packages/server/public/styles.css +142 -5
- package/harness/packages/server/src/auth.js +29 -5
- package/harness/packages/server/src/lib/agentRun.js +77 -0
- package/harness/packages/server/src/lib/announcer.js +92 -0
- package/harness/packages/server/src/lib/approvals.js +25 -3
- package/harness/packages/server/src/lib/channels/telegram.js +85 -20
- package/harness/packages/server/src/lib/ffmpeg.js +48 -0
- package/harness/packages/server/src/lib/procs.js +3 -18
- package/harness/packages/server/src/lib/runManager.js +24 -2
- package/harness/packages/server/src/lib/sessionView.js +43 -0
- package/harness/packages/server/src/lib/tasks.js +4 -9
- package/harness/packages/server/src/lib/ttsSettings.js +42 -0
- package/harness/packages/server/src/lib/workspace.js +37 -1
- package/harness/packages/server/src/routes/chat.js +48 -47
- package/harness/packages/server/src/routes/events.js +35 -0
- package/harness/packages/server/src/routes/files.js +22 -26
- package/harness/packages/server/src/routes/folders.js +9 -17
- package/harness/packages/server/src/routes/git.js +6 -14
- package/harness/packages/server/src/routes/procs.js +14 -16
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +5 -9
- package/harness/packages/server/src/routes/state.js +32 -0
- package/harness/packages/server/src/routes/tts.js +82 -0
- package/harness/packages/server/src/server.js +21 -1
- package/harness/packages/server/test/announcer.test.js +51 -0
- package/harness/packages/server/test/reconcile.test.js +146 -0
- package/harness/packages/server/test/relay.test.js +219 -0
- package/harness/packages/server/test/runStream.test.js +245 -0
- package/harness/packages/server/test/security.test.js +252 -0
- package/harness/packages/server/test/server.test.js +511 -1
- package/harness/packages/server/test/telegram-voz.test.js +156 -0
- package/harness/packages/server/test/tts.test.js +133 -0
- 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
|
-
|
|
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
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
/**
|
|
24
|
-
|
|
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,
|
|
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 =
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
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} ${
|
|
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') {
|