@danieltmn/tamnora 0.5.1 → 0.6.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 +7 -0
- package/harness/packages/core/src/config/index.js +1 -0
- package/harness/packages/core/src/index.js +1 -0
- package/harness/packages/core/src/storage/sqlite.js +108 -2
- package/harness/packages/core/src/tools/labels.js +83 -0
- package/harness/packages/core/test/labels.test.js +34 -0
- package/harness/packages/server/public/app.js +436 -110
- package/harness/packages/server/public/index.html +1 -1
- package/harness/packages/server/public/styles.css +95 -4
- package/harness/packages/server/src/lib/channels/index.js +43 -0
- package/harness/packages/server/src/lib/{telegram.js → channels/telegram.js} +157 -26
- package/harness/packages/server/src/lib/http.js +3 -2
- package/harness/packages/server/src/lib/runManager.js +102 -0
- package/harness/packages/server/src/lib/tasks.js +126 -0
- package/harness/packages/server/src/routes/chat.js +161 -77
- package/harness/packages/server/src/routes/sessions.js +17 -0
- package/harness/packages/server/src/server.js +12 -16
- package/harness/packages/server/test/server.test.js +129 -1
- package/harness/tamnora.example.json +2 -1
- package/package.json +1 -1
|
@@ -427,8 +427,38 @@ body.resizing * { cursor: col-resize !important; }
|
|
|
427
427
|
.msg-actions button.msg-act.danger:hover { color: var(--danger); border-color: var(--danger); }
|
|
428
428
|
.bubble .tools { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
|
|
429
429
|
|
|
430
|
+
.tools-actions { display: flex; align-items: center; gap: 8px; }
|
|
431
|
+
.tools-actions[hidden] { display: none; }
|
|
432
|
+
.tools-count { color: var(--muted); font-size: 12px; }
|
|
433
|
+
.tools-toggle {
|
|
434
|
+
background: none;
|
|
435
|
+
border: none;
|
|
436
|
+
color: var(--accent);
|
|
437
|
+
cursor: pointer;
|
|
438
|
+
font: 600 12px var(--sans);
|
|
439
|
+
padding: 2px 0;
|
|
440
|
+
}
|
|
441
|
+
.tools-toggle:hover { text-decoration: underline; }
|
|
442
|
+
|
|
430
443
|
.tool-card { font-size: 12.5px; border-left: 2px solid var(--border); padding-left: 10px; min-width: 0; }
|
|
431
|
-
.tool-card .tool-head {
|
|
444
|
+
.tool-card > .tool-head {
|
|
445
|
+
display: flex;
|
|
446
|
+
gap: 8px;
|
|
447
|
+
align-items: baseline;
|
|
448
|
+
min-width: 0;
|
|
449
|
+
cursor: pointer;
|
|
450
|
+
list-style: none;
|
|
451
|
+
touch-action: manipulation;
|
|
452
|
+
}
|
|
453
|
+
.tool-card > .tool-head::-webkit-details-marker { display: none; }
|
|
454
|
+
.tool-card > .tool-head::before {
|
|
455
|
+
content: '▸';
|
|
456
|
+
color: var(--muted);
|
|
457
|
+
flex: 0 0 auto;
|
|
458
|
+
font-size: 10px;
|
|
459
|
+
transition: transform 0.12s ease;
|
|
460
|
+
}
|
|
461
|
+
.tool-card[open] > .tool-head::before { transform: rotate(90deg); }
|
|
432
462
|
.tool-card .tool-glyph { color: var(--muted); flex: 0 0 auto; }
|
|
433
463
|
.tool-card.running .tool-glyph { color: var(--accent); animation: pulse 1s steps(2, start) infinite; }
|
|
434
464
|
.tool-card.done .tool-glyph { color: var(--ok); }
|
|
@@ -442,17 +472,78 @@ body.resizing * { cursor: col-resize !important; }
|
|
|
442
472
|
text-overflow: ellipsis;
|
|
443
473
|
white-space: nowrap;
|
|
444
474
|
}
|
|
445
|
-
.tool-card .tool-out {
|
|
475
|
+
.tool-card > .tool-out {
|
|
446
476
|
color: var(--muted);
|
|
447
477
|
margin: 4px 0 2px 18px;
|
|
448
478
|
padding-left: 10px;
|
|
449
479
|
border-left: 1px solid var(--border);
|
|
450
480
|
white-space: pre-wrap;
|
|
451
481
|
word-break: break-word;
|
|
452
|
-
max-height:
|
|
482
|
+
max-height: 320px;
|
|
453
483
|
overflow: auto;
|
|
454
484
|
}
|
|
455
|
-
.tool-card .tool-out:empty { display: none; }
|
|
485
|
+
.tool-card > .tool-out:empty { display: none; }
|
|
486
|
+
.tool-more {
|
|
487
|
+
display: inline-block;
|
|
488
|
+
margin: 2px 0 2px 18px;
|
|
489
|
+
background: none;
|
|
490
|
+
border: none;
|
|
491
|
+
color: var(--accent);
|
|
492
|
+
cursor: pointer;
|
|
493
|
+
font: 600 12px var(--sans);
|
|
494
|
+
padding: 0;
|
|
495
|
+
}
|
|
496
|
+
.tool-more[hidden] { display: none; }
|
|
497
|
+
.tool-more:hover { text-decoration: underline; }
|
|
498
|
+
|
|
499
|
+
.agent-card {
|
|
500
|
+
border: 1px solid var(--border);
|
|
501
|
+
border-radius: 7px;
|
|
502
|
+
padding: 6px 9px;
|
|
503
|
+
background: color-mix(in srgb, var(--accent) 4%, transparent);
|
|
504
|
+
min-width: 0;
|
|
505
|
+
}
|
|
506
|
+
.agent-card > .agent-head {
|
|
507
|
+
display: flex;
|
|
508
|
+
gap: 7px;
|
|
509
|
+
align-items: baseline;
|
|
510
|
+
cursor: pointer;
|
|
511
|
+
list-style: none;
|
|
512
|
+
touch-action: manipulation;
|
|
513
|
+
}
|
|
514
|
+
.agent-card > .agent-head::-webkit-details-marker { display: none; }
|
|
515
|
+
.agent-card > .agent-head::before {
|
|
516
|
+
content: '▸';
|
|
517
|
+
color: var(--muted);
|
|
518
|
+
font-size: 10px;
|
|
519
|
+
transition: transform 0.12s ease;
|
|
520
|
+
}
|
|
521
|
+
.agent-card[open] > .agent-head::before { transform: rotate(90deg); }
|
|
522
|
+
.agent-card .agent-glyph { color: var(--accent); flex: 0 0 auto; }
|
|
523
|
+
.agent-card.running .agent-glyph { animation: pulse 1s steps(2, start) infinite; }
|
|
524
|
+
.agent-card.done .agent-glyph { color: var(--ok); }
|
|
525
|
+
.agent-card.failed .agent-glyph { color: var(--danger); }
|
|
526
|
+
.agent-card .agent-title {
|
|
527
|
+
color: var(--text);
|
|
528
|
+
font-weight: 700;
|
|
529
|
+
flex: 1 1 auto;
|
|
530
|
+
min-width: 0;
|
|
531
|
+
overflow: hidden;
|
|
532
|
+
text-overflow: ellipsis;
|
|
533
|
+
white-space: nowrap;
|
|
534
|
+
}
|
|
535
|
+
.agent-card .agent-status { color: var(--muted); font-size: 11px; flex: 0 0 auto; }
|
|
536
|
+
.agent-card > .agent-body {
|
|
537
|
+
margin: 6px 0 2px 16px;
|
|
538
|
+
border-left: 1px solid var(--border);
|
|
539
|
+
padding-left: 10px;
|
|
540
|
+
display: flex;
|
|
541
|
+
flex-direction: column;
|
|
542
|
+
gap: 6px;
|
|
543
|
+
}
|
|
544
|
+
.agent-card .agent-text:empty { display: none; }
|
|
545
|
+
.agent-card .agent-tools { display: flex; flex-direction: column; gap: 6px; }
|
|
546
|
+
.agent-card .agent-tools:empty { display: none; }
|
|
456
547
|
.tool-out .diff-line { display: block; }
|
|
457
548
|
.tool-out .diff-add { color: var(--ok); }
|
|
458
549
|
.tool-out .diff-del { color: var(--danger); }
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Capa de canales de mensajeria. Hoy implementa Telegram; la interfaz comun
|
|
2
|
+
// (`stop`, `notify`) permite sumar otros proveedores mas adelante.
|
|
3
|
+
|
|
4
|
+
import { startTelegramChannel } from './telegram.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Arranca los canales configurados y los suscribe a los eventos de los runs
|
|
8
|
+
* para avisar a los chats vinculados (por ejemplo, de la consola).
|
|
9
|
+
*/
|
|
10
|
+
export function startChannels(ctx, { log = () => {} } = {}) {
|
|
11
|
+
const tgConfig = { ...(ctx.app?.telegram ?? {}), ...(ctx.config?.telegram ?? {}) };
|
|
12
|
+
const token = tgConfig.token || process.env.TAMNORA_TELEGRAM_TOKEN || '';
|
|
13
|
+
const rawAllow = tgConfig.allowedChatIds ?? process.env.TAMNORA_TELEGRAM_CHAT_IDS ?? [];
|
|
14
|
+
const allowedChatIds = Array.isArray(rawAllow) ? rawAllow : String(rawAllow).split(/[,\s]+/).filter(Boolean);
|
|
15
|
+
|
|
16
|
+
const channels = [];
|
|
17
|
+
const telegram = startTelegramChannel(ctx, {
|
|
18
|
+
token,
|
|
19
|
+
allowedChatIds,
|
|
20
|
+
transcribeModel: tgConfig.transcribeModel,
|
|
21
|
+
log,
|
|
22
|
+
});
|
|
23
|
+
if (telegram) channels.push(telegram);
|
|
24
|
+
|
|
25
|
+
const unsubscribe = ctx.runs?.subscribeAll?.((sessionId, event) => {
|
|
26
|
+
for (const channel of channels) {
|
|
27
|
+
try {
|
|
28
|
+
channel.notify?.(sessionId, event);
|
|
29
|
+
} catch {
|
|
30
|
+
// un canal caido no debe frenar el run
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
channels,
|
|
37
|
+
enabled: channels.length > 0,
|
|
38
|
+
stop() {
|
|
39
|
+
unsubscribe?.();
|
|
40
|
+
for (const channel of channels) channel.stop?.();
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
// Canal de Telegram: long polling (no necesita URL publica) con soporte de
|
|
2
|
+
// texto, imagenes, notas de voz (transcripcion) y aprobaciones por botones
|
|
3
|
+
// inline. Cada chat mapea a una sesion del harness.
|
|
4
|
+
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { runAgent, createTools, resolveMode, summarizeToolCall } from '../core.js';
|
|
7
|
+
import { resolveExistingDir } from '../workspace.js';
|
|
3
8
|
|
|
4
9
|
const API = 'https://api.telegram.org';
|
|
5
10
|
|
|
@@ -14,7 +19,7 @@ const HELP = [
|
|
|
14
19
|
'/stop — cancela la respuesta en curso',
|
|
15
20
|
'/help — esta ayuda',
|
|
16
21
|
'',
|
|
17
|
-
'
|
|
22
|
+
'Podés mandar texto, imágenes o notas de voz.',
|
|
18
23
|
].join('\n');
|
|
19
24
|
|
|
20
25
|
async function tg(token, method, payload) {
|
|
@@ -37,6 +42,13 @@ async function tg(token, method, payload) {
|
|
|
37
42
|
return data.result;
|
|
38
43
|
}
|
|
39
44
|
|
|
45
|
+
async function downloadFile(token, fileId) {
|
|
46
|
+
const file = await tg(token, 'getFile', { file_id: fileId });
|
|
47
|
+
const res = await fetch(`${API}/file/bot${token}/${file.file_path}`);
|
|
48
|
+
if (!res.ok) throw new Error(`no se pudo bajar el archivo (HTTP ${res.status})`);
|
|
49
|
+
return Buffer.from(await res.arrayBuffer());
|
|
50
|
+
}
|
|
51
|
+
|
|
40
52
|
function splitMessage(text, max = 3900) {
|
|
41
53
|
const value = String(text ?? '').trim() || '(sin texto)';
|
|
42
54
|
const parts = [];
|
|
@@ -47,24 +59,24 @@ function splitMessage(text, max = 3900) {
|
|
|
47
59
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
48
60
|
|
|
49
61
|
/**
|
|
50
|
-
*
|
|
51
|
-
* autorizados conversan con el mismo engine del harness; cada chat mapea a una
|
|
52
|
-
* sesion. Devuelve `{ stop }` o `null` si no hay token.
|
|
62
|
+
* Arranca el canal de Telegram. Devuelve `{ stop, notify }` o `null` sin token.
|
|
53
63
|
*/
|
|
54
|
-
export function
|
|
64
|
+
export function startTelegramChannel(ctx, { token, allowedChatIds = [], transcribeModel = 'whisper-1', log = () => {} } = {}) {
|
|
55
65
|
if (!token) return null;
|
|
56
66
|
const allowed = new Set(allowedChatIds.map(String));
|
|
57
67
|
const controllers = new Map();
|
|
68
|
+
const ownSessions = new Set();
|
|
58
69
|
let running = true;
|
|
59
70
|
let offset = 0;
|
|
60
71
|
|
|
61
72
|
const authorized = (chatId) => allowed.size > 0 && allowed.has(String(chatId));
|
|
62
73
|
const sessionKey = (chatId) => `telegram:chat:${chatId}`;
|
|
74
|
+
const reverseKey = (sessionId) => `telegram:session:${sessionId}`;
|
|
63
75
|
|
|
64
|
-
async function send(chatId, text) {
|
|
76
|
+
async function send(chatId, text, extra = {}) {
|
|
65
77
|
let first = null;
|
|
66
78
|
for (const chunk of splitMessage(text)) {
|
|
67
|
-
const result = await tg(token, 'sendMessage', { chat_id: chatId, text: chunk, disable_web_page_preview: true });
|
|
79
|
+
const result = await tg(token, 'sendMessage', { chat_id: chatId, text: chunk, disable_web_page_preview: true, ...extra });
|
|
68
80
|
if (!first) first = result;
|
|
69
81
|
}
|
|
70
82
|
return first;
|
|
@@ -91,10 +103,29 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
|
|
|
91
103
|
});
|
|
92
104
|
ctx.store.setSetting(key, sessionId);
|
|
93
105
|
}
|
|
106
|
+
ctx.store.setSetting(reverseKey(sessionId), String(chatId));
|
|
94
107
|
return sessionId;
|
|
95
108
|
}
|
|
96
109
|
|
|
97
|
-
async function
|
|
110
|
+
async function transcribe(buffer, filename) {
|
|
111
|
+
const providerId = ctx.effectiveProviderId();
|
|
112
|
+
const baseUrl = (ctx.providerSettings(providerId).baseUrl || '').replace(/\/+$/, '');
|
|
113
|
+
const apiKey = ctx.apiKeyFor(providerId);
|
|
114
|
+
if (!baseUrl) throw new Error('el proveedor no tiene baseUrl para transcribir');
|
|
115
|
+
const form = new FormData();
|
|
116
|
+
form.append('file', new Blob([buffer]), filename);
|
|
117
|
+
form.append('model', transcribeModel);
|
|
118
|
+
const res = await fetch(`${baseUrl}/audio/transcriptions`, {
|
|
119
|
+
method: 'POST',
|
|
120
|
+
headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
|
|
121
|
+
body: form,
|
|
122
|
+
});
|
|
123
|
+
if (!res.ok) throw new Error(`transcripción falló (HTTP ${res.status})`);
|
|
124
|
+
const data = await res.json();
|
|
125
|
+
return String(data.text ?? '').trim();
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function run(chatId, input, images = []) {
|
|
98
129
|
const sessionId = ensureSession(chatId);
|
|
99
130
|
const session = ctx.store.getSession(sessionId);
|
|
100
131
|
const providerId = session.provider || ctx.effectiveProviderId();
|
|
@@ -114,11 +145,28 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
|
|
|
114
145
|
mcp = null;
|
|
115
146
|
}
|
|
116
147
|
|
|
148
|
+
const onAsk = async (name, args) => {
|
|
149
|
+
const askId = randomUUID();
|
|
150
|
+
const preview = JSON.stringify(args ?? {}).slice(0, 300);
|
|
151
|
+
await send(chatId, `⚠ Permiso para ${name}\n${preview}`, {
|
|
152
|
+
reply_markup: {
|
|
153
|
+
inline_keyboard: [
|
|
154
|
+
[
|
|
155
|
+
{ text: 'Permitir', callback_data: `perm:${askId}:allow` },
|
|
156
|
+
{ text: 'Denegar', callback_data: `perm:${askId}:deny` },
|
|
157
|
+
],
|
|
158
|
+
],
|
|
159
|
+
},
|
|
160
|
+
});
|
|
161
|
+
return ctx.approvals.ask(askId);
|
|
162
|
+
};
|
|
163
|
+
|
|
117
164
|
const local = createTools({
|
|
118
165
|
root: cwd,
|
|
119
166
|
enabled: mode.tools ?? undefined,
|
|
120
167
|
permissions: ctx.config.permissions,
|
|
121
168
|
yes: false,
|
|
169
|
+
onAsk,
|
|
122
170
|
mcp,
|
|
123
171
|
});
|
|
124
172
|
const exposeAdmin = !mode.tools;
|
|
@@ -126,26 +174,38 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
|
|
|
126
174
|
const definitions = exposeAdmin ? [...local.definitions, ...admin.definitions] : local.definitions;
|
|
127
175
|
const execute = (name, args, context) =>
|
|
128
176
|
exposeAdmin && admin.handles(name)
|
|
129
|
-
? admin.execute(name, args, { approve:
|
|
177
|
+
? admin.execute(name, args, { approve: onAsk })
|
|
130
178
|
: local.execute(name, args, context);
|
|
131
179
|
const { provider, config } = ctx.resolveProvider({
|
|
132
180
|
...ctx.providerSettings(providerId),
|
|
133
181
|
apiKey: ctx.apiKeyFor(providerId),
|
|
134
182
|
});
|
|
135
183
|
const basePrompt = await ctx.systemPromptFor(cwd);
|
|
184
|
+
const userMessage = images.length
|
|
185
|
+
? { role: 'user', content: [{ type: 'text', text: input }, ...images.map((url) => ({ type: 'image_url', image_url: { url } }))] }
|
|
186
|
+
: { role: 'user', content: input };
|
|
136
187
|
const messages = [
|
|
137
188
|
{ role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
|
|
138
189
|
...history,
|
|
139
|
-
|
|
190
|
+
userMessage,
|
|
140
191
|
];
|
|
141
192
|
|
|
142
193
|
const controller = new AbortController();
|
|
143
194
|
controllers.set(String(chatId), controller);
|
|
195
|
+
ownSessions.add(sessionId);
|
|
144
196
|
const message = await send(chatId, '…');
|
|
145
197
|
let full = '';
|
|
198
|
+
let activity = '';
|
|
146
199
|
let lastEdit = 0;
|
|
147
200
|
|
|
201
|
+
const render = () => {
|
|
202
|
+
const body = full.trim();
|
|
203
|
+
const suffix = activity ? `\n\n— ${activity}` : '';
|
|
204
|
+
return `${body || '…'}${suffix}`;
|
|
205
|
+
};
|
|
206
|
+
|
|
148
207
|
try {
|
|
208
|
+
await tg(token, 'sendChatAction', { chat_id: chatId, action: 'typing' }).catch(() => null);
|
|
149
209
|
const result = await runAgent({
|
|
150
210
|
provider,
|
|
151
211
|
config,
|
|
@@ -156,23 +216,30 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
|
|
|
156
216
|
signal: controller.signal,
|
|
157
217
|
maxRounds: ctx.config.maxRounds,
|
|
158
218
|
onEvent(event) {
|
|
159
|
-
if (event.type
|
|
160
|
-
|
|
219
|
+
if (event.type === 'text') {
|
|
220
|
+
full = event.full;
|
|
221
|
+
} else if (event.type === 'tool_call') {
|
|
222
|
+
activity = `${event.name} ${summarizeToolCall(event.name, event.args)}`.trim();
|
|
223
|
+
} else if (event.type === 'tool_result') {
|
|
224
|
+
activity = `${event.name} ✓`;
|
|
225
|
+
} else {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
161
228
|
const now = Date.now();
|
|
162
229
|
if (now - lastEdit > 1200) {
|
|
163
230
|
lastEdit = now;
|
|
164
|
-
edit(chatId, message.message_id,
|
|
231
|
+
edit(chatId, message.message_id, render());
|
|
165
232
|
}
|
|
166
233
|
},
|
|
167
234
|
});
|
|
168
235
|
await edit(chatId, message.message_id, result.content?.trim() || full || '(sin texto)');
|
|
169
|
-
|
|
170
|
-
ctx.store.addMessages(sessionId, newMessages);
|
|
236
|
+
ctx.store.addMessages(sessionId, result.messages.slice(messages.length));
|
|
171
237
|
ctx.store.touchSession(sessionId, { provider: providerId, model });
|
|
172
238
|
} catch (error) {
|
|
173
239
|
await edit(chatId, message.message_id, error?.name === 'AbortError' ? 'Cancelado.' : `Error: ${error?.message ?? error}`);
|
|
174
240
|
} finally {
|
|
175
241
|
controllers.delete(String(chatId));
|
|
242
|
+
ownSessions.delete(sessionId);
|
|
176
243
|
mcp?.close?.();
|
|
177
244
|
}
|
|
178
245
|
}
|
|
@@ -219,23 +286,62 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
|
|
|
219
286
|
return send(chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
|
|
220
287
|
}
|
|
221
288
|
|
|
289
|
+
async function handleVoice(chatId, voice) {
|
|
290
|
+
await send(chatId, '🎧 Transcribiendo…');
|
|
291
|
+
try {
|
|
292
|
+
const buffer = await downloadFile(token, voice.file_id);
|
|
293
|
+
const text = await transcribe(buffer, voice.file_name || 'audio.ogg');
|
|
294
|
+
if (!text) return send(chatId, 'No pude transcribir el audio.');
|
|
295
|
+
await send(chatId, `📝 ${text}`);
|
|
296
|
+
return run(chatId, text);
|
|
297
|
+
} catch (error) {
|
|
298
|
+
return send(chatId, `No pude procesar el audio: ${error.message}`);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
222
302
|
async function handleMessage(message) {
|
|
223
303
|
const chatId = String(message.chat.id);
|
|
224
304
|
if (!authorized(chatId)) {
|
|
225
305
|
await send(chatId, `No autorizado. Tu chat id es ${chatId}; agregalo a telegram.allowedChatIds para habilitarlo.`);
|
|
226
306
|
return;
|
|
227
307
|
}
|
|
228
|
-
const text = String(message.text ?? '').trim();
|
|
229
|
-
if (
|
|
230
|
-
|
|
231
|
-
|
|
308
|
+
const text = String(message.text ?? message.caption ?? '').trim();
|
|
309
|
+
if (text.startsWith('/')) {
|
|
310
|
+
await handleCommand(chatId, text);
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (message.voice || message.audio) {
|
|
314
|
+
await handleVoice(chatId, message.voice ?? message.audio);
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
if (message.photo?.length) {
|
|
318
|
+
try {
|
|
319
|
+
const largest = message.photo[message.photo.length - 1];
|
|
320
|
+
const buffer = await downloadFile(token, largest.file_id);
|
|
321
|
+
const dataUrl = `data:image/jpeg;base64,${buffer.toString('base64')}`;
|
|
322
|
+
await run(chatId, text || '(imagen)', [dataUrl]);
|
|
323
|
+
} catch (error) {
|
|
324
|
+
await send(chatId, `No pude procesar la imagen: ${error.message}`);
|
|
325
|
+
}
|
|
326
|
+
return;
|
|
327
|
+
}
|
|
328
|
+
if (text) await run(chatId, text);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
async function handleCallback(query) {
|
|
332
|
+
const [_, askId, action] = String(query.data ?? '').split(':');
|
|
333
|
+
if (askId) ctx.approvals.decide(askId, action === 'allow');
|
|
334
|
+
await tg(token, 'answerCallbackQuery', { callback_query_id: query.id, text: action === 'allow' ? 'Permitido' : 'Denegado' }).catch(() => null);
|
|
335
|
+
if (query.message) {
|
|
336
|
+
await tg(token, 'editMessageReplyMarkup', { chat_id: query.message.chat.id, message_id: query.message.message_id }).catch(() => null);
|
|
337
|
+
}
|
|
232
338
|
}
|
|
233
339
|
|
|
234
340
|
async function loop() {
|
|
235
341
|
while (running) {
|
|
236
342
|
let updates = [];
|
|
237
343
|
try {
|
|
238
|
-
updates = await tg(token, 'getUpdates', { offset, timeout: 25, allowed_updates: ['message'] });
|
|
344
|
+
updates = await tg(token, 'getUpdates', { offset, timeout: 25, allowed_updates: ['message', 'callback_query'] });
|
|
239
345
|
} catch (error) {
|
|
240
346
|
if (error.status === 401 || error.status === 404) {
|
|
241
347
|
log(`telegram: token inválido (${error.message}); se detiene el bot.`);
|
|
@@ -247,21 +353,46 @@ export function startTelegramBot(ctx, { token, allowedChatIds = [], log = () =>
|
|
|
247
353
|
}
|
|
248
354
|
for (const update of updates) {
|
|
249
355
|
offset = update.update_id + 1;
|
|
250
|
-
if (update.message
|
|
251
|
-
|
|
252
|
-
}
|
|
356
|
+
if (update.message) handleMessage(update.message).catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
357
|
+
else if (update.callback_query) handleCallback(update.callback_query).catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
253
358
|
}
|
|
254
359
|
}
|
|
255
360
|
}
|
|
256
361
|
|
|
257
|
-
// Arranca en segundo plano; no bloquea el server.
|
|
258
362
|
loop().catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
259
363
|
|
|
260
364
|
return {
|
|
365
|
+
id: 'telegram',
|
|
261
366
|
stop() {
|
|
262
367
|
running = false;
|
|
263
368
|
for (const controller of controllers.values()) controller.abort();
|
|
264
369
|
controllers.clear();
|
|
265
370
|
},
|
|
371
|
+
/** Avisa a un chat vinculado sobre un run de otra interfaz (p.ej. la consola). */
|
|
372
|
+
notify(sessionId, event) {
|
|
373
|
+
if (ownSessions.has(sessionId)) return;
|
|
374
|
+
const chatId = ctx.store.getSetting(reverseKey(sessionId));
|
|
375
|
+
if (!chatId) return;
|
|
376
|
+
if (event.type === 'done') {
|
|
377
|
+
send(chatId, `✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`).catch(() => {});
|
|
378
|
+
} else if (event.type === 'error') {
|
|
379
|
+
send(chatId, `✗ Error en la sesión: ${event.data?.message ?? ''}`).catch(() => {});
|
|
380
|
+
} else if (event.type === 'agent_end') {
|
|
381
|
+
send(chatId, `🤖 Subagente ${event.data?.status}: ${String(event.data?.content ?? '').slice(0, 1500)}`).catch(() => {});
|
|
382
|
+
} else if (event.type === 'permission') {
|
|
383
|
+
const askId = event.data?.askId;
|
|
384
|
+
if (!askId) return;
|
|
385
|
+
send(chatId, `⚠ Permiso para ${event.data?.name ?? 'una tool'} (desde la consola)`, {
|
|
386
|
+
reply_markup: {
|
|
387
|
+
inline_keyboard: [
|
|
388
|
+
[
|
|
389
|
+
{ text: 'Permitir', callback_data: `perm:${askId}:allow` },
|
|
390
|
+
{ text: 'Denegar', callback_data: `perm:${askId}:deny` },
|
|
391
|
+
],
|
|
392
|
+
],
|
|
393
|
+
},
|
|
394
|
+
}).catch(() => {});
|
|
395
|
+
}
|
|
396
|
+
},
|
|
266
397
|
};
|
|
267
398
|
}
|
|
@@ -55,9 +55,10 @@ export function openSse(req, res) {
|
|
|
55
55
|
});
|
|
56
56
|
|
|
57
57
|
return {
|
|
58
|
-
event(name, data) {
|
|
58
|
+
event(name, data, id) {
|
|
59
59
|
if (closed || res.writableEnded) return;
|
|
60
|
-
|
|
60
|
+
const cursor = id === undefined || id === null ? '' : `id: ${id}\n`;
|
|
61
|
+
res.write(`${cursor}event: ${name}\ndata: ${JSON.stringify(data ?? {})}\n\n`);
|
|
61
62
|
},
|
|
62
63
|
comment(text) {
|
|
63
64
|
if (closed || res.writableEnded) return;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// Gestiona los runs activos del server, desacoplados del request HTTP: si el
|
|
2
|
+
// cliente se desconecta (por ejemplo, el celular bloquea la pantalla), el run
|
|
3
|
+
// sigue; cada evento se persiste con un cursor para que un cliente nuevo pueda
|
|
4
|
+
// reconectar y reconstruir el turno en curso.
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {object} deps
|
|
8
|
+
* @param {import('../../../core/src/storage/sqlite.js').Store} deps.store
|
|
9
|
+
*/
|
|
10
|
+
export function createRunManager({ store }) {
|
|
11
|
+
const runs = new Map();
|
|
12
|
+
const listeners = new Set();
|
|
13
|
+
|
|
14
|
+
function persist(run, sessionId, type, data, agentId) {
|
|
15
|
+
const coalesce =
|
|
16
|
+
(type === 'text' || type === 'reasoning') &&
|
|
17
|
+
run?.lastEvent &&
|
|
18
|
+
run.lastEvent.type === type &&
|
|
19
|
+
run.lastEvent.agentId === agentId;
|
|
20
|
+
if (coalesce) {
|
|
21
|
+
store.updateSessionEvent(run.lastEvent.id, data);
|
|
22
|
+
return { id: run.lastEvent.id, type, data, agentId };
|
|
23
|
+
}
|
|
24
|
+
const id = store.addSessionEvent(sessionId, { agentId, type, data });
|
|
25
|
+
if (run) run.lastEvent = { id, type, agentId };
|
|
26
|
+
return { id, type, data, agentId };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function emit(sessionId, type, data, { agentId = null } = {}) {
|
|
30
|
+
const run = runs.get(sessionId) ?? null;
|
|
31
|
+
const event = persist(run, sessionId, type, data, agentId);
|
|
32
|
+
if (run) {
|
|
33
|
+
for (const subscriber of [...run.subscribers]) {
|
|
34
|
+
try {
|
|
35
|
+
subscriber(event);
|
|
36
|
+
} catch {
|
|
37
|
+
// un suscriptor caido no debe frenar el run
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
for (const listener of [...listeners]) {
|
|
42
|
+
try {
|
|
43
|
+
listener(sessionId, event);
|
|
44
|
+
} catch {
|
|
45
|
+
// un listener caido no debe frenar el run
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return event;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function start(sessionId) {
|
|
52
|
+
if (runs.has(sessionId)) return null;
|
|
53
|
+
const controller = new AbortController();
|
|
54
|
+
const run = { sessionId, controller, subscribers: new Set(), lastEvent: null, startedAt: Date.now() };
|
|
55
|
+
runs.set(sessionId, run);
|
|
56
|
+
store.clearSessionEvents(sessionId);
|
|
57
|
+
return run;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function finish(sessionId) {
|
|
61
|
+
const run = runs.get(sessionId);
|
|
62
|
+
if (!run) return;
|
|
63
|
+
run.subscribers.clear();
|
|
64
|
+
runs.delete(sessionId);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function cancel(sessionId) {
|
|
68
|
+
runs.get(sessionId)?.controller.abort();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function cancelAll() {
|
|
72
|
+
for (const run of runs.values()) run.controller.abort();
|
|
73
|
+
runs.clear();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isActive(sessionId) {
|
|
77
|
+
return runs.has(sessionId);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function signal(sessionId) {
|
|
81
|
+
return runs.get(sessionId)?.controller.signal;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function subscribe(sessionId, fn) {
|
|
85
|
+
const run = runs.get(sessionId);
|
|
86
|
+
if (!run) return () => {};
|
|
87
|
+
run.subscribers.add(fn);
|
|
88
|
+
return () => run.subscribers.delete(fn);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function activeSessions() {
|
|
92
|
+
return [...runs.keys()];
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Suscriptor global de todos los runs (usado por los canales). */
|
|
96
|
+
function subscribeAll(fn) {
|
|
97
|
+
listeners.add(fn);
|
|
98
|
+
return () => listeners.delete(fn);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return { emit, start, finish, cancel, cancelAll, isActive, signal, subscribe, subscribeAll, activeSessions };
|
|
102
|
+
}
|