@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.
- package/CHANGELOG.md +30 -0
- package/README.md +51 -16
- package/harness/packages/cli/src/commands/doctor.js +14 -0
- package/harness/packages/cli/src/commands/update.js +91 -21
- package/harness/packages/core/src/agent/modes.js +14 -1
- package/harness/packages/core/src/agent/runAgent.js +23 -2
- package/harness/packages/core/src/browser/index.js +289 -0
- package/harness/packages/core/src/config/home.js +5 -0
- package/harness/packages/core/src/index.js +9 -2
- package/harness/packages/core/src/tools/executors.js +96 -0
- package/harness/packages/core/src/tools/labels.js +4 -0
- package/harness/packages/core/src/tools/permissions.js +4 -0
- package/harness/packages/core/src/tools/schema.js +47 -0
- package/harness/packages/core/src/version.js +81 -7
- package/harness/packages/core/test/agent.test.js +30 -1
- package/harness/packages/core/test/browser.test.js +18 -0
- package/harness/packages/core/test/tools.test.js +16 -1
- package/harness/packages/core/test/version.test.js +34 -0
- package/harness/packages/server/public/app.js +244 -8
- package/harness/packages/server/public/index.html +57 -3
- package/harness/packages/server/public/landing/index.html +14 -1
- package/harness/packages/server/public/markdown.js +54 -0
- package/harness/packages/server/public/reconcile.js +3 -1
- package/harness/packages/server/public/styles.css +48 -0
- package/harness/packages/server/src/lib/advisor.js +177 -0
- package/harness/packages/server/src/lib/agentRun.js +4 -3
- package/harness/packages/server/src/lib/channels/telegram.js +130 -25
- package/harness/packages/server/src/lib/ttsSettings.js +2 -2
- package/harness/packages/server/src/lib/update.js +82 -0
- package/harness/packages/server/src/routes/advisor.js +76 -0
- package/harness/packages/server/src/routes/chat.js +30 -0
- package/harness/packages/server/src/routes/files.js +25 -1
- package/harness/packages/server/src/routes/tts.js +1 -1
- package/harness/packages/server/src/routes/update.js +43 -0
- package/harness/packages/server/src/server.js +26 -2
- package/harness/packages/server/test/advisor.test.js +205 -0
- package/harness/packages/server/test/markdown.test.js +43 -1
- package/harness/packages/server/test/server.test.js +50 -14
- package/harness/packages/server/test/telegram-features.test.js +25 -4
- package/harness/packages/server/test/update.test.js +94 -0
- package/harness/scripts/self-update.mjs +123 -0
- 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
|
-
|
|
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 {
|
|
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
|
-
|
|
356
|
-
await send(chatId,
|
|
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
|
-
|
|
660
|
-
|
|
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
|
-
//
|
|
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,
|
|
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
|
+
}
|