@danieltmn/tamnora 0.22.0 → 0.23.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/harness/packages/cli/bin/tamnora.mjs +7 -1
  5. package/harness/packages/cli/src/commands/doctor.js +42 -4
  6. package/harness/packages/cli/src/commands/repl.js +1 -0
  7. package/harness/packages/cli/src/commands/run.js +1 -0
  8. package/harness/packages/cli/src/commands/tui.js +1 -0
  9. package/harness/packages/cli/src/lib/runtime.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +2 -0
  11. package/harness/packages/core/src/browser/index.js +27 -13
  12. package/harness/packages/core/src/tts/edge.js +34 -4
  13. package/harness/packages/core/test/agent.test.js +18 -0
  14. package/harness/packages/core/test/servicios-vivos.test.js +71 -0
  15. package/harness/packages/core/test/tts.test.js +81 -0
  16. package/harness/packages/server/bin/tamnora-server.mjs +7 -1
  17. package/harness/packages/server/public/app.js +4 -3
  18. package/harness/packages/server/public/cards.js +470 -0
  19. package/harness/packages/server/public/chat.js +1343 -1893
  20. package/harness/packages/server/public/chatFormat.js +156 -0
  21. package/harness/packages/server/public/chrome.js +5 -4
  22. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  23. package/harness/packages/server/public/icons/logo_light.png +0 -0
  24. package/harness/packages/server/public/index.html +22 -23
  25. package/harness/packages/server/public/settings.js +4 -0
  26. package/harness/packages/server/public/styles.css +20 -50
  27. package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
  28. package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
  29. package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
  30. package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
  31. package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
  32. package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
  33. package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
  34. package/harness/packages/server/src/lib/channels/telegram.js +163 -933
  35. package/harness/packages/server/src/lib/scheduler.js +1 -0
  36. package/harness/packages/server/src/lib/tasks.js +1 -0
  37. package/harness/packages/server/src/routes/chat.js +1 -1
  38. package/harness/packages/server/src/routes/providers.js +2 -0
  39. package/harness/packages/server/src/server.js +6 -0
  40. package/harness/packages/server/test/chatFormat.test.js +119 -0
  41. package/harness/packages/server/test/consoleModules.test.js +301 -0
  42. package/harness/packages/server/test/consoleSmoke.test.js +34 -0
  43. package/harness/packages/server/test/helpers.js +15 -1
  44. package/harness/packages/server/test/ops.test.js +9 -5
  45. package/harness/packages/server/test/settings.test.js +18 -1
  46. package/harness/scripts/canary.mjs +24 -0
  47. package/package.json +3 -2
