@danieltmn/tamnora 0.9.3 → 0.9.5

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 (42) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +51 -16
  3. package/harness/packages/cli/src/commands/doctor.js +14 -0
  4. package/harness/packages/cli/src/commands/update.js +91 -21
  5. package/harness/packages/core/src/agent/modes.js +14 -1
  6. package/harness/packages/core/src/agent/runAgent.js +23 -2
  7. package/harness/packages/core/src/browser/index.js +289 -0
  8. package/harness/packages/core/src/config/home.js +5 -0
  9. package/harness/packages/core/src/index.js +9 -2
  10. package/harness/packages/core/src/tools/executors.js +96 -0
  11. package/harness/packages/core/src/tools/labels.js +4 -0
  12. package/harness/packages/core/src/tools/permissions.js +4 -0
  13. package/harness/packages/core/src/tools/schema.js +47 -0
  14. package/harness/packages/core/src/version.js +81 -7
  15. package/harness/packages/core/test/agent.test.js +30 -1
  16. package/harness/packages/core/test/browser.test.js +18 -0
  17. package/harness/packages/core/test/tools.test.js +16 -1
  18. package/harness/packages/core/test/version.test.js +34 -0
  19. package/harness/packages/server/public/app.js +244 -8
  20. package/harness/packages/server/public/index.html +57 -3
  21. package/harness/packages/server/public/landing/index.html +14 -1
  22. package/harness/packages/server/public/markdown.js +54 -0
  23. package/harness/packages/server/public/reconcile.js +3 -1
  24. package/harness/packages/server/public/styles.css +48 -0
  25. package/harness/packages/server/src/lib/advisor.js +177 -0
  26. package/harness/packages/server/src/lib/agentRun.js +4 -3
  27. package/harness/packages/server/src/lib/channels/telegram.js +130 -25
  28. package/harness/packages/server/src/lib/ttsSettings.js +2 -2
  29. package/harness/packages/server/src/lib/update.js +82 -0
  30. package/harness/packages/server/src/routes/advisor.js +76 -0
  31. package/harness/packages/server/src/routes/chat.js +30 -0
  32. package/harness/packages/server/src/routes/files.js +25 -1
  33. package/harness/packages/server/src/routes/tts.js +1 -1
  34. package/harness/packages/server/src/routes/update.js +43 -0
  35. package/harness/packages/server/src/server.js +26 -2
  36. package/harness/packages/server/test/advisor.test.js +205 -0
  37. package/harness/packages/server/test/markdown.test.js +43 -1
  38. package/harness/packages/server/test/server.test.js +50 -14
  39. package/harness/packages/server/test/telegram-features.test.js +25 -4
  40. package/harness/packages/server/test/update.test.js +94 -0
  41. package/harness/scripts/self-update.mjs +123 -0
  42. package/package.json +1 -1
@@ -627,6 +627,17 @@ body.resizing * { cursor: col-resize !important; }
627
627
  font: 600 12px var(--sans);
628
628
  }
629
629
  .tool-card > .tool-audio-btn:hover { background: var(--theirs); }
630
+ .tool-card > .tool-shot {
631
+ display: block;
632
+ max-width: calc(100% - 28px);
633
+ max-height: 420px;
634
+ width: auto;
635
+ margin: 6px 0 4px 18px;
636
+ border: 1px solid var(--border);
637
+ border-radius: 8px;
638
+ cursor: zoom-in;
639
+ background: #fff;
640
+ }
630
641
 
631
642
  /* Reproductor único de audio (narración, speak, resumen): visible y detenible. */
