@danieltmn/tamnora 0.9.0 → 0.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/harness/packages/core/src/config/app.js +2 -0
  3. package/harness/packages/core/src/index.js +1 -0
  4. package/harness/packages/core/src/stt/google.js +91 -0
  5. package/harness/packages/server/public/app.js +659 -66
  6. package/harness/packages/server/public/index.html +120 -2
  7. package/harness/packages/server/public/styles.css +19 -0
  8. package/harness/packages/server/src/lib/channels/index.js +23 -3
  9. package/harness/packages/server/src/lib/channels/telegram.js +301 -93
  10. package/harness/packages/server/src/lib/presence.js +51 -0
  11. package/harness/packages/server/src/lib/profile.js +63 -0
  12. package/harness/packages/server/src/lib/prompt.js +7 -2
  13. package/harness/packages/server/src/lib/transcribe.js +51 -0
  14. package/harness/packages/server/src/routes/channels.js +2 -0
  15. package/harness/packages/server/src/routes/presence.js +17 -0
  16. package/harness/packages/server/src/routes/profile.js +22 -0
  17. package/harness/packages/server/src/routes/providers.js +2 -0
  18. package/harness/packages/server/src/routes/transcribe.js +115 -0
  19. package/harness/packages/server/src/routes/tts.js +2 -1
  20. package/harness/packages/server/src/server.js +11 -0
  21. package/harness/packages/server/test/profile.test.js +85 -0
  22. package/harness/packages/server/test/telegram-features.test.js +241 -0
  23. package/harness/packages/server/test/telegram-voz.test.js +9 -0
  24. package/harness/packages/server/test/telegram.test.js +37 -2
  25. package/harness/packages/server/test/transcribe.test.js +215 -0
  26. package/harness/packages/server/test/tts.test.js +24 -0
  27. 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,
