@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.
Files changed (35) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/harness/packages/cli/src/tui/app.js +22 -3
  3. package/harness/packages/cli/src/tui/layout.js +59 -2
  4. package/harness/packages/cli/test/tui.test.js +30 -0
  5. package/harness/packages/core/src/config/app.js +2 -0
  6. package/harness/packages/core/src/index.js +2 -1
  7. package/harness/packages/core/src/providers/presets.js +4 -0
  8. package/harness/packages/core/src/stt/google.js +91 -0
  9. package/harness/packages/core/src/tools/diff.js +70 -1
  10. package/harness/packages/core/test/diff.test.js +24 -1
  11. package/harness/packages/server/public/app.js +824 -45
  12. package/harness/packages/server/public/index.html +121 -1
  13. package/harness/packages/server/public/styles.css +106 -12
  14. package/harness/packages/server/src/lib/channels/index.js +23 -3
  15. package/harness/packages/server/src/lib/channels/telegram.js +289 -101
  16. package/harness/packages/server/src/lib/presence.js +51 -0
  17. package/harness/packages/server/src/lib/profile.js +63 -0
  18. package/harness/packages/server/src/lib/prompt.js +7 -2
  19. package/harness/packages/server/src/lib/transcribe.js +51 -0
  20. package/harness/packages/server/src/routes/channels.js +2 -0
  21. package/harness/packages/server/src/routes/presence.js +17 -0
  22. package/harness/packages/server/src/routes/profile.js +22 -0
  23. package/harness/packages/server/src/routes/providers.js +2 -0
  24. package/harness/packages/server/src/routes/sessions.js +55 -33
  25. package/harness/packages/server/src/routes/transcribe.js +115 -0
  26. package/harness/packages/server/src/routes/tts.js +2 -1
  27. package/harness/packages/server/src/server.js +11 -0
  28. package/harness/packages/server/test/profile.test.js +85 -0
  29. package/harness/packages/server/test/server.test.js +34 -0
  30. package/harness/packages/server/test/telegram-features.test.js +241 -0
  31. package/harness/packages/server/test/telegram-voz.test.js +12 -5
  32. package/harness/packages/server/test/telegram.test.js +37 -2
  33. package/harness/packages/server/test/transcribe.test.js +215 -0
  34. package/harness/packages/server/test/tts.test.js +24 -0
  35. 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
- '/new — nueva sesión',
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
- { token, allowedChatIds = [], transcribeModel = 'whisper-1', transcribeProvider = '', log = () => {} } = {}
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
- /** 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);
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 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);
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
- 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
- );
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
- const data = await res.json();
219
- return String(data.text ?? '').trim();
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 = ensureSession(chatId);
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 = ensureSession(chatId);
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 = ['⚙ Panel', `proveedor: ${providerId}`, `modelo: ${model}`, `modo: ${mode.label}`].join('\n');
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 = ensureSession(chatId);
383
- const providerId = ctx.store.getSession(sessionId)?.provider || ctx.effectiveProviderId();
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 = ctx.store.getSession(sessionId)?.model || ctx.effectiveModel();
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 = ctx.store.getSetting(sessionKey(chatId));
417
- if (cmd === 'start' || cmd === 'help' || cmd === 'ayuda') return send(chatId, HELP);
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
- return send(chatId, 'Nueva sesión. Mandá tu mensaje.');
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
- `proveedor: ${session?.provider || ctx.effectiveProviderId()}\nmodelo: ${session?.model || ctx.effectiveModel() || '—'}\nmodo: ${ctx.effectiveMode()}\nsesión: ${session?.title || '—'}`
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 run(chatId, text);
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 run(chatId, text || '(imagen)', [dataUrl]);
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 run(chatId, text);
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 = ctx.store.getSetting(sessionKey(chatId));
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 = ctx.store.getSetting(sessionKey(chatId));
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
- await send(chatId, 'Nueva sesión. Mandá tu mensaje.');
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 a un chat vinculado sobre un run de otra interfaz (p.ej. la consola). */
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
- 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,
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
- );
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') {
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
- 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` },
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
- }).catch(() => {});
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
+ }