@danieltmn/tamnora 0.21.2 → 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 (77) hide show
  1. package/CHANGELOG.md +28 -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/agent/runAgent.js +9 -5
  12. package/harness/packages/core/src/browser/index.js +27 -13
  13. package/harness/packages/core/src/index.js +1 -0
  14. package/harness/packages/core/src/mcp/stdio.js +15 -0
  15. package/harness/packages/core/src/net/hosts.js +70 -0
  16. package/harness/packages/core/src/redact.js +2 -0
  17. package/harness/packages/core/src/tools/executors.js +19 -24
  18. package/harness/packages/core/src/tools/index.js +1 -1
  19. package/harness/packages/core/src/tools/paths.js +35 -5
  20. package/harness/packages/core/src/tools/permissions.js +41 -2
  21. package/harness/packages/core/src/tools/registry.js +8 -3
  22. package/harness/packages/core/src/tts/edge.js +34 -4
  23. package/harness/packages/core/src/tunnel.js +15 -5
  24. package/harness/packages/core/test/agent.test.js +18 -0
  25. package/harness/packages/core/test/servicios-vivos.test.js +71 -0
  26. package/harness/packages/core/test/tools.test.js +44 -1
  27. package/harness/packages/core/test/tts.test.js +81 -0
  28. package/harness/packages/server/bin/tamnora-server.mjs +7 -1
  29. package/harness/packages/server/public/app.js +347 -6757
  30. package/harness/packages/server/public/cards.js +470 -0
  31. package/harness/packages/server/public/chat.js +1343 -0
  32. package/harness/packages/server/public/chatFormat.js +156 -0
  33. package/harness/packages/server/public/chrome.js +813 -0
  34. package/harness/packages/server/public/commands.js +403 -0
  35. package/harness/packages/server/public/core.js +209 -0
  36. package/harness/packages/server/public/explorer.js +625 -0
  37. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  38. package/harness/packages/server/public/icons/logo_light.png +0 -0
  39. package/harness/packages/server/public/index.html +40 -41
  40. package/harness/packages/server/public/sessions.js +274 -0
  41. package/harness/packages/server/public/settings.js +1080 -0
  42. package/harness/packages/server/public/styles.css +45 -52
  43. package/harness/packages/server/public/system.js +495 -0
  44. package/harness/packages/server/public/voice.js +1058 -0
  45. package/harness/packages/server/src/auth.js +12 -10
  46. package/harness/packages/server/src/lib/agentRun.js +6 -1
  47. package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
  48. package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
  49. package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
  50. package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
  51. package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
  52. package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
  53. package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
  54. package/harness/packages/server/src/lib/channels/telegram.js +163 -932
  55. package/harness/packages/server/src/lib/presence.js +12 -1
  56. package/harness/packages/server/src/lib/scheduler.js +12 -2
  57. package/harness/packages/server/src/lib/tasks.js +21 -3
  58. package/harness/packages/server/src/lib/workspace.js +90 -2
  59. package/harness/packages/server/src/routes/auth.js +8 -4
  60. package/harness/packages/server/src/routes/chat.js +16 -5
  61. package/harness/packages/server/src/routes/preview.js +10 -2
  62. package/harness/packages/server/src/routes/providers.js +14 -9
  63. package/harness/packages/server/src/server.js +25 -0
  64. package/harness/packages/server/test/chat.test.js +586 -0
  65. package/harness/packages/server/test/chatFormat.test.js +119 -0
  66. package/harness/packages/server/test/consoleModules.test.js +301 -0
  67. package/harness/packages/server/test/consoleSmoke.test.js +34 -0
  68. package/harness/packages/server/test/helpers.js +121 -0
  69. package/harness/packages/server/test/ops.test.js +515 -0
  70. package/harness/packages/server/test/runs.test.js +816 -0
  71. package/harness/packages/server/test/security.test.js +112 -9
  72. package/harness/packages/server/test/sessions.test.js +437 -0
  73. package/harness/packages/server/test/settings.test.js +482 -0
  74. package/harness/scripts/canary.mjs +24 -0
  75. package/harness/tamnora.example.json +1 -1
  76. package/package.json +3 -2
  77. package/harness/packages/server/test/server.test.js +0 -2885
@@ -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
+
@@ -0,0 +1,57 @@
1
+ // Helpers de texto: lo que describe al usuario qué está pidiendo el agente y
2
+ // cómo se resume un turno. Puro, sin estado del canal.
3
+
4
+ function humanSize(bytes) {
5
+ const n = Number(bytes) || 0;
6
+ if (n < 1024) return `${n} B`;
7
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
8
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
9
+ }
10
+
11
+ function oneLine(value, max = 400) {
12
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
13
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
14
+ }
15
+
16
+ /**
17
+ * Explica en una frase qué va a hacer el agente si se aprueba el permiso, en
18
+ * vez de volcar el JSON crudo (que para `write` mostraba medio archivo).
19
+ */
20
+ export function describePermission(name, args = {}) {
21
+ const a = args && typeof args === 'object' ? args : {};
22
+ switch (name) {
23
+ case 'bash':
24
+ return { action: 'ejecutar un comando en el proyecto', detail: oneLine(a.command, 700) };
25
+ case 'write':
26
+ return {
27
+ action: `escribir el archivo ${a.path ?? '(sin ruta)'}`,
28
+ detail: a.content ? `Contenido: ${humanSize(Buffer.byteLength(String(a.content)))}` : '',
29
+ };
30
+ case 'edit':
31
+ return {
32
+ action: `editar el archivo ${a.path ?? '(sin ruta)'}`,
33
+ detail: a.oldString ? `Reemplaza: "${oneLine(a.oldString, 100)}"` : '',
34
+ };
35
+ case 'git_commit':
36
+ return { action: 'hacer un commit', detail: oneLine(a.message, 200) };
37
+ case 'screenshot':
38
+ return { action: 'sacar una captura de la página', detail: oneLine(a.url, 200) };
39
+ case 'console_errors':
40
+ return { action: 'revisar la consola de una página', detail: oneLine(a.url, 200) };
41
+ case 'fetch_url':
42
+ return { action: 'consultar una URL', detail: oneLine(a.url, 200) };
43
+ case 'read':
44
+ return { action: `leer ${oneLine(a.path, 120) || 'un archivo'}`, detail: '' };
45
+ default:
46
+ return { action: `usar la herramienta "${name}"`, detail: oneLine(JSON.stringify(a), 200) };
47
+ }
48
+ }
49
+
50
+ /** Linea de cierre del turno (rondas, herramientas, duración). */
51
+ export function completionFooter(stopReason, rounds, toolCalls, durationMs) {
52
+ const parts = [`${rounds ?? '?'} ronda${rounds === 1 ? '' : 's'}`];
53
+ if (toolCalls) parts.push(`${toolCalls} herramienta${toolCalls === 1 ? '' : 's'}`);
54
+ if (durationMs > 0) parts.push(durationMs >= 1000 ? `${(durationMs / 1000).toFixed(1)}s` : `${durationMs}ms`);
55
+ const maxed = ['max_rounds', 'no_progress', 'budget'].includes(stopReason);
56
+ return `${maxed ? '⚠ corte de seguridad' : '✓ terminado'} · ${parts.join(' · ')}`;
57
+ }