@danieltmn/tamnora 0.9.1 → 0.9.3
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 +13 -0
- package/harness/packages/cli/src/tui/app.js +22 -3
- package/harness/packages/cli/src/tui/layout.js +59 -2
- package/harness/packages/cli/test/tui.test.js +30 -0
- package/harness/packages/core/src/config/app.js +2 -0
- package/harness/packages/core/src/index.js +2 -1
- package/harness/packages/core/src/providers/presets.js +4 -0
- package/harness/packages/core/src/stt/google.js +91 -0
- package/harness/packages/core/src/tools/diff.js +70 -1
- package/harness/packages/core/test/diff.test.js +24 -1
- package/harness/packages/server/public/app.js +824 -45
- package/harness/packages/server/public/index.html +121 -1
- package/harness/packages/server/public/styles.css +106 -12
- package/harness/packages/server/src/lib/channels/index.js +23 -3
- package/harness/packages/server/src/lib/channels/telegram.js +289 -101
- package/harness/packages/server/src/lib/presence.js +51 -0
- package/harness/packages/server/src/lib/profile.js +63 -0
- package/harness/packages/server/src/lib/prompt.js +7 -2
- package/harness/packages/server/src/lib/transcribe.js +51 -0
- package/harness/packages/server/src/routes/channels.js +2 -0
- package/harness/packages/server/src/routes/presence.js +17 -0
- package/harness/packages/server/src/routes/profile.js +22 -0
- package/harness/packages/server/src/routes/providers.js +2 -0
- package/harness/packages/server/src/routes/sessions.js +55 -33
- package/harness/packages/server/src/routes/transcribe.js +115 -0
- package/harness/packages/server/src/routes/tts.js +2 -1
- package/harness/packages/server/src/server.js +11 -0
- package/harness/packages/server/test/profile.test.js +85 -0
- package/harness/packages/server/test/server.test.js +34 -0
- package/harness/packages/server/test/telegram-features.test.js +241 -0
- package/harness/packages/server/test/telegram-voz.test.js +12 -5
- package/harness/packages/server/test/telegram.test.js +37 -2
- package/harness/packages/server/test/transcribe.test.js +215 -0
- package/harness/packages/server/test/tts.test.js +24 -0
- package/package.json +1 -1
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
// Canal de Telegram: long polling (no necesita URL publica) con soporte de
|
|
2
2
|
// texto, imagenes, notas de voz (transcripcion) y aprobaciones por botones
|
|
3
|
-
// inline. Cada chat mapea a una sesion del harness
|
|
3
|
+
// inline. Cada chat mapea a una sesion del harness, que ahora empieza eligiendo
|
|
4
|
+
// proyecto (una carpeta de la raiz configurada en la consola).
|
|
4
5
|
|
|
6
|
+
import path from 'node:path';
|
|
5
7
|
import { randomUUID } from 'node:crypto';
|
|
6
8
|
import { runAgent, createTools, resolveMode, listProviderModels, PROVIDER_META, sintetizar, MAX_TEXTO } from '../core.js';
|
|
7
|
-
import { resolveExistingDir } from '../workspace.js';
|
|
9
|
+
import { resolveExistingDir, listSubdirs } from '../workspace.js';
|
|
8
10
|
import {
|
|
9
11
|
combineToolset,
|
|
10
12
|
composeAgentMessages,
|
|
@@ -12,18 +14,25 @@ import {
|
|
|
12
14
|
resolveProviderFor,
|
|
13
15
|
toolCallPayload,
|
|
14
16
|
} from '../agentRun.js';
|
|
15
|
-
import { fraseFin } from '../announcer.js';
|
|
16
17
|
import { mp3AOgg } from '../ffmpeg.js';
|
|
17
18
|
import { resolveTts } from '../ttsSettings.js';
|
|
19
|
+
import { transcribeAudio } from '../transcribe.js';
|
|
18
20
|
|
|
19
21
|
const API = 'https://api.telegram.org';
|
|
22
|
+
const MAX_PROJECTS = 24;
|
|
23
|
+
|
|
24
|
+
const RESUME_INSTRUCTION =
|
|
25
|
+
'Resumí la conversación para la persona: qué se pidió, qué se hizo, decisiones tomadas, archivos o comandos relevantes y el estado actual. Sé claro y conciso, en español, sin markdown pesado.';
|
|
20
26
|
|
|
21
27
|
const HELP = [
|
|
22
28
|
'Tamnora Harness — bot',
|
|
23
29
|
'',
|
|
30
|
+
'/proyecto [nombre] — elegí el proyecto (sin argumento: lista)',
|
|
31
|
+
'/new — nueva sesión (vuelve a preguntar el proyecto)',
|
|
32
|
+
'/resumen — resumen en texto de la conversación',
|
|
33
|
+
'/audioresumen — el resumen como nota de voz',
|
|
24
34
|
'/menu — panel con botones (modelo, modo, proveedor)',
|
|
25
|
-
'/
|
|
26
|
-
'/status — proveedor, modelo, modo y sesión',
|
|
35
|
+
'/status — proveedor, modelo, modo, proyecto y sesión',
|
|
27
36
|
'/model [id] — cambia el modelo (sin id: lista con botones)',
|
|
28
37
|
'/provider [id] — cambia el proveedor (sin id: lista)',
|
|
29
38
|
'/mode [plan|build] — cambia el modo (sin arg: panel)',
|
|
@@ -111,13 +120,30 @@ export function completionFooter(stopReason, rounds, toolCalls, durationMs) {
|
|
|
111
120
|
*/
|
|
112
121
|
export function startTelegramChannel(
|
|
113
122
|
ctx,
|
|
114
|
-
{
|
|
123
|
+
{
|
|
124
|
+
token,
|
|
125
|
+
allowedChatIds = [],
|
|
126
|
+
transcribeModel = 'whisper-1',
|
|
127
|
+
transcribeProvider = '',
|
|
128
|
+
notify = {},
|
|
129
|
+
root = '',
|
|
130
|
+
log = () => {},
|
|
131
|
+
} = {}
|
|
115
132
|
) {
|
|
116
133
|
if (!token) return null;
|
|
117
134
|
const allowed = new Set(allowedChatIds.map(String));
|
|
135
|
+
const notifyConfig = {
|
|
136
|
+
enabled: notify?.enabled === true,
|
|
137
|
+
done: notify?.done !== false,
|
|
138
|
+
error: notify?.error !== false,
|
|
139
|
+
permission: notify?.permission !== false,
|
|
140
|
+
agent: notify?.agent !== false,
|
|
141
|
+
};
|
|
118
142
|
const controllers = new Map();
|
|
119
143
|
const ownSessions = new Set();
|
|
120
144
|
const modelChoices = new Map();
|
|
145
|
+
const projectChoices = new Map();
|
|
146
|
+
const pendingInputs = new Map();
|
|
121
147
|
let running = true;
|
|
122
148
|
let offset = 0;
|
|
123
149
|
|
|
@@ -168,59 +194,144 @@ export function startTelegramChannel(
|
|
|
168
194
|
}
|
|
169
195
|
}
|
|
170
196
|
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
197
|
+
// --- proyectos ------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
/** Carpeta raíz para elegir proyecto: la predeterminada de la consola o el cwd. */
|
|
200
|
+
function projectRoot() {
|
|
201
|
+
const stored = ctx.store.getSetting('defaultCwd');
|
|
202
|
+
const candidate = stored || root || ctx.cwd;
|
|
203
|
+
try {
|
|
204
|
+
return resolveExistingDir(candidate);
|
|
205
|
+
} catch {
|
|
206
|
+
return ctx.cwd;
|
|
207
|
+
}
|
|
176
208
|
}
|
|
177
209
|
|
|
178
|
-
function
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
title: `Telegram ${chatId}`,
|
|
184
|
-
provider: ctx.effectiveProviderId(),
|
|
185
|
-
model: ctx.effectiveModel(),
|
|
186
|
-
cwd: ctx.cwd,
|
|
187
|
-
meta: { telegramChatId: String(chatId) },
|
|
188
|
-
});
|
|
189
|
-
ctx.store.setSetting(key, sessionId);
|
|
210
|
+
function listProjects() {
|
|
211
|
+
try {
|
|
212
|
+
return listSubdirs(projectRoot()).map((dir) => ({ name: dir.name, path: dir.path }));
|
|
213
|
+
} catch {
|
|
214
|
+
return [];
|
|
190
215
|
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function projectLabel(session) {
|
|
219
|
+
const cwd = session?.cwd || '';
|
|
220
|
+
return cwd ? path.basename(cwd) : 'sin carpeta';
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Un proyecto está habilitado si alguna pestaña abierta de la consola lo usa. */
|
|
224
|
+
function projectIsOpen(session) {
|
|
225
|
+
const ids = ctx.presence?.ids?.() ?? [];
|
|
226
|
+
if (!ids.length) return false;
|
|
227
|
+
const cwd = session?.cwd || '';
|
|
228
|
+
return ids.some((id) => {
|
|
229
|
+
const open = ctx.store.getSession(id);
|
|
230
|
+
return open && (open.cwd || '') === cwd;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function activeSessionId(chatId) {
|
|
235
|
+
const id = ctx.store.getSetting(sessionKey(chatId));
|
|
236
|
+
if (id && ctx.store.getSession(id)) return id;
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function createSessionForProject(chatId, cwd) {
|
|
241
|
+
const sessionId = ctx.store.createSession({
|
|
242
|
+
title: path.basename(cwd) || `Telegram ${chatId}`,
|
|
243
|
+
provider: ctx.effectiveProviderId(),
|
|
244
|
+
model: ctx.effectiveModel(),
|
|
245
|
+
cwd,
|
|
246
|
+
meta: { telegramChatId: String(chatId) },
|
|
247
|
+
});
|
|
248
|
+
ctx.store.setSetting(sessionKey(chatId), sessionId);
|
|
191
249
|
ctx.store.setSetting(reverseKey(sessionId), String(chatId));
|
|
192
250
|
return sessionId;
|
|
193
251
|
}
|
|
194
252
|
|
|
195
|
-
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
);
|
|
253
|
+
function resolveProjectArg(arg) {
|
|
254
|
+
const value = String(arg ?? '').trim();
|
|
255
|
+
if (!value) return null;
|
|
256
|
+
if (/[\\/]/.test(value) || /^[a-zA-Z]:/.test(value)) return value;
|
|
257
|
+
const projects = listProjects();
|
|
258
|
+
const exact = projects.find((p) => p.name.toLowerCase() === value.toLowerCase());
|
|
259
|
+
if (exact) return exact.path;
|
|
260
|
+
const matches = projects.filter((p) => p.name.toLowerCase().includes(value.toLowerCase()));
|
|
261
|
+
return matches.length === 1 ? matches[0].path : null;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function sendProjectPicker(chatId, { text } = {}) {
|
|
265
|
+
const base = projectRoot();
|
|
266
|
+
const all = listProjects();
|
|
267
|
+
const shown = all.slice(0, MAX_PROJECTS);
|
|
268
|
+
projectChoices.set(String(chatId), shown);
|
|
269
|
+
const rows = shown.map((project, index) => [{ text: project.name, callback_data: `proj:${index}` }]);
|
|
270
|
+
rows.push([{ text: `📁 usar «${path.basename(base)}»`, callback_data: 'proj:root' }]);
|
|
271
|
+
const intro = text || '¿En qué proyecto vas a trabajar?';
|
|
272
|
+
await send(chatId, `${intro}\n\nraíz: ${base}`, { reply_markup: { inline_keyboard: rows } });
|
|
273
|
+
if (all.length > shown.length) {
|
|
274
|
+
await send(chatId, `Hay ${all.length} proyectos; mostré ${shown.length}. Para otro, usá /proyecto <nombre>.`);
|
|
217
275
|
}
|
|
218
|
-
|
|
219
|
-
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
async function selectProject(chatId, projectPath) {
|
|
279
|
+
let cwd;
|
|
280
|
+
try {
|
|
281
|
+
cwd = resolveExistingDir(projectPath);
|
|
282
|
+
} catch (error) {
|
|
283
|
+
return send(chatId, `No pude usar ese proyecto: ${error.message}`);
|
|
284
|
+
}
|
|
285
|
+
createSessionForProject(chatId, cwd);
|
|
286
|
+
const key = String(chatId);
|
|
287
|
+
const pending = pendingInputs.get(key);
|
|
288
|
+
pendingInputs.delete(key);
|
|
289
|
+
await send(chatId, `Proyecto «${path.basename(cwd)}».${pending ? '' : ' Mandá tu mensaje.'}`);
|
|
290
|
+
if (pending) run(chatId, pending.input, pending.images).catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function transcribe(buffer, filename) {
|
|
294
|
+
return transcribeAudio(ctx, {
|
|
295
|
+
buffer,
|
|
296
|
+
filename,
|
|
297
|
+
providerId: transcribeProvider || undefined,
|
|
298
|
+
model: transcribeModel,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Resumen de la conversación de una sesión (sin tocar el historial). */
|
|
303
|
+
async function resumirSesion(sessionId) {
|
|
304
|
+
const messages = ctx.store.getMessages(sessionId);
|
|
305
|
+
if (!messages.length) throw new Error('La conversación está vacía.');
|
|
306
|
+
const session = ctx.store.getSession(sessionId);
|
|
307
|
+
const providerId = session?.provider || ctx.effectiveProviderId();
|
|
308
|
+
const model = session?.model || ctx.effectiveModel();
|
|
309
|
+
if (!model) throw new Error('Falta el modelo.');
|
|
310
|
+
const transcript = messages
|
|
311
|
+
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
|
|
312
|
+
.join('\n');
|
|
313
|
+
const { provider, config } = resolveProviderFor(ctx, providerId);
|
|
314
|
+
const result = await runAgent({
|
|
315
|
+
provider,
|
|
316
|
+
config,
|
|
317
|
+
model,
|
|
318
|
+
messages: [
|
|
319
|
+
{ role: 'system', content: RESUME_INSTRUCTION },
|
|
320
|
+
{ role: 'user', content: transcript.slice(0, 60000) },
|
|
321
|
+
],
|
|
322
|
+
tools: [],
|
|
323
|
+
});
|
|
324
|
+
const summary = result.content?.trim();
|
|
325
|
+
if (!summary) throw new Error('El modelo no devolvió un resumen.');
|
|
326
|
+
return { summary };
|
|
220
327
|
}
|
|
221
328
|
|
|
222
329
|
async function run(chatId, input, images = []) {
|
|
223
|
-
const sessionId =
|
|
330
|
+
const sessionId = activeSessionId(chatId);
|
|
331
|
+
if (!sessionId) {
|
|
332
|
+
pendingInputs.set(String(chatId), { input, images });
|
|
333
|
+
return sendProjectPicker(chatId);
|
|
334
|
+
}
|
|
224
335
|
const session = ctx.store.getSession(sessionId);
|
|
225
336
|
const providerId = session.provider || ctx.effectiveProviderId();
|
|
226
337
|
const model = session.model || ctx.effectiveModel();
|
|
@@ -242,7 +353,7 @@ export function startTelegramChannel(
|
|
|
242
353
|
const onAsk = async (name, args) => {
|
|
243
354
|
const askId = randomUUID();
|
|
244
355
|
const preview = JSON.stringify(args ?? {}).slice(0, 300);
|
|
245
|
-
await send(chatId, `⚠ Permiso para ${name}\n${preview}`, {
|
|
356
|
+
await send(chatId, `⚠ [${projectLabel(session)}] Permiso para ${name}\n${preview}`, {
|
|
246
357
|
reply_markup: {
|
|
247
358
|
inline_keyboard: [
|
|
248
359
|
[
|
|
@@ -339,7 +450,6 @@ export function startTelegramChannel(
|
|
|
339
450
|
}
|
|
340
451
|
const footer = completionFooter(result.stopReason, result.rounds, toolCalls, durationMs);
|
|
341
452
|
await edit(chatId, message.message_id, `${body}\n\n— ${footer}`);
|
|
342
|
-
await sendResumenDeVoz(chatId, fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }));
|
|
343
453
|
ctx.store.addMessages(sessionId, newMessages);
|
|
344
454
|
ctx.store.touchSession(sessionId, { provider: providerId, model });
|
|
345
455
|
} catch (error) {
|
|
@@ -352,12 +462,18 @@ export function startTelegramChannel(
|
|
|
352
462
|
}
|
|
353
463
|
|
|
354
464
|
async function sendMenu(chatId) {
|
|
355
|
-
const sessionId =
|
|
356
|
-
const session = ctx.store.getSession(sessionId);
|
|
465
|
+
const sessionId = activeSessionId(chatId);
|
|
466
|
+
const session = sessionId ? ctx.store.getSession(sessionId) : null;
|
|
357
467
|
const providerId = session?.provider || ctx.effectiveProviderId();
|
|
358
468
|
const model = session?.model || ctx.effectiveModel() || '—';
|
|
359
469
|
const mode = resolveMode(ctx.effectiveMode());
|
|
360
|
-
const text = [
|
|
470
|
+
const text = [
|
|
471
|
+
'⚙ Panel',
|
|
472
|
+
`proyecto: ${session ? projectLabel(session) : '(elegí con /proyecto)'}`,
|
|
473
|
+
`proveedor: ${providerId}`,
|
|
474
|
+
`modelo: ${model}`,
|
|
475
|
+
`modo: ${mode.label}`,
|
|
476
|
+
].join('\n');
|
|
361
477
|
await send(chatId, text, {
|
|
362
478
|
reply_markup: {
|
|
363
479
|
inline_keyboard: [
|
|
@@ -370,23 +486,25 @@ export function startTelegramChannel(
|
|
|
370
486
|
{ text: 'proveedores', callback_data: 'menu:providers' },
|
|
371
487
|
],
|
|
372
488
|
[
|
|
489
|
+
{ text: '📁 proyecto', callback_data: 'menu:project' },
|
|
373
490
|
{ text: '🆕 nueva', callback_data: 'menu:new' },
|
|
374
|
-
{ text: '⏹ detener', callback_data: 'menu:stop' },
|
|
375
491
|
],
|
|
492
|
+
[{ text: '⏹ detener', callback_data: 'menu:stop' }],
|
|
376
493
|
],
|
|
377
494
|
},
|
|
378
495
|
});
|
|
379
496
|
}
|
|
380
497
|
|
|
381
498
|
async function sendModels(chatId) {
|
|
382
|
-
const sessionId =
|
|
383
|
-
const
|
|
499
|
+
const sessionId = activeSessionId(chatId);
|
|
500
|
+
const session = sessionId ? ctx.store.getSession(sessionId) : null;
|
|
501
|
+
const providerId = session?.provider || ctx.effectiveProviderId();
|
|
384
502
|
const models = await listProviderModels({
|
|
385
503
|
...ctx.providerSettings(providerId),
|
|
386
504
|
apiKey: ctx.apiKeyFor(providerId),
|
|
387
505
|
}).catch(() => []);
|
|
388
506
|
|
|
389
|
-
const current =
|
|
507
|
+
const current = session?.model || ctx.effectiveModel();
|
|
390
508
|
if (!models?.length) return send(chatId, 'No pude listar modelos. Usá /model <id>.');
|
|
391
509
|
const capped = models.slice(0, 12);
|
|
392
510
|
modelChoices.set(String(chatId), capped.map((m) => m.id));
|
|
@@ -413,20 +531,49 @@ export function startTelegramChannel(
|
|
|
413
531
|
const [raw, ...rest] = text.slice(1).split(/\s+/);
|
|
414
532
|
const cmd = raw.toLowerCase();
|
|
415
533
|
const arg = rest.join(' ').trim();
|
|
416
|
-
const sessionId =
|
|
417
|
-
if (cmd === 'start'
|
|
534
|
+
const sessionId = activeSessionId(chatId);
|
|
535
|
+
if (cmd === 'start') {
|
|
536
|
+
if (sessionId) {
|
|
537
|
+
return send(chatId, `Hola. Proyecto actual: «${projectLabel(ctx.store.getSession(sessionId))}».\n\n${HELP}`);
|
|
538
|
+
}
|
|
539
|
+
return sendProjectPicker(chatId, { text: 'Hola. ¿En qué proyecto vas a trabajar?' });
|
|
540
|
+
}
|
|
541
|
+
if (cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
|
|
418
542
|
if (cmd === 'menu' || cmd === 'panel' || cmd === 'config') return sendMenu(chatId);
|
|
419
543
|
if (cmd === 'new' || cmd === 'nueva') {
|
|
420
544
|
ctx.store.setSetting(sessionKey(chatId), null);
|
|
421
|
-
|
|
545
|
+
pendingInputs.delete(String(chatId));
|
|
546
|
+
return sendProjectPicker(chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
|
|
547
|
+
}
|
|
548
|
+
if (cmd === 'proyecto' || cmd === 'proyectos' || cmd === 'project') {
|
|
549
|
+
if (!arg) return sendProjectPicker(chatId);
|
|
550
|
+
const projectPath = resolveProjectArg(arg);
|
|
551
|
+
if (!projectPath) {
|
|
552
|
+
return send(chatId, `No encontré el proyecto "${arg}". Usá /proyecto sin argumento para ver la lista.`);
|
|
553
|
+
}
|
|
554
|
+
return selectProject(chatId, projectPath);
|
|
422
555
|
}
|
|
423
556
|
if (cmd === 'status') {
|
|
424
557
|
const session = sessionId ? ctx.store.getSession(sessionId) : null;
|
|
425
558
|
return send(
|
|
426
559
|
chatId,
|
|
427
|
-
`
|
|
560
|
+
`proyecto: ${session ? projectLabel(session) : '—'}\nproveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
|
|
428
561
|
);
|
|
429
562
|
}
|
|
563
|
+
if (cmd === 'resumen' || cmd === 'resume') {
|
|
564
|
+
if (!sessionId) return send(chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
|
|
565
|
+
const data = await resumirSesion(sessionId).catch((error) => ({ error: error.message }));
|
|
566
|
+
if (data.error) return send(chatId, `No pude resumir: ${data.error}`);
|
|
567
|
+
return send(chatId, `📝 Resumen\n\n${data.summary}`);
|
|
568
|
+
}
|
|
569
|
+
if (cmd === 'audioresumen' || cmd === 'audio-resumen') {
|
|
570
|
+
if (!sessionId) return send(chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
|
|
571
|
+
await send(chatId, '🎧 Preparando el resumen…');
|
|
572
|
+
const data = await resumirSesion(sessionId).catch((error) => ({ error: error.message }));
|
|
573
|
+
if (data.error) return send(chatId, `No pude resumir: ${data.error}`);
|
|
574
|
+
await send(chatId, `📝 Resumen\n\n${data.summary}`);
|
|
575
|
+
return enviarNotaDeVoz(chatId, data.summary);
|
|
576
|
+
}
|
|
430
577
|
if (cmd === 'model' || cmd === 'modelo') {
|
|
431
578
|
if (!arg) return sendModels(chatId);
|
|
432
579
|
ctx.store.setSetting('activeModel', arg);
|
|
@@ -459,12 +606,21 @@ export function startTelegramChannel(
|
|
|
459
606
|
const text = await transcribe(buffer, voice.file_name || 'audio.ogg');
|
|
460
607
|
if (!text) return send(chatId, 'No pude transcribir el audio.');
|
|
461
608
|
await send(chatId, `📝 ${text}`);
|
|
462
|
-
return
|
|
609
|
+
return deliver(chatId, text);
|
|
463
610
|
} catch (error) {
|
|
464
611
|
return send(chatId, `No pude procesar el audio: ${error.message}`);
|
|
465
612
|
}
|
|
466
613
|
}
|
|
467
614
|
|
|
615
|
+
/** Entrega un mensaje: si no hay proyecto elegido, primero pregunta cuál. */
|
|
616
|
+
async function deliver(chatId, input, images = []) {
|
|
617
|
+
if (!activeSessionId(chatId)) {
|
|
618
|
+
pendingInputs.set(String(chatId), { input, images });
|
|
619
|
+
return sendProjectPicker(chatId);
|
|
620
|
+
}
|
|
621
|
+
return run(chatId, input, images);
|
|
622
|
+
}
|
|
623
|
+
|
|
468
624
|
async function handleMessage(message) {
|
|
469
625
|
const chatId = String(message.chat.id);
|
|
470
626
|
if (!authorized(chatId)) {
|
|
@@ -485,13 +641,13 @@ export function startTelegramChannel(
|
|
|
485
641
|
const largest = message.photo[message.photo.length - 1];
|
|
486
642
|
const buffer = await downloadFile(token, largest.file_id);
|
|
487
643
|
const dataUrl = `data:image/jpeg;base64,${buffer.toString('base64')}`;
|
|
488
|
-
await
|
|
644
|
+
await deliver(chatId, text || '(imagen)', [dataUrl]);
|
|
489
645
|
} catch (error) {
|
|
490
646
|
await send(chatId, `No pude procesar la imagen: ${error.message}`);
|
|
491
647
|
}
|
|
492
648
|
return;
|
|
493
649
|
}
|
|
494
|
-
if (text) await
|
|
650
|
+
if (text) await deliver(chatId, text);
|
|
495
651
|
}
|
|
496
652
|
|
|
497
653
|
async function handleCallback(query) {
|
|
@@ -502,19 +658,32 @@ export function startTelegramChannel(
|
|
|
502
658
|
if (kind === 'perm') {
|
|
503
659
|
ctx.approvals.decide(a, b === 'allow');
|
|
504
660
|
toast = b === 'allow' ? 'Permitido' : 'Denegado';
|
|
661
|
+
} else if (kind === 'proj') {
|
|
662
|
+
if (a === 'root') {
|
|
663
|
+
await selectProject(chatId, projectRoot());
|
|
664
|
+
toast = 'Proyecto elegido';
|
|
665
|
+
} else {
|
|
666
|
+
const choice = (projectChoices.get(chatId) ?? [])[Number(a)];
|
|
667
|
+
if (choice) {
|
|
668
|
+
await selectProject(chatId, choice.path);
|
|
669
|
+
toast = `Proyecto ${choice.name}`;
|
|
670
|
+
} else {
|
|
671
|
+
toast = 'Elegí de nuevo';
|
|
672
|
+
}
|
|
673
|
+
}
|
|
505
674
|
} else if (kind === 'mode') {
|
|
506
675
|
ctx.store.setSetting('activeMode', resolveMode(a).id);
|
|
507
676
|
toast = `Modo ${resolveMode(a).label}`;
|
|
508
677
|
} else if (kind === 'provider') {
|
|
509
678
|
ctx.store.setSetting('activeProvider', a);
|
|
510
|
-
const sid =
|
|
679
|
+
const sid = activeSessionId(chatId);
|
|
511
680
|
if (sid) ctx.store.touchSession(sid, { provider: a });
|
|
512
681
|
toast = `Proveedor ${a}`;
|
|
513
682
|
} else if (kind === 'model') {
|
|
514
683
|
const id = (modelChoices.get(chatId) ?? [])[Number(a)];
|
|
515
684
|
if (id) {
|
|
516
685
|
ctx.store.setSetting('activeModel', id);
|
|
517
|
-
const sid =
|
|
686
|
+
const sid = activeSessionId(chatId);
|
|
518
687
|
if (sid) ctx.store.touchSession(sid, { model: id });
|
|
519
688
|
toast = `Modelo ${id}`;
|
|
520
689
|
}
|
|
@@ -522,9 +691,11 @@ export function startTelegramChannel(
|
|
|
522
691
|
if (a === 'models') await sendModels(chatId);
|
|
523
692
|
else if (a === 'providers') await sendProviders(chatId);
|
|
524
693
|
else if (a === 'home') await sendMenu(chatId);
|
|
694
|
+
else if (a === 'project') await sendProjectPicker(chatId, { text: 'Elegí el proyecto:' });
|
|
525
695
|
else if (a === 'new') {
|
|
526
696
|
ctx.store.setSetting(sessionKey(chatId), null);
|
|
527
|
-
|
|
697
|
+
pendingInputs.delete(String(chatId));
|
|
698
|
+
await sendProjectPicker(chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
|
|
528
699
|
} else if (a === 'stop') {
|
|
529
700
|
controllers.get(chatId)?.abort();
|
|
530
701
|
await send(chatId, 'Cancelando…');
|
|
@@ -563,6 +734,16 @@ export function startTelegramChannel(
|
|
|
563
734
|
|
|
564
735
|
loop().catch((error) => log(`telegram: ${error?.message ?? error}`));
|
|
565
736
|
|
|
737
|
+
/** Chats que deben recibir un aviso de una sesión (vínculo explícito o proyecto abierto). */
|
|
738
|
+
function notifyTargets(sessionId) {
|
|
739
|
+
const explicit = ctx.store.getSetting(reverseKey(sessionId));
|
|
740
|
+
if (explicit) return [String(explicit)];
|
|
741
|
+
if (!notifyConfig.enabled) return [];
|
|
742
|
+
const session = ctx.store.getSession(sessionId);
|
|
743
|
+
if (!session || !projectIsOpen(session)) return [];
|
|
744
|
+
return [...allowed];
|
|
745
|
+
}
|
|
746
|
+
|
|
566
747
|
return {
|
|
567
748
|
id: 'telegram',
|
|
568
749
|
stop() {
|
|
@@ -570,45 +751,52 @@ export function startTelegramChannel(
|
|
|
570
751
|
for (const controller of controllers.values()) controller.abort();
|
|
571
752
|
controllers.clear();
|
|
572
753
|
},
|
|
573
|
-
/** Avisa
|
|
754
|
+
/** Avisa sobre un run de otra interfaz (la consola), siempre con el proyecto. */
|
|
574
755
|
notify(sessionId, event) {
|
|
575
756
|
if (ownSessions.has(sessionId)) return;
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
if (event.type === '
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
event.data?.
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
})
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
757
|
+
|
|
758
|
+
// La tool `speak` entrega audio en vivo a un chat vinculado.
|
|
759
|
+
if (event.type === 'tool_call') {
|
|
760
|
+
if (event.data?.name !== 'speak') return;
|
|
761
|
+
const linked = ctx.store.getSetting(reverseKey(sessionId));
|
|
762
|
+
if (linked) {
|
|
763
|
+
enviarNotaDeVoz(linked, event.data?.args?.texto, {
|
|
764
|
+
voice: event.data?.args?.voice,
|
|
765
|
+
rate: event.data?.args?.rate,
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const targets = notifyTargets(sessionId);
|
|
772
|
+
if (!targets.length) return;
|
|
773
|
+
const session = ctx.store.getSession(sessionId);
|
|
774
|
+
const project = projectLabel(session);
|
|
775
|
+
|
|
776
|
+
if (event.type === 'done' && notifyConfig.done) {
|
|
777
|
+
const text = `[${project}] ✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`;
|
|
778
|
+
for (const chatId of targets) send(chatId, text).catch(() => {});
|
|
779
|
+
} else if (event.type === 'error' && notifyConfig.error) {
|
|
780
|
+
const text = `[${project}] ✗ Error en la sesión: ${event.data?.message ?? ''}`;
|
|
781
|
+
for (const chatId of targets) send(chatId, text).catch(() => {});
|
|
782
|
+
} else if (event.type === 'agent_end' && notifyConfig.agent) {
|
|
783
|
+
const text = `[${project}] 🤖 Subagente ${event.data?.status}: ${String(event.data?.content ?? '').slice(0, 1500)}`;
|
|
784
|
+
for (const chatId of targets) send(chatId, text).catch(() => {});
|
|
785
|
+
} else if (event.type === 'permission' && notifyConfig.permission) {
|
|
600
786
|
const askId = event.data?.askId;
|
|
601
787
|
if (!askId) return;
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
[
|
|
606
|
-
|
|
607
|
-
|
|
788
|
+
for (const chatId of targets) {
|
|
789
|
+
send(chatId, `[${project}] ⚠ Permiso para ${event.data?.name ?? 'una tool'}`, {
|
|
790
|
+
reply_markup: {
|
|
791
|
+
inline_keyboard: [
|
|
792
|
+
[
|
|
793
|
+
{ text: 'Permitir', callback_data: `perm:${askId}:allow` },
|
|
794
|
+
{ text: 'Denegar', callback_data: `perm:${askId}:deny` },
|
|
795
|
+
],
|
|
608
796
|
],
|
|
609
|
-
|
|
610
|
-
}
|
|
611
|
-
}
|
|
797
|
+
},
|
|
798
|
+
}).catch(() => {});
|
|
799
|
+
}
|
|
612
800
|
}
|
|
613
801
|
},
|
|
614
802
|
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Presencia de pestañas abiertas en la consola. El cliente reporta qué sesiones
|
|
2
|
+
// (pestañas) tiene abiertas; el canal de Telegram la usa para saber qué
|
|
3
|
+
// proyectos están "habilitados" para avisar (los abiertos en el chat).
|
|
4
|
+
//
|
|
5
|
+
// Es efímera a propósito: si la consola se cierra no hay un "adiós", así que
|
|
6
|
+
// cada entrada vence por TTL y el cliente la refresca con un latido.
|
|
7
|
+
|
|
8
|
+
const DEFAULT_TTL_MS = 90000;
|
|
9
|
+
|
|
10
|
+
/** Tracker en memoria: sessionId -> último latido. */
|
|
11
|
+
export function createPresence({ ttlMs = DEFAULT_TTL_MS, now = Date.now } = {}) {
|
|
12
|
+
const seen = new Map();
|
|
13
|
+
|
|
14
|
+
const alive = (ts) => now() - ts <= ttlMs;
|
|
15
|
+
|
|
16
|
+
return {
|
|
17
|
+
/** Marca como abiertas (vistas recién) esas sesiones. */
|
|
18
|
+
touch(ids = []) {
|
|
19
|
+
const ts = now();
|
|
20
|
+
for (const id of ids) if (id) seen.set(String(id), ts);
|
|
21
|
+
return this;
|
|
22
|
+
},
|
|
23
|
+
/** Marca como cerradas (quita la sesión sin esperar el TTL). */
|
|
24
|
+
close(ids = []) {
|
|
25
|
+
for (const id of ids) seen.delete(String(id));
|
|
26
|
+
return this;
|
|
27
|
+
},
|
|
28
|
+
has(sessionId) {
|
|
29
|
+
const id = String(sessionId ?? '');
|
|
30
|
+
const ts = seen.get(id);
|
|
31
|
+
if (ts === undefined) return false;
|
|
32
|
+
if (!alive(ts)) {
|
|
33
|
+
seen.delete(id);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
return true;
|
|
37
|
+
},
|
|
38
|
+
/** Ids vivos (purga los vencidos de paso). */
|
|
39
|
+
ids() {
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const [id, ts] of seen) {
|
|
42
|
+
if (alive(ts)) out.push(id);
|
|
43
|
+
else seen.delete(id);
|
|
44
|
+
}
|
|
45
|
+
return out;
|
|
46
|
+
},
|
|
47
|
+
clear() {
|
|
48
|
+
seen.clear();
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|