632
643
  .audio-player {
@@ -1620,3 +1631,40 @@ dialog.modal::backdrop { background: rgba(24, 22, 16, 0.55); backdrop-filter: bl
1620
1631
  .modal-card { display: flex; flex-direction: column; gap: 12px; padding: 20px; }
1621
1632
  .modal-card h3 { margin: 0; font: 700 17px var(--serif); letter-spacing: -0.02em; }
1622
1633
  .row .spacer { flex: 1; }
1634
+
1635
+ /* update banner ----------------------------------------------------------- */
1636
+ .update-banner {
1637
+ display: flex;
1638
+ align-items: center;
1639
+ gap: 12px;
1640
+ padding: 8px 16px;
1641
+ background: var(--accent-soft);
1642
+ border-bottom: 1px solid var(--border);
1643
+ color: var(--text);
1644
+ font: 500 13px var(--sans, inherit);
1645
+ }
1646
+ .update-banner[hidden] { display: none; }
1647
+ .update-banner-icon { color: var(--accent); flex: 0 0 auto; }
1648
+ .update-banner-text { flex: 1; min-width: 0; }
1649
+ .update-banner-actions { display: flex; gap: 8px; flex: 0 0 auto; }
1650
+ .update-banner .btn { padding: 4px 12px; font-size: 12px; }
1651
+ .brand .ver.has-update { color: var(--accent); cursor: help; }
1652
+ .brand .ver.has-update::after { content: " ⬆"; }
1653
+
1654
+ /* advisor card ------------------------------------------------------------ */
1655
+ .advisor-card {
1656
+ margin: 10px 0;
1657
+ padding: 10px 14px;
1658
+ border-left: 3px solid var(--accent);
1659
+ border-radius: 8px;
1660
+ background: var(--accent-soft);
1661
+ }
1662
+ .advisor-head { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
1663
+ .advisor-glyph { color: var(--accent); font-size: 13px; line-height: 1; }
1664
+ .advisor-title { font: 600 10px var(--mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--muted); }
1665
+ .advisor-text { color: var(--text); line-height: 1.5; }
1666
+ .advisor-code { border-left-color: var(--warn); }
1667
+ .advisor-code .advisor-glyph { color: var(--warn); }
1668
+
1669
+ .brand .ver.ver-action { cursor: pointer; }
1670
+ .brand .ver.ver-action:hover { color: var(--accent); }
@@ -0,0 +1,177 @@
1
+ // Asesor tipo Jarvis: una capa de comentario sobre el run activo. Mira lo que
2
+ // el agente hizo (pedido, respuesta, herramientas y diffs), lo interpreta con
3
+ // una llamada corta al modelo y decide si vale la pena decir algo. NO lee la
4
+ // respuesta del agente: solo agrega alertas de codigo, explicaciones o consejos.
5
+ //
6
+ // La salida es JSON estricto para poder decidir el silencio: si el modelo no
7
+ // tiene nada que aportar, no habla ni deja tarjeta.
8
+
9
+ import { summarizeToolCall, extractJson } from './core.js';
10
+
11
+ export const DEFAULT_ADVISOR = {
12
+ enabled: false,
13
+ code: true,
14
+ explain: true,
15
+ advice: true,
16
+ notes: '',
17
+ maxWords: 40,
18
+ };
19
+
20
+ const KINDS = new Set(['code', 'explain', 'advice']);
21
+ const MAX_PROMPT_CHARS = 8000;
22
+ const MAX_DIFF_CHARS = 2500;
23
+
24
+ /** Config efectiva: lo guardado en app.json (la casa) sobre los defaults. */
25
+ export function resolveAdvisor(app) {
26
+ const saved = app?.advisor && typeof app.advisor === 'object' ? app.advisor : {};
27
+ const maxWords = Number(saved.maxWords);
28
+ return {
29
+ enabled: saved.enabled === true,
30
+ code: saved.code !== false,
31
+ explain: saved.explain !== false,
32
+ advice: saved.advice !== false,
33
+ notes: typeof saved.notes === 'string' ? saved.notes.slice(0, 2000) : '',
34
+ maxWords: Number.isFinite(maxWords)
35
+ ? Math.min(Math.max(Math.round(maxWords), 10), 120)
36
+ : DEFAULT_ADVISOR.maxWords,
37
+ };
38
+ }
39
+
40
+ /** Gate barato: sin herramientas ni una respuesta larga no hay nada que mirar. */
41
+ export function advisorShouldRun({ toolCalls = 0, answer = '' } = {}) {
42
+ return Number(toolCalls) > 0 || String(answer ?? '').length > 200;
43
+ }
44
+
45
+ function parseArgs(call) {
46
+ try {
47
+ return JSON.parse(call?.function?.arguments ?? call?.arguments ?? '{}') ?? {};
48
+ } catch {
49
+ return {};
50
+ }
51
+ }
52
+
53
+ /** Tool-calls de los mensajes del turno como `nombre: resumen` (deduplicado, acotado). */
54
+ export function collectToolSummary(messages = [], limit = 40) {
55
+ const out = [];
56
+ const seen = new Set();
57
+ for (const message of messages) {
58
+ if (message.role !== 'assistant') continue;
59
+ for (const call of message.tool_calls ?? []) {
60
+ const name = call.function?.name ?? call.name ?? '';
61
+ if (!name) continue;
62
+ const line = `${name}: ${summarizeToolCall(name, parseArgs(call))}`;
63
+ if (seen.has(line)) continue;
64
+ seen.add(line);
65
+ out.push(line);
66
+ if (out.length >= limit) return out;
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ /** Archivos tocados por write/edit con su diff, tal como lo devolvio la tool. */
73
+ export function collectChangedFiles(messages = []) {
74
+ const calls = new Map();
75
+ for (const message of messages) {
76
+ if (message.role !== 'assistant') continue;
77
+ for (const call of message.tool_calls ?? []) {
78
+ const name = call.function?.name ?? call.name;
79
+ if (name !== 'write' && name !== 'edit') continue;
80
+ const id = call.id ?? call.tool_call_id;
81
+ if (id) calls.set(id, { name, path: parseArgs(call).path ?? '' });
82
+ }
83
+ }
84
+ const files = [];
85
+ for (const message of messages) {
86
+ if (message.role !== 'tool') continue;
87
+ const call = calls.get(message.tool_call_id);
88
+ if (!call) continue;
89
+ const content = typeof message.content === 'string' ? message.content : '';
90
+ files.push({ path: call.path || 'archivo', diff: content.slice(0, MAX_DIFF_CHARS) });
91
+ }
92
+ return files;
93
+ }
94
+
95
+ function focusLines(options) {
96
+ const lines = [];
97
+ if (options.code) lines.push('- alertar de un problema real en el codigo (bug, riesgo, secreto expuesto, inconsistencia)');
98
+ if (options.explain) lines.push('- explicar un proceso o decision que quedo poco clara');
99
+ if (options.advice) lines.push('- dar un consejo concreto y accionable');
100
+ if (!lines.length) lines.push('- aportar algo util que el agente no haya dicho');
101
+ return lines.join('\n');
102
+ }
103
+
104
+ /** System + user para la consulta al asesor (expuestos para test). */
105
+ export function buildAdvisorPrompt({ userInput = '', answer = '', toolSummary = [], changedFiles = [], options = {} } = {}) {
106
+ const maxWords = options.maxWords ?? DEFAULT_ADVISOR.maxWords;
107
+ const system =
108
+ 'Sos el asesor personal del usuario dentro de Tamnora, al estilo de Jarvis.\n' +
109
+ 'Recibis lo que el agente hizo en el ultimo turno: el pedido del usuario, su respuesta\n' +
110
+ 'final, las herramientas que uso y los diffs de los archivos que toco.\n\n' +
111
+ 'Tu trabajo NO es resumir ni repetir la respuesta del agente: el usuario ya la esta\n' +
112
+ 'viendo. Solo aportas algo que el agente no dijo. Enfocate en:\n' +
113
+ `${focusLines(options)}\n\n` +
114
+ 'Hablale al usuario en segunda persona, breve (' +
115
+ `${maxWords} palabras como maximo), en espanol, sin markdown ni listas. Si no hay nada\n` +
116
+ 'que valga la pena decir, no hables.\n' +
117
+ (options.notes ? `\nInstrucciones del usuario: ${options.notes}\n` : '') +
118
+ '\nResponde SOLO con JSON: {"speak": true|false, "kind": "code|explain|advice", "text": "..."}';
119
+
120
+ const parts = [`PEDIDO DEL USUARIO:\n${userInput || '(sin texto)'}`];
121
+ parts.push(`RESPUESTA FINAL DEL AGENTE:\n${answer || '(sin respuesta)'}`);
122
+ if (toolSummary.length) parts.push(`HERRAMIENTAS USADAS:\n${toolSummary.map((t) => `- ${t}`).join('\n')}`);
123
+ if (changedFiles.length) {
124
+ const files = changedFiles.map((f) => `### ${f.path}\n${f.diff}`).join('\n\n');
125
+ parts.push(`ARCHIVOS MODIFICADOS (diffs):\n${files}`);
126
+ }
127
+
128
+ return { system, user: parts.join('\n\n').slice(0, MAX_PROMPT_CHARS) };
129
+ }
130
+
131
+ function truncateWords(text, maxWords) {
132
+ const words = String(text ?? '').replace(/\s+/g, ' ').trim().split(' ').filter(Boolean);
133
+ if (words.length <= maxWords) return words.join(' ');
134
+ return `${words.slice(0, maxWords).join(' ')}…`;
135
+ }
136
+
137
+ /**
138
+ * Consulta unica al modelo (mismo patron que el juez de eval). Devuelve
139
+ * `{ speak, kind, text }` o `null` si no se pudo interpretar. Nunca tira: un
140
+ * asesor roto no debe romper el run.
141
+ */
142
+ export async function consultAdvisor({
143
+ provider,
144
+ config,
145
+ model,
146
+ signal,
147
+ userInput,
148
+ answer,
149
+ messages = [],
150
+ options = {},
151
+ } = {}) {
152
+ const todo = collectToolSummary(messages);
153
+ const changedFiles = collectChangedFiles(messages);
154
+ const { system, user } = buildAdvisorPrompt({ userInput, answer, toolSummary: todo, changedFiles, options });
155
+ const maxWords = options.maxWords ?? DEFAULT_ADVISOR.maxWords;
156
+
157
+ try {
158
+ const result = await provider.streamChat(config, {
159
+ model,
160
+ messages: [
161
+ { role: 'system', content: system },
162
+ { role: 'user', content: user },
163
+ ],
164
+ tools: [],
165
+ signal,
166
+ onEvent: () => {},
167
+ });
168
+ const { value, error } = extractJson(result?.content ?? '');
169
+ if (error || !value || typeof value !== 'object') return null;
170
+ const kind = KINDS.has(value.kind) ? value.kind : 'advice';
171
+ const text = truncateWords(value.text, maxWords);
172
+ if (value.speak !== true || !text) return { speak: false, kind, text: '' };
173
+ return { speak: true, kind, text };
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
@@ -6,10 +6,10 @@ import { summarizeToolCall, previewToolResult } from './core.js';
6
6
 
7
7
  const FAILED_RE = /^Error|^Permiso denegado/i;
8
8
 
9
- /** Payload estandar de un tool_result para eventos SSE: preview + failed. */
9
+ /** Payload estandar de un tool_result para eventos SSE: preview + failed (+ meta). */
10
10
  export function toolResultPayload(event) {
11
11
  const { preview, truncated } = previewToolResult(event.name, event.result);
12
- return { preview, truncated, failed: FAILED_RE.test(String(event.result)) };
12
+ return { preview, truncated, failed: FAILED_RE.test(String(event.result)), meta: event.meta };
13
13
  }
14
14
 
15
15
  export function toolCallPayload(event) {
@@ -71,7 +71,8 @@ export async function composeAgentMessages({ ctx, cwd, mode, history = [], userM
71
71
  { role: 'system', content: mode.systemPrompt ? `${basePrompt}\n\n${mode.systemPrompt}` : basePrompt },
72
72
  ...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
73
73
  ...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
74
- ...history,
74
+ // El asesor no contamina el contexto del modelo: es una capa de comentario.
75
+ ...history.filter((message) => message.name !== 'advisor'),
75
76
  userMessage,
76
77
  ];
77
78
  }
@@ -4,8 +4,18 @@
4
4
  // proyecto (una carpeta de la raiz configurada en la consola).
5
5
 
6
6
  import path from 'node:path';
7
+ import { readFile } from 'node:fs/promises';
7
8
  import { randomUUID } from 'node:crypto';
8
- import { runAgent, createTools, resolveMode, listProviderModels, PROVIDER_META, sintetizar, MAX_TEXTO } from '../core.js';
9
+ import {
10
+ runAgent,
11
+ createTools,
12
+ resolveMode,
13
+ listProviderModels,
14
+ PROVIDER_META,
15
+ sintetizar,
16
+ MAX_TEXTO,
17
+ screenshotsDir,
18
+ } from '../core.js';
9
19
  import { resolveExistingDir, listSubdirs } from '../workspace.js';
10
20
  import {
11
21
  combineToolset,
@@ -42,6 +52,72 @@ const HELP = [
42
52
  'Podés mandar texto, imágenes o notas de voz.',
43
53
  ].join('\n');
44
54
 
55
+ function humanSize(bytes) {
56
+ const n = Number(bytes) || 0;
57
+ if (n < 1024) return `${n} B`;
58
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
59
+ return `${(n / 1024 / 1024).toFixed(1)} MB`;
60
+ }
61
+
62
+ function oneLine(value, max = 400) {
63
+ const text = String(value ?? '').replace(/\s+/g, ' ').trim();
64
+ return text.length > max ? `${text.slice(0, max - 1).trimEnd()}…` : text;
65
+ }
66
+
67
+ /**
68
+ * Explica en una frase qué va a hacer el agente si se aprueba el permiso, en
69
+ * vez de volcar el JSON crudo (que para `write` mostraba medio archivo).
70
+ */
71
+ export function describePermission(name, args = {}) {
72
+ const a = args && typeof args === 'object' ? args : {};
73
+ switch (name) {
74
+ case 'bash':
75
+ return { action: 'ejecutar un comando en el proyecto', detail: oneLine(a.command, 700) };
76
+ case 'write':
77
+ return {
78
+ action: `escribir el archivo ${a.path ?? '(sin ruta)'}`,
79
+ detail: a.content ? `Contenido: ${humanSize(Buffer.byteLength(String(a.content)))}` : '',
80
+ };
81
+ case 'edit':
82
+ return {
83
+ action: `editar el archivo ${a.path ?? '(sin ruta)'}`,
84
+ detail: a.oldString ? `Reemplaza: "${oneLine(a.oldString, 100)}"` : '',
85
+ };
86
+ case 'git_commit':
87
+ return { action: 'hacer un commit', detail: oneLine(a.message, 200) };
88
+ case 'screenshot':
89
+ return { action: 'sacar una captura de la página', detail: oneLine(a.url, 200) };
90
+ case 'console_errors':
91
+ return { action: 'revisar la consola de una página', detail: oneLine(a.url, 200) };
92
+ case 'fetch_url':
93
+ return { action: 'consultar una URL', detail: oneLine(a.url, 200) };
94
+ case 'read':
95
+ return { action: `leer ${oneLine(a.path, 120) || 'un archivo'}`, detail: '' };
96
+ default:
97
+ return { action: `usar la herramienta "${name}"`, detail: oneLine(JSON.stringify(a), 200) };
98
+ }
99
+ }
100
+
101
+ function permissionMessage(project, name, args) {
102
+ const { action, detail } = describePermission(name, args);
103
+ const lines = [`⚠ [${project}] Permiso: el agente quiere ${action}.`];
104
+ if (detail) lines.push('', detail);
105
+ lines.push('', '¿Qué hacemos?');
106
+ return lines.join('\n');
107
+ }
108
+
109
+ function permissionKeyboard(askId) {
110
+ return {
111
+ inline_keyboard: [
112
+ [
113
+ { text: 'Permitir', callback_data: `perm:${askId}:allow` },
114
+ { text: 'Permitir todo', callback_data: `perm:${askId}:all` },
115
+ ],
116
+ [{ text: 'Denegar', callback_data: `perm:${askId}:deny` }],
117
+ ],
118
+ };
119
+ }
120
+
45
121
  async function tg(token, method, payload) {
46
122
  const res = await fetch(`${API}/bot${token}/${method}`, {
47
123
  method: 'POST',
@@ -144,6 +220,7 @@ export function startTelegramChannel(
144
220
  const modelChoices = new Map();
145
221
  const projectChoices = new Map();
146
222
  const pendingInputs = new Map();
223
+ const askSessions = new Map(); // askId -> sessionId, para "Permitir todo"
147
224
  let running = true;
148
225
  let offset = 0;
149
226
 
@@ -194,6 +271,32 @@ export function startTelegramChannel(
194
271
  }
195
272
  }
196
273
 
274
+ /** Manda una imagen (una captura del tool `screenshot`). */
275
+ async function enviarFoto(chatId, buffer, caption) {
276
+ try {
277
+ const form = new FormData();
278
+ form.append('chat_id', chatId);
279
+ if (caption) form.append('caption', String(caption).slice(0, 1000));
280
+ form.append('photo', new Blob([buffer], { type: 'image/png' }), 'captura.png');
281
+ await tgForm(token, 'sendPhoto', form);
282
+ } catch (error) {
283
+ log(`telegram: foto falló: ${error?.message ?? error}`);
284
+ }
285
+ }
286
+
287
+ /** Entrega una captura guardada en la casa (por nombre, sin path traversal). */
288
+ async function enviarCaptura(chatId, name, caption) {
289
+ const safe = String(name ?? '');
290
+ if (!/^[a-z0-9][a-z0-9-]*\.png$/i.test(safe)) return;
291
+ let buffer;
292
+ try {
293
+ buffer = await readFile(path.join(screenshotsDir(), safe));
294
+ } catch {
295
+ return;
296
+ }
297
+ await enviarFoto(chatId, buffer, caption);
298
+ }
299
+
197
300
  // --- proyectos ------------------------------------------------------------
198
301
 
199
302
  /** Carpeta raíz para elegir proyecto: la predeterminada de la consola o el cwd. */
@@ -351,19 +454,15 @@ export function startTelegramChannel(
351
454
  }
352
455
 
353
456
  const onAsk = async (name, args) => {
457
+ // "Permitir todo" activa el auto-approve de la sesion (compartido con la
458
+ // consola): a partir de ahi no se vuelve a preguntar.
459
+ if (ctx.isAutoApprove?.(sessionId)) return true;
354
460
  const askId = randomUUID();
355
- const preview = JSON.stringify(args ?? {}).slice(0, 300);
356
- await send(chatId, `⚠ [${projectLabel(session)}] Permiso para ${name}\n${preview}`, {
357
- reply_markup: {
358
- inline_keyboard: [
359
- [
360
- { text: 'Permitir', callback_data: `perm:${askId}:allow` },
361
- { text: 'Denegar', callback_data: `perm:${askId}:deny` },
362
- ],
363
- ],
364
- },
461
+ askSessions.set(askId, sessionId);
462
+ await send(chatId, permissionMessage(projectLabel(session), name, args), {
463
+ reply_markup: permissionKeyboard(askId),
365
464
  });
366
- return ctx.approvals.ask(askId);
465
+ return ctx.approvals.ask(askId, { sessionId, name, args });
367
466
  };
368
467
 
369
468
  const local = createTools({
@@ -419,6 +518,9 @@ export function startTelegramChannel(
419
518
  }
420
519
  } else if (event.type === 'tool_result') {
421
520
  activity = `${event.name} ✓`;
521
+ if (event.name === 'screenshot' && event.meta?.screenshot?.name) {
522
+ enviarCaptura(chatId, event.meta.screenshot.name, event.meta.screenshot.url);
523
+ }
422
524
  } else {
423
525
  return;
424
526
  }
@@ -656,8 +758,12 @@ export function startTelegramChannel(
656
758
  let toast = '';
657
759
 
658
760
  if (kind === 'perm') {
659
- ctx.approvals.decide(a, b === 'allow');
660
- toast = b === 'allow' ? 'Permitido' : 'Denegado';
761
+ const all = b === 'all';
762
+ ctx.approvals.decide(a, b === 'allow' || all);
763
+ const sessionId = askSessions.get(a);
764
+ if (sessionId) askSessions.delete(a);
765
+ if (all && sessionId) ctx.setAutoApprove?.(sessionId, true);
766
+ toast = all ? 'Permitidos todos' : b === 'allow' ? 'Permitido' : 'Denegado';
661
767
  } else if (kind === 'proj') {
662
768
  if (a === 'root') {
663
769
  await selectProject(chatId, projectRoot());
@@ -755,7 +861,7 @@ export function startTelegramChannel(
755
861
  notify(sessionId, event) {
756
862
  if (ownSessions.has(sessionId)) return;
757
863
 
758
- // La tool `speak` entrega audio en vivo a un chat vinculado.
864
+ // Las tools `speak` y `screenshot` entregan en vivo a un chat vinculado.
759
865
  if (event.type === 'tool_call') {
760
866
  if (event.data?.name !== 'speak') return;
761
867
  const linked = ctx.store.getSetting(reverseKey(sessionId));
@@ -767,6 +873,12 @@ export function startTelegramChannel(
767
873
  }
768
874
  return;
769
875
  }
876
+ if (event.type === 'tool_result' && event.data?.name === 'screenshot') {
877
+ const linked = ctx.store.getSetting(reverseKey(sessionId));
878
+ const shot = event.data?.meta?.screenshot;
879
+ if (linked && shot?.name) enviarCaptura(linked, shot.name, shot.url);
880
+ return;
881
+ }
770
882
 
771
883
  const targets = notifyTargets(sessionId);
772
884
  if (!targets.length) return;
@@ -785,17 +897,10 @@ export function startTelegramChannel(
785
897
  } else if (event.type === 'permission' && notifyConfig.permission) {
786
898
  const askId = event.data?.askId;
787
899
  if (!askId) return;
900
+ if (event.data?.sessionId) askSessions.set(askId, event.data.sessionId);
901
+ const text = permissionMessage(project, event.data?.name ?? 'una tool', event.data?.args);
788
902
  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
- ],
796
- ],
797
- },
798
- }).catch(() => {});
903
+ send(chatId, text, { reply_markup: permissionKeyboard(askId) }).catch(() => {});
799
904
  }
800
905
  }
801
906
  },
@@ -8,14 +8,14 @@ export const DEFAULT_TTS = {
8
8
  enabled: false,
9
9
  voice: VOZ_POR_DEFECTO,
10
10
  rate: '+0%',
11
- announce: { start: true, tools: true, end: true },
11
+ announce: { start: true, tools: true, end: true, advice: true, answer: false },
12
12
  };
13
13
 
14
14
  /** Merge de defaults + lo guardado (con `announce` aplanado a booleans). */
15
15
  export function resolveTts(app) {
16
16
  const saved = app?.tts && typeof app.tts === 'object' ? app.tts : {};
17
17
  const announce = { ...DEFAULT_TTS.announce, ...(saved.announce ?? {}) };
18
- for (const key of ['start', 'tools', 'end']) announce[key] = announce[key] === true;
18
+ for (const key of ['start', 'tools', 'end', 'advice', 'answer']) announce[key] = announce[key] === true;
19
19
  return {
20
20
  ...DEFAULT_TTS,
21
21
  ...saved,
@@ -0,0 +1,82 @@
1
+ // Chequeo de version nueva contra el registro npm. Cacheado a proposito: el
2
+ // banner de la consola pregunta seguido y no queremos pegarle a npm en cada
3
+ // request. Un fallo de red no rompe: se devuelve `updateAvailable:false`.
4
+
5
+ import { spawn } from 'node:child_process';
6
+ import path from 'node:path';
7
+ import {
8
+ ROOT_PACKAGE_NAME,
9
+ readPackageVersion,
10
+ compareVersions,
11
+ fetchLatestVersion,
12
+ packageRoot,
13
+ homeDir,
14
+ } from './core.js';
15
+
16
+ const CACHE_MS = 10 * 60 * 1000;
17
+
18
+ let cache = { at: 0, latest: '' };
19
+
20
+ /** Limpia la cache (tests). */
21
+ export function clearUpdateCache() {
22
+ cache = { at: 0, latest: '' };
23
+ }
24
+
25
+ /**
26
+ * @returns {{current:string, latest:string, updateAvailable:boolean, checkedAt:string|null, error?:string}}
27
+ */
28
+ export async function checkUpdate({ force = false, current = readPackageVersion(), fetchImpl } = {}) {
29
+ const now = Date.now();
30
+ const fresh = cache.latest && now - cache.at < CACHE_MS;
31
+ if (!force && fresh) {
32
+ return {
33
+ current,
34
+ latest: cache.latest,
35
+ updateAvailable: compareVersions(cache.latest, current) > 0,
36
+ checkedAt: new Date(cache.at).toISOString(),
37
+ };
38
+ }
39
+
40
+ try {
41
+ const latest = await fetchLatestVersion({ pkg: ROOT_PACKAGE_NAME, ...(fetchImpl ? { fetchImpl } : {}) });
42
+ cache = { at: now, latest };
43
+ return {
44
+ current,
45
+ latest,
46
+ updateAvailable: compareVersions(latest, current) > 0,
47
+ checkedAt: new Date(now).toISOString(),
48
+ };
49
+ } catch (error) {
50
+ return {
51
+ current,
52
+ latest: '',
53
+ updateAvailable: false,
54
+ checkedAt: null,
55
+ error: String(error?.message ?? error),
56
+ };
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Lanza el script detached que espera la muerte de este server, instala la
62
+ * version nueva y lo vuelve a levantar. Devolver rapido es el punto: el server
63
+ * tiene que soltar los archivos (Windows) antes de que npm los reemplace.
64
+ */
65
+ export function spawnSelfUpdate({ pid = process.pid, port, host, cwd, target, home } = {}) {
66
+ const root = packageRoot();
67
+ if (!root) return { ok: false, error: 'no encontre el paquete del harness' };
68
+ const script = path.join(root, 'harness', 'scripts', 'self-update.mjs');
69
+ const args = [script, '--pid', String(pid)];
70
+ if (port) args.push('--port', String(port));
71
+ if (host) args.push('--host', String(host));
72
+ if (cwd) args.push('--cwd', String(cwd));
73
+ if (target) args.push('--target', String(target));
74
+ args.push('--home', home || homeDir());
75
+ try {
76
+ const child = spawn(process.execPath, args, { detached: true, stdio: 'ignore', windowsHide: true });
77
+ child.unref();
78
+ return { ok: true, pid: child.pid };
79
+ } catch (error) {
80
+ return { ok: false, error: String(error?.message ?? error) };
81
+ }
82
+ }
@@ -0,0 +1,76 @@
1
+ // Ajustes del asesor (app.json -> advisor) y una prueba manual que corre una
2
+ // consulta real para ver que devuelve el modelo.
3
+
4
+ import { resolveAdvisor, consultAdvisor } from '../lib/advisor.js';
5
+ import { readApp, writeApp } from '../lib/core.js';
6
+ import { readJson, sendJson, sendError } from '../lib/http.js';
7
+
8
+ export function registerAdvisor(route, ctx) {
9
+ route('GET', '/api/advisor/settings', ({ res }) => {
10
+ sendJson(res, 200, resolveAdvisor(ctx.app));
11
+ });
12
+
13
+ route('PUT', '/api/advisor/settings', async ({ req, res }) => {
14
+ const body = await readJson(req);
15
+ const app = readApp();
16
+ const current = resolveAdvisor(ctx.app);
17
+ const next = { ...current };
18
+ if ('enabled' in body) next.enabled = body.enabled === true;
19
+ if ('code' in body) next.code = body.code !== false;
20
+ if ('explain' in body) next.explain = body.explain !== false;
21
+ if ('advice' in body) next.advice = body.advice !== false;
22
+ if ('notes' in body) next.notes = String(body.notes ?? '').slice(0, 2000);
23
+ if ('maxWords' in body) {
24
+ const value = Number(body.maxWords);
25
+ next.maxWords = Number.isFinite(value)
26
+ ? Math.min(Math.max(Math.round(value), 10), 120)
27
+ : current.maxWords;
28
+ }
29
+ app.advisor = next;
30
+ writeApp(app);
31
+ ctx.app = app;
32
+ sendJson(res, 200, resolveAdvisor(app));
33
+ });
34
+
35
+ route('POST', '/api/advisor/test', async ({ res }) => {
36
+ const options = resolveAdvisor(ctx.app);
37
+ const providerId = ctx.effectiveProviderId();
38
+ const model = ctx.effectiveModel();
39
+ if (!model) return sendError(res, 400, 'Elegí un proveedor y un modelo primero.');
40
+ const { provider, config } = ctx.resolveProvider({
41
+ ...ctx.providerSettings(providerId),
42
+ apiKey: ctx.apiKeyFor(providerId),
43
+ });
44
+ // Turno de ejemplo con una edicion: alcanza para ver si el asesor aporta algo.
45
+ const messages = [
46
+ {
47
+ role: 'assistant',
48
+ content: '',
49
+ tool_calls: [
50
+ {
51
+ id: 'test-1',
52
+ type: 'function',
53
+ function: { name: 'edit', arguments: JSON.stringify({ path: 'src/auth.js' }) },
54
+ },
55
+ ],
56
+ },
57
+ {
58
+ role: 'tool',
59
+ tool_call_id: 'test-1',
60
+ name: 'edit',
61
+ content: 'Editado src/auth.js\n- const token = "secreto123"\n+ const token = process.env.TOKEN',
62
+ },
63
+ ];
64
+ const advice = await consultAdvisor({
65
+ provider,
66
+ config,
67
+ model,
68
+ userInput: 'Revisá el manejo del token de sesión.',
69
+ answer: 'Listo, ajusté el archivo.',
70
+ messages,
71
+ options,
72
+ });
73
+ if (!advice) return sendError(res, 502, 'El asesor no devolvió una respuesta interpretable.');
74
+ sendJson(res, 200, advice);
75
+ });
76
+ }