@@ -15,15 +17,23 @@ import {
15
17
  import { fraseFin } from '../announcer.js';
16
18
  import { mp3AOgg } from '../ffmpeg.js';
17
19
  import { resolveTts } from '../ttsSettings.js';
20
+ import { transcribeAudio } from '../transcribe.js';
18
21
 
19
22
  const API = 'https://api.telegram.org';
23
+ const MAX_PROJECTS = 24;
24
+
25
+ const RESUME_INSTRUCTION =
26
+ '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
27
 
21
28
  const HELP = [
22
29
  'Tamnora Harness — bot',
23
30
  '',
31
+ '/proyecto [nombre] — elegí el proyecto (sin argumento: lista)',
32
+ '/new — nueva sesión (vuelve a preguntar el proyecto)',
33
+ '/resumen — resumen en texto de la conversación',
34
+ '/audioresumen — el resumen como nota de voz',
24
35
  '/menu — panel con botones (modelo, modo, proveedor)',
25
- '/new — nueva sesión',
26
- '/status — proveedor, modelo, modo y sesión',
36
+ '/status — proveedor, modelo, modo, proyecto y sesión',
27
37
  '/model [id] — cambia el modelo (sin id: lista con botones)',
28
38
  '/provider [id] — cambia el proveedor (sin id: lista)',
29
39
  '/mode [plan|build] — cambia el modo (sin arg: panel)',
@@ -111,13 +121,30 @@ export function completionFooter(stopReason, rounds, toolCalls, durationMs) {
111
121
  */
112
122
  export function startTelegramChannel(
113
123
  ctx,
114
- { token, allowedChatIds = [], transcribeModel = 'whisper-1', transcribeProvider = '', log = () => {} } = {}
124
+ {
125
+ token,
126
+ allowedChatIds = [],
127
+ transcribeModel = 'whisper-1',
128
+ transcribeProvider = '',
129
+ notify = {},
130
+ root = '',
131
+ log = () => {},
132
+ } = {}
115
133
  ) {
116
134
  if (!token) return null;
117
135
  const allowed = new Set(allowedChatIds.map(String));
136
+ const notifyConfig = {
137
+ enabled: notify?.enabled === true,
138
+ done: notify?.done !== false,
139
+ error: notify?.error !== false,
140
+ permission: notify?.permission !== false,
141
+ agent: notify?.agent !== false,
142
+ };
118
143
  const controllers = new Map();
119
144
  const ownSessions = new Set();
120
145
  const modelChoices = new Map();
146
+ const projectChoices = new Map();
147
+ const pendingInputs = new Map();
121
148
  let running = true;
122
149
  let offset = 0;
123
150
 
@@ -175,52 +202,144 @@ export function startTelegramChannel(
175
202
  await enviarNotaDeVoz(chatId, texto);
176
203
  }
177
204
 
178
- function ensureSession(chatId) {
179
- const key = sessionKey(chatId);
180
- let sessionId = ctx.store.getSetting(key);
181
- if (!sessionId || !ctx.store.getSession(sessionId)) {
182
- sessionId = ctx.store.createSession({
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);
205
+ // --- proyectos ------------------------------------------------------------
206
+
207
+ /** Carpeta raíz para elegir proyecto: la predeterminada de la consola o el cwd. */
208
+ function projectRoot() {
209
+ const stored = ctx.store.getSetting('defaultCwd');
210
+ const candidate = stored || root || ctx.cwd;
211
+ try {
212
+ return resolveExistingDir(candidate);
213
+ } catch {
214
+ return ctx.cwd;
190
215
  }
216
+ }
217
+
218
+ function listProjects() {
219
+ try {
220
+ return listSubdirs(projectRoot()).map((dir) => ({ name: dir.name, path: dir.path }));
221
+ } catch {
222
+ return [];
223
+ }
224
+ }
225
+
226
+ function projectLabel(session) {
227
+ const cwd = session?.cwd || '';
228
+ return cwd ? path.basename(cwd) : 'sin carpeta';
229
+ }
230
+
231
+ /** Un proyecto está habilitado si alguna pestaña abierta de la consola lo usa. */
232
+ function projectIsOpen(session) {
233
+ const ids = ctx.presence?.ids?.() ?? [];
234
+ if (!ids.length) return false;
235
+ const cwd = session?.cwd || '';
236
+ return ids.some((id) => {
237
+ const open = ctx.store.getSession(id);
238
+ return open && (open.cwd || '') === cwd;
239
+ });
240
+ }
241
+
242
+ function activeSessionId(chatId) {
243
+ const id = ctx.store.getSetting(sessionKey(chatId));
244
+ if (id && ctx.store.getSession(id)) return id;
245
+ return null;
246
+ }
247
+
248
+ function createSessionForProject(chatId, cwd) {
249
+ const sessionId = ctx.store.createSession({
250
+ title: path.basename(cwd) || `Telegram ${chatId}`,
251
+ provider: ctx.effectiveProviderId(),
252
+ model: ctx.effectiveModel(),
253
+ cwd,
254
+ meta: { telegramChatId: String(chatId) },
255
+ });
256
+ ctx.store.setSetting(sessionKey(chatId), sessionId);
191
257
  ctx.store.setSetting(reverseKey(sessionId), String(chatId));
192
258
  return sessionId;
193
259
  }
194
260
 
195
- async function transcribe(buffer, filename) {
196
- const providerId = transcribeProvider || ctx.effectiveProviderId();
197
- const settings = ctx.providerSettings(providerId) ?? {};
198
- const baseUrl = (settings.baseUrl || '').replace(/\/+$/, '');
199
- const apiKey = ctx.apiKeyFor(providerId);
200
- if (!baseUrl) throw new Error(`el proveedor "${providerId}" no tiene baseUrl para transcribir`);
201
- const form = new FormData();
202
- form.append('file', new Blob([buffer]), filename);
203
- form.append('model', transcribeModel);
204
- const res = await fetch(`${baseUrl}/audio/transcriptions`, {
205
- method: 'POST',
206
- headers: apiKey ? { Authorization: `Bearer ${apiKey}` } : {},
207
- body: form,
208
- });
209
- if (!res.ok) {
210
- const hint =
211
- res.status === 404
212
- ? ` — "${providerId}" no expone /audio/transcriptions`
213
- : '';
214
- throw new Error(
215
- `transcripción falló (HTTP ${res.status})${hint}. Elegí un proveedor con voz (OpenAI, Groq…) en Ajustes → Telegram.`
216
- );
261
+ function resolveProjectArg(arg) {
262
+ const value = String(arg ?? '').trim();
263
+ if (!value) return null;
264
+ if (/[\\/]/.test(value) || /^[a-zA-Z]:/.test(value)) return value;
265
+ const projects = listProjects();
266
+ const exact = projects.find((p) => p.name.toLowerCase() === value.toLowerCase());
267
+ if (exact) return exact.path;
268
+ const matches = projects.filter((p) => p.name.toLowerCase().includes(value.toLowerCase()));
269
+ return matches.length === 1 ? matches[0].path : null;
270
+ }
271
+
272
+ async function sendProjectPicker(chatId, { text } = {}) {
273
+ const base = projectRoot();
274
+ const all = listProjects();
275
+ const shown = all.slice(0, MAX_PROJECTS);
276
+ projectChoices.set(String(chatId), shown);
277
+ const rows = shown.map((project, index) => [{ text: project.name, callback_data: `proj:${index}` }]);
278
+ rows.push([{ text: `📁 usar «${path.basename(base)}»`, callback_data: 'proj:root' }]);
279
+ const intro = text || '¿En qué proyecto vas a trabajar?';
280
+ await send(chatId, `${intro}\n\nraíz: ${base}`, { reply_markup: { inline_keyboard: rows } });
281
+ if (all.length > shown.length) {
282
+ await send(chatId, `Hay ${all.length} proyectos; mostré ${shown.length}. Para otro, usá /proyecto <nombre>.`);
217
283
  }
218
- const data = await res.json();
219
- return String(data.text ?? '').trim();
284
+ }
285
+
286
+ async function selectProject(chatId, projectPath) {
287
+ let cwd;
288
+ try {
289
+ cwd = resolveExistingDir(projectPath);
290
+ } catch (error) {
291
+ return send(chatId, `No pude usar ese proyecto: ${error.message}`);
292
+ }
293
+ createSessionForProject(chatId, cwd);
294
+ const key = String(chatId);
295
+ const pending = pendingInputs.get(key);
296
+ pendingInputs.delete(key);
297
+ await send(chatId, `Proyecto «${path.basename(cwd)}».${pending ? '' : ' Mandá tu mensaje.'}`);
298
+ if (pending) run(chatId, pending.input, pending.images).catch((error) => log(`telegram: ${error?.message ?? error}`));
299
+ }
300
+
301
+ function transcribe(buffer, filename) {
302
+ return transcribeAudio(ctx, {
303
+ buffer,
304
+ filename,
305
+ providerId: transcribeProvider || undefined,
306
+ model: transcribeModel,
307
+ });
308
+ }
309
+
310
+ /** Resumen de la conversación de una sesión (sin tocar el historial). */
311
+ async function resumirSesion(sessionId) {
312
+ const messages = ctx.store.getMessages(sessionId);
313
+ if (!messages.length) throw new Error('La conversación está vacía.');
314
+ const session = ctx.store.getSession(sessionId);
315
+ const providerId = session?.provider || ctx.effectiveProviderId();
316
+ const model = session?.model || ctx.effectiveModel();
317
+ if (!model) throw new Error('Falta el modelo.');
318
+ const transcript = messages
319
+ .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
320
+ .join('\n');
321
+ const { provider, config } = resolveProviderFor(ctx, providerId);
322
+ const result = await runAgent({
323
+ provider,
324
+ config,
325
+ model,
326
+ messages: [
327
+ { role: 'system', content: RESUME_INSTRUCTION },
328
+ { role: 'user', content: transcript.slice(0, 60000) },
329
+ ],
330
+ tools: [],
331
+ });
332
+ const summary = result.content?.trim();
333
+ if (!summary) throw new Error('El modelo no devolvió un resumen.');
334
+ return { summary };
220
335
  }
221
336
 
222
337
  async function run(chatId, input, images = []) {
223
- const sessionId = ensureSession(chatId);
338
+ const sessionId = activeSessionId(chatId);
339
+ if (!sessionId) {
340
+ pendingInputs.set(String(chatId), { input, images });
341
+ return sendProjectPicker(chatId);
342
+ }
224
343
  const session = ctx.store.getSession(sessionId);
225
344
  const providerId = session.provider || ctx.effectiveProviderId();
226
345
  const model = session.model || ctx.effectiveModel();
@@ -242,7 +361,7 @@ export function startTelegramChannel(
242
361
  const onAsk = async (name, args) => {
243
362
  const askId = randomUUID();
244
363
  const preview = JSON.stringify(args ?? {}).slice(0, 300);
245
- await send(chatId, `⚠ Permiso para ${name}\n${preview}`, {
364
+ await send(chatId, `⚠ [${projectLabel(session)}] Permiso para ${name}\n${preview}`, {
246
365
  reply_markup: {
247
366
  inline_keyboard: [
248
367
  [
@@ -352,12 +471,18 @@ export function startTelegramChannel(
352
471
  }
353
472
 
354
473
  async function sendMenu(chatId) {
355
- const sessionId = ensureSession(chatId);
356
- const session = ctx.store.getSession(sessionId);
474
+ const sessionId = activeSessionId(chatId);
475
+ const session = sessionId ? ctx.store.getSession(sessionId) : null;
357
476
  const providerId = session?.provider || ctx.effectiveProviderId();
358
477
  const model = session?.model || ctx.effectiveModel() || '—';
359
478
  const mode = resolveMode(ctx.effectiveMode());
360
- const text = ['⚙ Panel', `proveedor: ${providerId}`, `modelo: ${model}`, `modo: ${mode.label}`].join('\n');
479
+ const text = [
480
+ '⚙ Panel',
481
+ `proyecto: ${session ? projectLabel(session) : '(elegí con /proyecto)'}`,
482
+ `proveedor: ${providerId}`,
483
+ `modelo: ${model}`,
484
+ `modo: ${mode.label}`,
485
+ ].join('\n');
361
486
  await send(chatId, text, {
362
487
  reply_markup: {
363
488
  inline_keyboard: [
@@ -370,23 +495,25 @@ export function startTelegramChannel(
370
495
  { text: 'proveedores', callback_data: 'menu:providers' },
371
496
  ],
372
497
  [
498
+ { text: '📁 proyecto', callback_data: 'menu:project' },
373
499
  { text: '🆕 nueva', callback_data: 'menu:new' },
374
- { text: '⏹ detener', callback_data: 'menu:stop' },
375
500
  ],
501
+ [{ text: '⏹ detener', callback_data: 'menu:stop' }],
376
502
  ],
377
503
  },
378
504
  });
379
505
  }
380
506
 
381
507
  async function sendModels(chatId) {
382
- const sessionId = ensureSession(chatId);
383
- const providerId = ctx.store.getSession(sessionId)?.provider || ctx.effectiveProviderId();
508
+ const sessionId = activeSessionId(chatId);
509
+ const session = sessionId ? ctx.store.getSession(sessionId) : null;
510
+ const providerId = session?.provider || ctx.effectiveProviderId();
384
511
  const models = await listProviderModels({
385
512
  ...ctx.providerSettings(providerId),
386
513
  apiKey: ctx.apiKeyFor(providerId),
387
514
  }).catch(() => []);
388
515
 
389
- const current = ctx.store.getSession(sessionId)?.model || ctx.effectiveModel();
516
+ const current = session?.model || ctx.effectiveModel();
390
517
  if (!models?.length) return send(chatId, 'No pude listar modelos. Usá /model <id>.');
391
518
  const capped = models.slice(0, 12);
392
519
  modelChoices.set(String(chatId), capped.map((m) => m.id));
@@ -413,20 +540,49 @@ export function startTelegramChannel(
413
540
  const [raw, ...rest] = text.slice(1).split(/\s+/);
414
541
  const cmd = raw.toLowerCase();
415
542
  const arg = rest.join(' ').trim();
416
- const sessionId = ctx.store.getSetting(sessionKey(chatId));
417
- if (cmd === 'start' || cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
543
+ const sessionId = activeSessionId(chatId);
544
+ if (cmd === 'start') {
545
+ if (sessionId) {
546
+ return send(chatId, `Hola. Proyecto actual: «${projectLabel(ctx.store.getSession(sessionId))}».\n\n${HELP}`);
547
+ }
548
+ return sendProjectPicker(chatId, { text: 'Hola. ¿En qué proyecto vas a trabajar?' });
549
+ }
550
+ if (cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
418
551
  if (cmd === 'menu' || cmd === 'panel' || cmd === 'config') return sendMenu(chatId);
419
552
  if (cmd === 'new' || cmd === 'nueva') {
420
553
  ctx.store.setSetting(sessionKey(chatId), null);
421
- return send(chatId, 'Nueva sesión. Mandá tu mensaje.');
554
+ pendingInputs.delete(String(chatId));
555
+ return sendProjectPicker(chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
556
+ }
557
+ if (cmd === 'proyecto' || cmd === 'proyectos' || cmd === 'project') {
558
+ if (!arg) return sendProjectPicker(chatId);
559
+ const projectPath = resolveProjectArg(arg);
560
+ if (!projectPath) {
561
+ return send(chatId, `No encontré el proyecto "${arg}". Usá /proyecto sin argumento para ver la lista.`);
562
+ }
563
+ return selectProject(chatId, projectPath);
422
564
  }
423
565
  if (cmd === 'status') {
424
566
  const session = sessionId ? ctx.store.getSession(sessionId) : null;
425
567
  return send(
426
568
  chatId,
427
- `proveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
569
+ `proyecto: ${session ? projectLabel(session) : '—'}\nproveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
428
570
  );
429
571
  }
572
+ if (cmd === 'resumen' || cmd === 'resume') {
573
+ if (!sessionId) return send(chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
574
+ const data = await resumirSesion(sessionId).catch((error) => ({ error: error.message }));
575
+ if (data.error) return send(chatId, `No pude resumir: ${data.error}`);
576
+ return send(chatId, `📝 Resumen\n\n${data.summary}`);
577
+ }
578
+ if (cmd === 'audioresumen' || cmd === 'audio-resumen') {
579
+ if (!sessionId) return send(chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
580
+ await send(chatId, '🎧 Preparando el resumen…');
581
+ const data = await resumirSesion(sessionId).catch((error) => ({ error: error.message }));
582
+ if (data.error) return send(chatId, `No pude resumir: ${data.error}`);
583
+ await send(chatId, `📝 Resumen\n\n${data.summary}`);
584
+ return enviarNotaDeVoz(chatId, data.summary);
585
+ }
430
586
  if (cmd === 'model' || cmd === 'modelo') {
431
587
  if (!arg) return sendModels(chatId);
432
588
  ctx.store.setSetting('activeModel', arg);
@@ -459,12 +615,21 @@ export function startTelegramChannel(
459
615
  const text = await transcribe(buffer, voice.file_name || 'audio.ogg');
460
616
  if (!text) return send(chatId, 'No pude transcribir el audio.');
461
617
  await send(chatId, `📝 ${text}`);
462
- return run(chatId, text);
618
+ return deliver(chatId, text);
463
619
  } catch (error) {
464
620
  return send(chatId, `No pude procesar el audio: ${error.message}`);
465
621
  }
466
622
  }
467
623
 
624
+ /** Entrega un mensaje: si no hay proyecto elegido, primero pregunta cuál. */
625
+ async function deliver(chatId, input, images = []) {
626
+ if (!activeSessionId(chatId)) {
627
+ pendingInputs.set(String(chatId), { input, images });
628
+ return sendProjectPicker(chatId);
629
+ }
630
+ return run(chatId, input, images);
631
+ }
632
+
468
633
  async function handleMessage(message) {
469
634
  const chatId = String(message.chat.id);
470
635
  if (!authorized(chatId)) {
@@ -485,13 +650,13 @@ export function startTelegramChannel(
485
650
  const largest = message.photo[message.photo.length - 1];
486
651
  const buffer = await downloadFile(token, largest.file_id);
487
652
  const dataUrl = `data:image/jpeg;base64,${buffer.toString('base64')}`;
488
- await run(chatId, text || '(imagen)', [dataUrl]);
653
+ await deliver(chatId, text || '(imagen)', [dataUrl]);
489
654
  } catch (error) {
490
655
  await send(chatId, `No pude procesar la imagen: ${error.message}`);
491
656
  }
492
657
  return;
493
658
  }
494
- if (text) await run(chatId, text);
659
+ if (text) await deliver(chatId, text);
495
660
  }
496
661
 
497
662
  async function handleCallback(query) {
@@ -502,19 +667,32 @@ export function startTelegramChannel(
502
667
  if (kind === 'perm') {
503
668
  ctx.approvals.decide(a, b === 'allow');
504
669
  toast = b === 'allow' ? 'Permitido' : 'Denegado';
670
+ } else if (kind === 'proj') {
671
+ if (a === 'root') {
672
+ await selectProject(chatId, projectRoot());
673
+ toast = 'Proyecto elegido';
674
+ } else {
675
+ const choice = (projectChoices.get(chatId) ?? [])[Number(a)];
676
+ if (choice) {
677
+ await selectProject(chatId, choice.path);
678
+ toast = `Proyecto ${choice.name}`;
679
+ } else {
680
+ toast = 'Elegí de nuevo';
681
+ }
682
+ }
505
683
  } else if (kind === 'mode') {
506
684
  ctx.store.setSetting('activeMode', resolveMode(a).id);
507
685
  toast = `Modo ${resolveMode(a).label}`;
508
686
  } else if (kind === 'provider') {
509
687
  ctx.store.setSetting('activeProvider', a);
510
- const sid = ctx.store.getSetting(sessionKey(chatId));
688
+ const sid = activeSessionId(chatId);
511
689
  if (sid) ctx.store.touchSession(sid, { provider: a });
512
690
  toast = `Proveedor ${a}`;
513
691
  } else if (kind === 'model') {
514
692
  const id = (modelChoices.get(chatId) ?? [])[Number(a)];
515
693
  if (id) {
516
694
  ctx.store.setSetting('activeModel', id);
517
- const sid = ctx.store.getSetting(sessionKey(chatId));
695
+ const sid = activeSessionId(chatId);
518
696
  if (sid) ctx.store.touchSession(sid, { model: id });
519
697
  toast = `Modelo ${id}`;
520
698
  }
@@ -522,9 +700,11 @@ export function startTelegramChannel(
522
700
  if (a === 'models') await sendModels(chatId);
523
701
  else if (a === 'providers') await sendProviders(chatId);
524
702
  else if (a === 'home') await sendMenu(chatId);
703
+ else if (a === 'project') await sendProjectPicker(chatId, { text: 'Elegí el proyecto:' });
525
704
  else if (a === 'new') {
526
705
  ctx.store.setSetting(sessionKey(chatId), null);
527
- await send(chatId, 'Nueva sesión. Mandá tu mensaje.');
706
+ pendingInputs.delete(String(chatId));
707
+ await sendProjectPicker(chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
528
708
  } else if (a === 'stop') {
529
709
  controllers.get(chatId)?.abort();
530
710
  await send(chatId, 'Cancelando…');
@@ -563,6 +743,16 @@ export function startTelegramChannel(
563
743
 
564
744
  loop().catch((error) => log(`telegram: ${error?.message ?? error}`));
565
745
 
746
+ /** Chats que deben recibir un aviso de una sesión (vínculo explícito o proyecto abierto). */
747
+ function notifyTargets(sessionId) {
748
+ const explicit = ctx.store.getSetting(reverseKey(sessionId));
749
+ if (explicit) return [String(explicit)];
750
+ if (!notifyConfig.enabled) return [];
751
+ const session = ctx.store.getSession(sessionId);
752
+ if (!session || !projectIsOpen(session)) return [];
753
+ return [...allowed];
754
+ }
755
+
566
756
  return {
567
757
  id: 'telegram',
568
758
  stop() {
@@ -570,45 +760,63 @@ export function startTelegramChannel(
570
760
  for (const controller of controllers.values()) controller.abort();
571
761
  controllers.clear();
572
762
  },
573
- /** Avisa a un chat vinculado sobre un run de otra interfaz (p.ej. la consola). */
763
+ /** Avisa sobre un run de otra interfaz (la consola), siempre con el proyecto. */
574
764
  notify(sessionId, event) {
575
765
  if (ownSessions.has(sessionId)) return;
576
- const chatId = ctx.store.getSetting(reverseKey(sessionId));
577
- if (!chatId) return;
578
- if (event.type === 'done') {
579
- send(chatId, `✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`).catch(() => {});
580
- sendResumenDeVoz(
581
- chatId,
766
+
767
+ // La tool `speak` entrega audio en vivo a un chat vinculado.
768
+ if (event.type === 'tool_call') {
769
+ if (event.data?.name !== 'speak') return;
770
+ const linked = ctx.store.getSetting(reverseKey(sessionId));
771
+ if (linked) {
772
+ enviarNotaDeVoz(linked, event.data?.args?.texto, {
773
+ voice: event.data?.args?.voice,
774
+ rate: event.data?.args?.rate,
775
+ });
776
+ }
777
+ return;
778
+ }
779
+
780
+ const targets = notifyTargets(sessionId);
781
+ if (!targets.length) return;
782
+ const session = ctx.store.getSession(sessionId);
783
+ const project = projectLabel(session);
784
+
785
+ if (event.type === 'done' && notifyConfig.done) {
786
+ const text = `[${project}] ✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`;
787
+ const decir =
582
788
  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
- );
590
- } else if (event.type === 'error') {
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
- });
597
- } else if (event.type === 'agent_end') {
598
- send(chatId, `🤖 Subagente ${event.data?.status}: ${String(event.data?.content ?? '').slice(0, 1500)}`).catch(() => {});
599
- } else if (event.type === 'permission') {
789
+ fraseFin({
790
+ stopReason: event.data?.stopReason,
791
+ rounds: event.data?.rounds,
792
+ toolCalls: event.data?.toolCalls,
793
+ durationMs: event.data?.durationMs,
794
+ });
795
+ for (const chatId of targets) {
796
+ send(chatId, text).catch(() => {});
797
+ sendResumenDeVoz(chatId, `${project}. ${decir}`);
798
+ }
799
+ } else if (event.type === 'error' && notifyConfig.error) {
800
+ const text = `[${project}] ✗ Error en la sesión: ${event.data?.message ?? ''}`;
801
+ for (const chatId of targets) send(chatId, text).catch(() => {});
802
+ } else if (event.type === 'agent_end' && notifyConfig.agent) {
803
+ const text = `[${project}] 🤖 Subagente ${event.data?.status}: ${String(event.data?.content ?? '').slice(0, 1500)}`;
804
+ for (const chatId of targets) send(chatId, text).catch(() => {});
805
+ } else if (event.type === 'permission' && notifyConfig.permission) {
600
806
  const askId = event.data?.askId;
601
807
  if (!askId) return;
602
- send(chatId, `⚠ Permiso para ${event.data?.name ?? 'una tool'} (desde la consola)`, {
603
- reply_markup: {
604
- inline_keyboard: [
605
- [
606
- { text: 'Permitir', callback_data: `perm:${askId}:allow` },
607
- { text: 'Denegar', callback_data: `perm:${askId}:deny` },
808
+ for (const chatId of targets) {
809
+ send(chatId, `[${project}] ⚠ Permiso para ${event.data?.name ?? 'una tool'}`, {
810
+ reply_markup: {
811
+ inline_keyboard: [
812
+ [
813
+ { text: 'Permitir', callback_data: `perm:${askId}:allow` },
814
+ { text: 'Denegar', callback_data: `perm:${askId}:deny` },
815
+ ],
608
816
  ],
609
- ],
610
- },
611
- }).catch(() => {});
817
+ },
818
+ }).catch(() => {});
819
+ }
612
820
  }
613
821
  },
614
822
  };
@@ -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
+ }