@@ -0,0 +1,309 @@
1
+ // Mensajes entrantes de Telegram: comandos (`/proyecto`, `/model`, ...), notas
2
+ // de voz, fotos y los botones de los paneles.
3
+
4
+ import { resolveMode, listProviderModels, PROVIDER_META } from '../../core.js';
5
+ import { downloadFile, send, tg } from './api.js';
6
+ import { enviarNotaDeVoz } from './media.js';
7
+ import {
8
+ activeSessionId,
9
+ projectLabel,
10
+ projectRoot,
11
+ resolveProjectArg,
12
+ selectProject,
13
+ sendProjectPicker,
14
+ } from './projects.js';
15
+ import { resumirSesion, run } from './run.js';
16
+ import { transcribeAudio } from '../../transcribe.js';
17
+
18
+ const HELP = [
19
+ 'Tamnora Harness — bot',
20
+ '',
21
+ '/proyecto [nombre] — elegí el proyecto (sin argumento: lista)',
22
+ '/new — nueva sesión (vuelve a preguntar el proyecto)',
23
+ '/resumen — resumen en texto de la conversación',
24
+ '/audioresumen — el resumen como nota de voz',
25
+ '/menu — panel con botones (modelo, modo, proveedor)',
26
+ '/status — proveedor, modelo, modo, proyecto y sesión',
27
+ '/model [id] — cambia el modelo (sin id: lista con botones)',
28
+ '/provider [id] — cambia el proveedor (sin id: lista)',
29
+ '/mode [plan|build] — cambia el modo (sin arg: panel)',
30
+ '/estricto [on|off] — el agente hace solo lo pedido (sin arg: alterna)',
31
+ '/stop — cancela la respuesta en curso',
32
+ '/help — esta ayuda',
33
+ '',
34
+ 'Podés mandar texto, imágenes o notas de voz.',
35
+ ].join('\n');
36
+
37
+ function transcribe(T, buffer, filename) {
38
+ return transcribeAudio(T.ctx, {
39
+ buffer,
40
+ filename,
41
+ providerId: T.transcribeProvider || undefined,
42
+ model: T.transcribeModel,
43
+ });
44
+ }
45
+
46
+ export async function sendMenu(T, chatId) {
47
+ const sessionId = activeSessionId(T, chatId);
48
+ const session = sessionId ? T.ctx.store.getSession(sessionId) : null;
49
+ const providerId = session?.provider || T.ctx.effectiveProviderId();
50
+ const model = session?.model || T.ctx.effectiveModel() || '-';
51
+ const mode = resolveMode(T.ctx.effectiveMode());
52
+ const text = [
53
+ '⚙ Panel',
54
+ `proyecto: ${session ? projectLabel(session) : '(elegí con /proyecto)'}`,
55
+ `proveedor: ${providerId}`,
56
+ `modelo: ${model}`,
57
+ `modo: ${mode.label}`,
58
+ ].join('\n');
59
+ await send(T, chatId, text, {
60
+ reply_markup: {
61
+ inline_keyboard: [
62
+ [
63
+ { text: mode.id === 'plan' ? '⚙ Plan' : 'Plan', callback_data: 'mode:plan' },
64
+ { text: mode.id === 'agent' ? '⚙ Build' : 'Build', callback_data: 'mode:agent' },
65
+ ],
66
+ [
67
+ { text: 'modelos', callback_data: 'menu:models' },
68
+ { text: 'proveedores', callback_data: 'menu:providers' },
69
+ ],
70
+ [
71
+ { text: '?? proyecto', callback_data: 'menu:project' },
72
+ { text: '?? nueva', callback_data: 'menu:new' },
73
+ ],
74
+ [{ text: '⚙ detener', callback_data: 'menu:stop' }],
75
+ ],
76
+ },
77
+ });
78
+ }
79
+
80
+ export async function sendModels(T, chatId) {
81
+ const sessionId = activeSessionId(T, chatId);
82
+ const session = sessionId ? T.ctx.store.getSession(sessionId) : null;
83
+ const providerId = session?.provider || T.ctx.effectiveProviderId();
84
+ const models = await listProviderModels({
85
+ ...T.ctx.providerSettings(providerId),
86
+ apiKey: T.ctx.apiKeyFor(providerId),
87
+ }).catch(() => []);
88
+
89
+ const current = session?.model || T.ctx.effectiveModel();
90
+ if (!models?.length) return send(T, chatId, 'No pude listar modelos. Usá /model <id>.');
91
+ const capped = models.slice(0, 12);
92
+ T.modelChoices.set(String(chatId), capped.map((m) => m.id));
93
+ const rows = capped.map((m, i) => [
94
+ { text: `${m.id === current ? '? ' : ''}${m.label || m.id}`, callback_data: `model:${i}` },
95
+ ]);
96
+ rows.push([{ text: '< panel', callback_data: 'menu:home' }]);
97
+ await send(T, chatId, `Modelos de ${providerId} (elegí uno):`, { reply_markup: { inline_keyboard: rows } });
98
+ if (models.length > capped.length) {
99
+ send(T, chatId, `Hay ${models.length} modelos; mostré 12. Para otro, usá /model <id>.`).catch(() => {});
100
+ }
101
+ }
102
+
103
+ export async function sendProviders(T, chatId) {
104
+ const custom = T.ctx.customProviderDefs?.() ?? [];
105
+ const ids = [...new Set([...PROVIDER_META.map((p) => p.id), ...custom.map((p) => p.id)])];
106
+ const current = T.ctx.effectiveProviderId();
107
+ const rows = ids.map((id) => [{ text: `${id === current ? '? ' : ''}${id}`, callback_data: `provider:${id}` }]);
108
+ rows.push([{ text: '< panel', callback_data: 'menu:home' }]);
109
+ await send(T, chatId, 'Proveedores (elegí uno):', { reply_markup: { inline_keyboard: rows } });
110
+ }
111
+
112
+ export async function handleCommand(T, chatId, text) {
113
+ const [raw, ...rest] = text.slice(1).split(/\s+/);
114
+ const cmd = raw.toLowerCase();
115
+ const arg = rest.join(' ').trim();
116
+ const sessionId = activeSessionId(T, chatId);
117
+ if (cmd === 'start') {
118
+ if (sessionId) {
119
+ return send(T, chatId, `Hola. Proyecto actual: «${projectLabel(T.ctx.store.getSession(sessionId))}».\n\n${HELP}`);
120
+ }
121
+ return sendProjectPicker(T, chatId, { text: 'Hola. ¿En qué proyecto vas a trabajar?' });
122
+ }
123
+ if (cmd === 'help' || cmd === 'ayuda') return send(T, chatId, HELP);
124
+ if (cmd === 'menu' || cmd === 'panel' || cmd === 'config') return sendMenu(T, chatId);
125
+ if (cmd === 'new' || cmd === 'nueva') {
126
+ T.ctx.store.setSetting(T.sessionKey(chatId), null);
127
+ T.pendingInputs.delete(String(chatId));
128
+ return sendProjectPicker(T, chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
129
+ }
130
+ if (cmd === 'proyecto' || cmd === 'proyectos' || cmd === 'project') {
131
+ if (!arg) return sendProjectPicker(T, chatId);
132
+ const projectPath = resolveProjectArg(T, arg);
133
+ if (!projectPath) {
134
+ return send(T, chatId, `No encontré el proyecto "${arg}". Usá /proyecto sin argumento para ver la lista.`);
135
+ }
136
+ return selectProject(T, chatId, projectPath);
137
+ }
138
+ if (cmd === 'status') {
139
+ const session = sessionId ? T.ctx.store.getSession(sessionId) : null;
140
+ return send(
141
+ T,
142
+ chatId,
143
+ `proyecto: ${session ? projectLabel(session) : '-'}\nproveedor: ${session?.provider || T.ctx.effectiveProviderId()}\nmodelo: ${session?.model || T.ctx.effectiveModel() || '-'}\nmodo: ${T.ctx.effectiveMode()}\nsolo lo pedido: ${T.ctx.store.getSetting('strictScope') === true ? 'sí' : 'no'}\nsesión: ${session?.title || '-'}`,
144
+ );
145
+ }
146
+ if (cmd === 'resumen' || cmd === 'resume') {
147
+ if (!sessionId) return send(T, chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
148
+ const data = await resumirSesion(T, sessionId).catch((error) => ({ error: error.message }));
149
+ if (data.error) return send(T, chatId, `No pude resumir: ${data.error}`);
150
+ return send(T, chatId, `?📝 Resumen\n\n${data.summary}`);
151
+ }
152
+ if (cmd === 'audioresumen' || cmd === 'audio-resumen') {
153
+ if (!sessionId) return send(T, chatId, 'No hay una conversación todavía. Elegí un proyecto con /proyecto.');
154
+ await send(T, chatId, '?📝 Preparando el resumen.');
155
+ const data = await resumirSesion(T, sessionId).catch((error) => ({ error: error.message }));
156
+ if (data.error) return send(T, chatId, `No pude resumir: ${data.error}`);
157
+ await send(T, chatId, `?📝 Resumen\n\n${data.summary}`);
158
+ return enviarNotaDeVoz(T, chatId, data.summary);
159
+ }
160
+ if (cmd === 'model' || cmd === 'modelo') {
161
+ if (!arg) return sendModels(T, chatId);
162
+ T.ctx.store.setSetting('activeModel', arg);
163
+ if (sessionId) T.ctx.store.touchSession(sessionId, { model: arg });
164
+ return send(T, chatId, `modelo: ${arg}`);
165
+ }
166
+ if (cmd === 'provider' || cmd === 'proveedor') {
167
+ if (!arg) return sendProviders(T, chatId);
168
+ T.ctx.store.setSetting('activeProvider', arg);
169
+ if (sessionId) T.ctx.store.touchSession(sessionId, { provider: arg });
170
+ return send(T, chatId, `proveedor: ${arg}`);
171
+ }
172
+ if (cmd === 'mode' || cmd === 'modo') {
173
+ if (!arg) return sendMenu(T, chatId);
174
+ const id = arg.toLowerCase() === 'build' ? 'agent' : arg.toLowerCase();
175
+ T.ctx.store.setSetting('activeMode', resolveMode(id).id);
176
+ return send(T, chatId, `modo: ${resolveMode(id).label}`);
177
+ }
178
+ if (cmd === 'stop' || cmd === 'parar') {
179
+ T.controllers.get(String(chatId))?.abort();
180
+ return send(T, chatId, 'Cancelando.');
181
+ }
182
+ if (cmd === 'estricto' || cmd === 'strict') {
183
+ const current = T.ctx.store.getSetting('strictScope') === true;
184
+ const value = arg ? /^(on|si|sí|1|true)$/i.test(arg) : !current;
185
+ T.ctx.store.setSetting('strictScope', value);
186
+ return send(T, chatId, `solo lo pedido: ${value ? 'activado' : 'desactivado'}`);
187
+ }
188
+ return send(T, chatId, `Comando desconocido: /${cmd}.\n\n${HELP}`);
189
+ }
190
+
191
+ export async function handleVoice(T, chatId, voice) {
192
+ await send(T, chatId, '?📝 Transcribiendo.');
193
+ try {
194
+ const buffer = await downloadFile(T.token, voice.file_id);
195
+ const text = await transcribe(T, buffer, voice.file_name || 'audio.ogg');
196
+ if (!text) return send(T, chatId, 'No pude transcribir el audio.');
197
+ await send(T, chatId, `?? ${text}`);
198
+ return deliver(T, chatId, text);
199
+ } catch (error) {
200
+ return send(T, chatId, `No pude procesar el audio: ${error.message}`);
201
+ }
202
+ }
203
+
204
+ /** Entrega un mensaje: si no hay proyecto elegido, primero pregunta cuál. */
205
+ export async function deliver(T, chatId, input, images = []) {
206
+ if (!activeSessionId(T, chatId)) {
207
+ T.pendingInputs.set(String(chatId), { input, images });
208
+ return sendProjectPicker(T, chatId);
209
+ }
210
+ return run(T, chatId, input, images);
211
+ }
212
+
213
+ export async function handleMessage(T, message) {
214
+ const chatId = String(message.chat.id);
215
+ if (!T.authorized(chatId)) {
216
+ await send(T, chatId, `No autorizado. Tu chat id es ${chatId}; agregalo a telegram.allowedChatIds para habilitarlo.`);
217
+ return;
218
+ }
219
+ const text = String(message.text ?? message.caption ?? '').trim();
220
+ if (text.startsWith('/')) {
221
+ await handleCommand(T, chatId, text);
222
+ return;
223
+ }
224
+ if (message.voice || message.audio) {
225
+ await handleVoice(T, chatId, message.voice ?? message.audio);
226
+ return;
227
+ }
228
+ if (message.photo?.length) {
229
+ try {
230
+ const largest = message.photo[message.photo.length - 1];
231
+ const buffer = await downloadFile(T.token, largest.file_id);
232
+ const dataUrl = `data:image/jpeg;base64,${buffer.toString('base64')}`;
233
+ await deliver(T, chatId, text || '(imagen)', [dataUrl]);
234
+ } catch (error) {
235
+ await send(T, chatId, `No pude procesar la imagen: ${error.message}`);
236
+ }
237
+ return;
238
+ }
239
+ if (text) await deliver(T, chatId, text);
240
+ }
241
+
242
+ export async function handleCallback(T, query) {
243
+ const chatId = String(query.message?.chat.id ?? '');
244
+ const [kind, a, b] = String(query.data ?? '').split(':');
245
+ let toast = '';
246
+
247
+ if (kind === 'perm') {
248
+ const all = b === 'all';
249
+ T.ctx.approvals.decide(a, b === 'allow' || all);
250
+ const sessionId = T.askSessions.get(a);
251
+ if (sessionId) T.askSessions.delete(a);
252
+ if (all && sessionId) T.ctx.setAutoApprove?.(sessionId, true);
253
+ toast = all ? 'Permitidos todos' : b === 'allow' ? 'Permitido' : 'Denegado';
254
+ } else if (kind === 'proj') {
255
+ if (a === 'root') {
256
+ await selectProject(T, chatId, projectRoot(T));
257
+ toast = 'Proyecto elegido';
258
+ } else {
259
+ const choice = (T.projectChoices.get(chatId) ?? [])[Number(a)];
260
+ if (choice) {
261
+ await selectProject(T, chatId, choice.path);
262
+ toast = `Proyecto ${choice.name}`;
263
+ } else {
264
+ toast = 'Elegí de nuevo';
265
+ }
266
+ }
267
+ } else if (kind === 'mode') {
268
+ T.ctx.store.setSetting('activeMode', resolveMode(a).id);
269
+ toast = `Modo ${resolveMode(a).label}`;
270
+ } else if (kind === 'provider') {
271
+ T.ctx.store.setSetting('activeProvider', a);
272
+ const sid = activeSessionId(T, chatId);
273
+ if (sid) T.ctx.store.touchSession(sid, { provider: a });
274
+ toast = `Proveedor ${a}`;
275
+ } else if (kind === 'model') {
276
+ const id = (T.modelChoices.get(chatId) ?? [])[Number(a)];
277
+ if (id) {
278
+ T.ctx.store.setSetting('activeModel', id);
279
+ const sid = activeSessionId(T, chatId);
280
+ if (sid) T.ctx.store.touchSession(sid, { model: id });
281
+ toast = `Modelo ${id}`;
282
+ }
283
+ } else if (kind === 'menu') {
284
+ if (a === 'models') await sendModels(T, chatId);
285
+ else if (a === 'providers') await sendProviders(T, chatId);
286
+ else if (a === 'home') await sendMenu(T, chatId);
287
+ else if (a === 'project') await sendProjectPicker(T, chatId, { text: 'Elegí el proyecto:' });
288
+ else if (a === 'new') {
289
+ T.ctx.store.setSetting(T.sessionKey(chatId), null);
290
+ T.pendingInputs.delete(String(chatId));
291
+ await sendProjectPicker(T, chatId, { text: 'Nueva sesión. ¿En qué proyecto vas a trabajar?' });
292
+ } else if (a === 'stop') {
293
+ T.controllers.get(chatId)?.abort();
294
+ await send(T, chatId, 'Cancelando.');
295
+ }
296
+ toast = 'ok';
297
+ }
298
+
299
+ await tg(T.token, 'answerCallbackQuery', { callback_query_id: query.id, text: toast }).catch(() => null);
300
+ // Solo los permisos cierran su tarjeta; el panel conserva los botones.
301
+ if (kind === 'perm' && query.message) {
302
+ await tg(T.token, 'editMessageReplyMarkup', {
303
+ chat_id: query.message.chat.id,
304
+ message_id: query.message.message_id,
305
+ }).catch(() => null);
306
+ }
307
+ }
308
+
309
+ export { HELP };
@@ -0,0 +1,64 @@
1
+ // Entrega de multimedia a un chat: fotos (capturas del tool `screenshot`) y
2
+ // notas de voz (TTS). Un fallo del servicio no debe romper el run.
3
+
4
+ import path from 'node:path';
5
+ import { readFile } from 'node:fs/promises';
6
+ import { sintetizar, MAX_TEXTO, screenshotsDir } from '../../core.js';
7
+ import { mp3AOgg } from '../../ffmpeg.js';
8
+ import { resolveTts } from '../../ttsSettings.js';
9
+ import { tgForm } from './api.js';
10
+
11
+ /**
12
+ * Nota de voz con un texto cualquiera. Con ffmpeg manda OGG/Opus
13
+ * (`sendVoice`, aparece como nota de voz); sin ffmpeg cae a audio MP3.
14
+ */
15
+ export async function enviarNotaDeVoz(T, chatId, texto, { voice, rate, pitch } = {}) {
16
+ const tts = resolveTts(T.ctx.app);
17
+ const frase = String(texto ?? '').trim().slice(0, MAX_TEXTO);
18
+ if (!frase) return;
19
+ try {
20
+ const mp3 = await sintetizar(frase, {
21
+ voice: voice || tts.voice,
22
+ rate: rate || tts.rate,
23
+ pitch: pitch || tts.pitch,
24
+ });
25
+ const ogg = await mp3AOgg(mp3);
26
+ const form = new FormData();
27
+ form.append('chat_id', chatId);
28
+ if (ogg) {
29
+ form.append('voice', new Blob([ogg], { type: 'audio/ogg' }), 'resumen.ogg');
30
+ await tgForm(T.token, 'sendVoice', form);
31
+ } else {
32
+ form.append('audio', new Blob([mp3], { type: 'audio/mpeg' }), 'resumen.mp3');
33
+ await tgForm(T.token, 'sendAudio', form);
34
+ }
35
+ } catch (error) {
36
+ T.log(`telegram: nota de voz falló: ${error?.message ?? error}`);
37
+ }
38
+ }
39
+
40
+ /** Manda una imagen (una captura del tool `screenshot`). */
41
+ export async function enviarFoto(T, chatId, buffer, caption) {
42
+ try {
43
+ const form = new FormData();
44
+ form.append('chat_id', chatId);
45
+ if (caption) form.append('caption', String(caption).slice(0, 1000));
46
+ form.append('photo', new Blob([buffer], { type: 'image/png' }), 'captura.png');
47
+ await tgForm(T.token, 'sendPhoto', form);
48
+ } catch (error) {
49
+ T.log(`telegram: foto falló: ${error?.message ?? error}`);
50
+ }
51
+ }
52
+
53
+ /** Entrega una captura guardada en la casa (por nombre, sin path traversal). */
54
+ export async function enviarCaptura(T, chatId, name, caption) {
55
+ const safe = String(name ?? '');
56
+ if (!/^[a-z0-9][a-z0-9-]*\.png$/i.test(safe)) return;
57
+ let buffer;
58
+ try {
59
+ buffer = await readFile(path.join(screenshotsDir(), safe));
60
+ } catch {
61
+ return;
62
+ }
63
+ await enviarFoto(T, chatId, buffer, caption);
64
+ }
@@ -0,0 +1,102 @@
1
+ // Elegir proyecto: un chat de Telegram arranca eligiendo en qué carpeta de la
2
+ // raíz trabajar, y cada proyecto tiene su propia sesión del harness.
3
+
4
+ import path from 'node:path';
5
+ import { resolveExistingDir, listSubdirs } from '../../workspace.js';
6
+ import { send } from './api.js';
7
+
8
+ export const MAX_PROJECTS = 24;
9
+
10
+ /** Carpeta raíz para elegir proyecto: la predeterminada de la consola o el cwd. */
11
+ export function projectRoot(T) {
12
+ const stored = T.ctx.store.getSetting('defaultCwd');
13
+ const candidate = stored || T.root || T.ctx.cwd;
14
+ try {
15
+ return resolveExistingDir(candidate);
16
+ } catch {
17
+ return T.ctx.cwd;
18
+ }
19
+ }
20
+
21
+ export function listProjects(T) {
22
+ try {
23
+ return listSubdirs(projectRoot(T)).map((dir) => ({ name: dir.name, path: dir.path }));
24
+ } catch {
25
+ return [];
26
+ }
27
+ }
28
+
29
+ export function projectLabel(session) {
30
+ const cwd = session?.cwd || '';
31
+ return cwd ? path.basename(cwd) : 'sin carpeta';
32
+ }
33
+
34
+ /** Un proyecto está habilitado si alguna pestaña abierta de la consola lo usa. */
35
+ export function projectIsOpen(T, session) {
36
+ const ids = T.ctx.presence?.ids?.() ?? [];
37
+ if (!ids.length) return false;
38
+ const cwd = session?.cwd || '';
39
+ return ids.some((id) => {
40
+ const open = T.ctx.store.getSession(id);
41
+ return open && (open.cwd || '') === cwd;
42
+ });
43
+ }
44
+
45
+ export function activeSessionId(T, chatId) {
46
+ const id = T.ctx.store.getSetting(T.sessionKey(chatId));
47
+ if (id && T.ctx.store.getSession(id)) return id;
48
+ return null;
49
+ }
50
+
51
+ export function createSessionForProject(T, chatId, cwd) {
52
+ const sessionId = T.ctx.store.createSession({
53
+ title: path.basename(cwd) || `Telegram ${chatId}`,
54
+ provider: T.ctx.effectiveProviderId(),
55
+ model: T.ctx.effectiveModel(),
56
+ cwd,
57
+ meta: { telegramChatId: String(chatId) },
58
+ });
59
+ T.ctx.store.setSetting(T.sessionKey(chatId), sessionId);
60
+ T.ctx.store.setSetting(T.reverseKey(sessionId), String(chatId));
61
+ return sessionId;
62
+ }
63
+
64
+ export function resolveProjectArg(T, arg) {
65
+ const value = String(arg ?? '').trim();
66
+ if (!value) return null;
67
+ if (/[\\/]/.test(value) || /^[a-zA-Z]:/.test(value)) return value;
68
+ const projects = listProjects(T);
69
+ const exact = projects.find((p) => p.name.toLowerCase() === value.toLowerCase());
70
+ if (exact) return exact.path;
71
+ const matches = projects.filter((p) => p.name.toLowerCase().includes(value.toLowerCase()));
72
+ return matches.length === 1 ? matches[0].path : null;
73
+ }
74
+
75
+ export async function sendProjectPicker(T, chatId, { text } = {}) {
76
+ const base = projectRoot(T);
77
+ const all = listProjects(T);
78
+ const shown = all.slice(0, MAX_PROJECTS);
79
+ T.projectChoices.set(String(chatId), shown);
80
+ const rows = shown.map((project, index) => [{ text: project.name, callback_data: `proj:${index}` }]);
81
+ rows.push([{ text: `?? usar «${path.basename(base)}»`, callback_data: 'proj:root' }]);
82
+ const intro = text || '¿En qué proyecto vas a trabajar?';
83
+ await send(T, chatId, `${intro}\n\nraíz: ${base}`, { reply_markup: { inline_keyboard: rows } });
84
+ if (all.length > shown.length) {
85
+ await send(T, chatId, `Hay ${all.length} proyectos; mostré ${shown.length}. Para otro, usá /proyecto <nombre>.`);
86
+ }
87
+ }
88
+
89
+ export async function selectProject(T, chatId, projectPath) {
90
+ let cwd;
91
+ try {
92
+ cwd = resolveExistingDir(projectPath);
93
+ } catch (error) {
94
+ return send(T, chatId, `No pude usar ese proyecto: ${error.message}`);
95
+ }
96
+ createSessionForProject(T, chatId, cwd);
97
+ const key = String(chatId);
98
+ const pending = T.pendingInputs.get(key);
99
+ T.pendingInputs.delete(key);
100
+ await send(T, chatId, `Proyecto «${path.basename(cwd)}».${pending ? '' : ' Mandá tu mensaje.'}`);
101
+ if (pending) T.run(T, chatId, pending.input, pending.images).catch((error) => T.log(`telegram: ${error?.message ?? error}`));
102
+ }
@@ -0,0 +1,206 @@
1
+ // El turno del agente en Telegram: armar las tools, correr el run y editar el
2
+ // mensaje con el progreso. También el resumen de la conversación.
3
+
4
+ import { randomUUID } from 'node:crypto';
5
+ import { runAgent, createTools, resolveMode } from '../../core.js';
6
+ import { resolveExistingDir } from '../../workspace.js';
7
+ import {
8
+ combineToolset,
9
+ composeAgentMessages,
10
+ buildUserMessage,
11
+ resolveProviderFor,
12
+ toolCallPayload,
13
+ } from '../../agentRun.js';
14
+ import { createRulesInjector } from '../../rules.js';
15
+ import { edit, send, tg } from './api.js';
16
+ import { completionFooter } from './text.js';
17
+ import { permissionMessage, permissionKeyboard } from './permissions.js';
18
+ import { enviarCaptura, enviarNotaDeVoz } from './media.js';
19
+ import { activeSessionId, projectLabel, sendProjectPicker } from './projects.js';
20
+
21
+ const RESUME_INSTRUCTION =
22
+ '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.';
23
+
24
+ /** Resumen de la conversación de una sesión (sin tocar el historial). */
25
+ export async function resumirSesion(T, sessionId) {
26
+ const messages = T.ctx.store.getMessages(sessionId);
27
+ if (!messages.length) throw new Error('La conversación está vacía.');
28
+ const session = T.ctx.store.getSession(sessionId);
29
+ const providerId = session?.provider || T.ctx.effectiveProviderId();
30
+ const model = session?.model || T.ctx.effectiveModel();
31
+ if (!model) throw new Error('Falta el modelo.');
32
+ const transcript = messages
33
+ .map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
34
+ .join('\n');
35
+ const { provider, config } = resolveProviderFor(T.ctx, providerId);
36
+ const result = await runAgent({
37
+ provider,
38
+ config,
39
+ model,
40
+ messages: [
41
+ { role: 'system', content: RESUME_INSTRUCTION },
42
+ { role: 'user', content: transcript.slice(0, 60000) },
43
+ ],
44
+ tools: [],
45
+ });
46
+ const summary = result.content?.trim();
47
+ if (!summary) throw new Error('El modelo no devolvió un resumen.');
48
+ return { summary };
49
+ }
50
+
51
+ export async function run(T, chatId, input, images = []) {
52
+ const sessionId = activeSessionId(T, chatId);
53
+ if (!sessionId) {
54
+ T.pendingInputs.set(String(chatId), { input, images });
55
+ return sendProjectPicker(T, chatId);
56
+ }
57
+ const session = T.ctx.store.getSession(sessionId);
58
+ const providerId = session.provider || T.ctx.effectiveProviderId();
59
+ const model = session.model || T.ctx.effectiveModel();
60
+ if (!model) {
61
+ await send(T, chatId, 'Falta el modelo. Usá /model <id> o configurá uno en Ajustes.');
62
+ return;
63
+ }
64
+ const mode = resolveMode(T.ctx.effectiveMode());
65
+ const cwd = resolveExistingDir(session.cwd || T.ctx.cwd);
66
+ const history = T.ctx.store.getMessages(sessionId);
67
+
68
+ let mcp = null;
69
+ try {
70
+ mcp = await T.ctx.getMcp();
71
+ } catch {
72
+ mcp = null;
73
+ }
74
+
75
+ const onAsk = async (name, args) => {
76
+ // "Permitir todo" activa el auto-approve de la sesión (compartido con la
77
+ // consola): a partir de ahí no se vuelve a preguntar.
78
+ if (T.ctx.isAutoApprove?.(sessionId)) return true;
79
+ const askId = randomUUID();
80
+ T.askSessions.set(askId, sessionId);
81
+ await send(T, chatId, permissionMessage(projectLabel(session), name, args), {
82
+ reply_markup: permissionKeyboard(askId),
83
+ });
84
+ return T.ctx.approvals.ask(askId, { sessionId, name, args });
85
+ };
86
+
87
+ const local = createTools({
88
+ root: cwd,
89
+ enabled: mode.tools ?? undefined,
90
+ permissions: T.ctx.config.permissions,
91
+ yes: false,
92
+ onAsk,
93
+ mcp,
94
+ mcpMode: T.ctx.effectiveMcpMode(),
95
+ mcpPinned: T.ctx.effectiveMcpPinned(),
96
+ });
97
+ const exposeAdmin = !mode.tools;
98
+ const admin = T.ctx.agentTools;
99
+ const { definitions, execute } = combineToolset({
100
+ local,
101
+ admin,
102
+ plugins: exposeAdmin ? T.ctx.plugins : null,
103
+ exposeAdmin,
104
+ approve: onAsk,
105
+ });
106
+ const { provider, config } = resolveProviderFor(T.ctx, providerId);
107
+ const userMessage = buildUserMessage(input, images);
108
+ const messages = await composeAgentMessages({ ctx: T.ctx, cwd, mode, history, userMessage });
109
+
110
+ const controller = new AbortController();
111
+ T.controllers.set(String(chatId), controller);
112
+ T.ownSessions.add(sessionId);
113
+ const startedAt = Date.now();
114
+ const message = await send(T, chatId, '.');
115
+ let full = '';
116
+ let activity = '';
117
+ let lastEdit = 0;
118
+
119
+ const render = () => {
120
+ const body = full.trim();
121
+ const suffix = activity ? `\n\n- ${activity}` : '';
122
+ return `${body || '.'}${suffix}`;
123
+ };
124
+
125
+ try {
126
+ await tg(T.token, 'sendChatAction', { chat_id: chatId, action: 'typing' }).catch(() => null);
127
+ const result = await runAgent({
128
+ provider,
129
+ config,
130
+ model,
131
+ messages,
132
+ tools: definitions,
133
+ execute,
134
+ toolContext: { cwd, sessionId },
135
+ onLifecycle: createRulesInjector({ cwd, patterns: T.ctx.config.rules ?? [] }) ?? undefined,
136
+ redactExtra: T.ctx.secretExtra?.() ?? [],
137
+ signal: controller.signal,
138
+ maxRounds: T.ctx.effectiveMaxRounds?.() ?? T.ctx.config.maxRounds,
139
+ maxTokens: T.ctx.effectiveMaxTokens?.() ?? 0,
140
+ maxDurationMs: T.ctx.effectiveMaxDurationMs?.() ?? 0,
141
+ maxOutputTokens: T.ctx.effectiveMaxOutputTokens?.() ?? T.ctx.config.maxOutputTokens ?? 0,
142
+ promptCaching: T.ctx.effectivePromptCaching?.() ?? T.ctx.config.promptCaching === true,
143
+ onEvent(event) {
144
+ if (event.type === 'text') {
145
+ full = event.full;
146
+ } else if (event.type === 'tool_call') {
147
+ activity = `${event.name} ${toolCallPayload(event).summary}`.trim();
148
+ if (event.name === 'speak') {
149
+ enviarNotaDeVoz(T, chatId, event.args?.texto, {
150
+ voice: event.args?.voice,
151
+ rate: event.args?.rate,
152
+ pitch: event.args?.pitch,
153
+ });
154
+ }
155
+ } else if (event.type === 'tool_result') {
156
+ activity = `${event.name} V`;
157
+ if (event.name === 'screenshot' && event.meta?.screenshot?.name) {
158
+ enviarCaptura(T, chatId, event.meta.screenshot.name, event.meta.screenshot.url);
159
+ }
160
+ } else {
161
+ return;
162
+ }
163
+ const now = Date.now();
164
+ if (now - lastEdit > 1200) {
165
+ lastEdit = now;
166
+ edit(T, chatId, message.message_id, render());
167
+ }
168
+ },
169
+ });
170
+ const newMessages = result.messages.slice(messages.length);
171
+ const toolCalls = newMessages.filter((m) => m.role === 'tool').length;
172
+ const durationMs = Date.now() - startedAt;
173
+ let body = result.content?.trim() || full.trim();
174
+ if (!body) {
175
+ if (result.stopReason === 'no_progress') {
176
+ body = '✓ El agente se quedó repitiendo las mismas herramientas sin avanzar.';
177
+ } else if (result.stopReason === 'budget') {
178
+ body = '✓ El agente alcanzó el presupuesto de tokens/tiempo sin respuesta final.';
179
+ } else if (result.stopReason === 'max_rounds') {
180
+ body = `✓ El agente alcanzó el máximo de rondas (${result.rounds}) sin respuesta final.${
181
+ toolCalls ? ` Hizo ${toolCalls} herramientas.` : ''
182
+ }`;
183
+ } else if (toolCalls) {
184
+ body = 'V El agente terminó sus herramientas, pero no devolvió un mensaje final.';
185
+ } else {
186
+ body = '(sin texto)';
187
+ }
188
+ }
189
+ const footer = completionFooter(result.stopReason, result.rounds, toolCalls, durationMs);
190
+ await edit(T, chatId, message.message_id, `${body}\n\n- ${footer}`);
191
+ T.ctx.store.addMessages(sessionId, newMessages);
192
+ T.ctx.store.touchSession(sessionId, { provider: providerId, model });
193
+ } catch (error) {
194
+ await edit(
195
+ T,
196
+ chatId,
197
+ message.message_id,
198
+ error?.name === 'AbortError' ? 'Cancelado.' : `Error: ${error?.message ?? error}`,
199
+ );
200
+ } finally {
201
+ T.controllers.delete(String(chatId));
202
+ T.ownSessions.delete(sessionId);
203
+ mcp?.close?.();
204
+ }
205
+ }
206
+