@danieltmn/tamnora 0.9.2 → 0.9.4
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 +13 -0
- package/README.md +30 -10
- package/harness/packages/cli/src/tui/app.js +22 -3
- package/harness/packages/cli/src/tui/layout.js +59 -2
- package/harness/packages/cli/test/tui.test.js +30 -0
- 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 +3 -2
- package/harness/packages/core/src/providers/presets.js +4 -0
- package/harness/packages/core/src/tools/diff.js +70 -1
- 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/test/agent.test.js +30 -1
- package/harness/packages/core/test/browser.test.js +18 -0
- package/harness/packages/core/test/diff.test.js +24 -1
- package/harness/packages/core/test/tools.test.js +16 -1
- package/harness/packages/server/public/app.js +203 -38
- package/harness/packages/server/public/index.html +2 -1
- package/harness/packages/server/public/landing/index.html +14 -1
- package/harness/packages/server/public/reconcile.js +3 -1
- package/harness/packages/server/public/styles.css +98 -12
- package/harness/packages/server/src/lib/agentRun.js +2 -2
- package/harness/packages/server/src/lib/channels/telegram.js +129 -44
- package/harness/packages/server/src/routes/files.js +25 -1
- package/harness/packages/server/src/routes/sessions.js +55 -33
- package/harness/packages/server/test/server.test.js +84 -14
- package/harness/packages/server/test/telegram-features.test.js +25 -4
- package/harness/packages/server/test/telegram-voz.test.js +3 -5
- package/package.json +1 -1
|
@@ -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,
|
|
@@ -14,7 +24,6 @@ import {
|
|
|
14
24
|
resolveProviderFor,
|
|
15
25
|
toolCallPayload,
|
|
16
26
|
} from '../agentRun.js';
|
|
17
|
-
import { fraseFin } from '../announcer.js';
|
|
18
27
|
import { mp3AOgg } from '../ffmpeg.js';
|
|
19
28
|
import { resolveTts } from '../ttsSettings.js';
|
|
20
29
|
import { transcribeAudio } from '../transcribe.js';
|
|
@@ -43,6 +52,72 @@ const HELP = [
|
|
|
43
52
|
'Podés mandar texto, imágenes o notas de voz.',
|
|
44
53
|
].join('\n');
|
|
45
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
|
+
|
|
46
121
|
async function tg(token, method, payload) {
|
|
47
122
|
const res = await fetch(`${API}/bot${token}/${method}`, {
|
|
48
123
|
method: 'POST',
|
|
@@ -145,6 +220,7 @@ export function startTelegramChannel(
|
|
|
145
220
|
const modelChoices = new Map();
|
|
146
221
|
const projectChoices = new Map();
|
|
147
222
|
const pendingInputs = new Map();
|
|
223
|
+
const askSessions = new Map(); // askId -> sessionId, para "Permitir todo"
|
|
148
224
|
let running = true;
|
|
149
225
|
let offset = 0;
|
|
150
226
|
|
|
@@ -195,11 +271,30 @@ export function startTelegramChannel(
|
|
|
195
271
|
}
|
|
196
272
|
}
|
|
197
273
|
|
|
198
|
-
/**
|
|
199
|
-
async function
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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);
|
|
203
298
|
}
|
|
204
299
|
|
|
205
300
|
// --- proyectos ------------------------------------------------------------
|
|
@@ -359,19 +454,15 @@ export function startTelegramChannel(
|
|
|
359
454
|
}
|
|
360
455
|
|
|
361
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;
|
|
362
460
|
const askId = randomUUID();
|
|
363
|
-
|
|
364
|
-
await send(chatId,
|
|
365
|
-
reply_markup:
|
|
366
|
-
inline_keyboard: [
|
|
367
|
-
[
|
|
368
|
-
{ text: 'Permitir', callback_data: `perm:${askId}:allow` },
|
|
369
|
-
{ text: 'Denegar', callback_data: `perm:${askId}:deny` },
|
|
370
|
-
],
|
|
371
|
-
],
|
|
372
|
-
},
|
|
461
|
+
askSessions.set(askId, sessionId);
|
|
462
|
+
await send(chatId, permissionMessage(projectLabel(session), name, args), {
|
|
463
|
+
reply_markup: permissionKeyboard(askId),
|
|
373
464
|
});
|
|
374
|
-
return ctx.approvals.ask(askId);
|
|
465
|
+
return ctx.approvals.ask(askId, { sessionId, name, args });
|
|
375
466
|
};
|
|
376
467
|
|
|
377
468
|
const local = createTools({
|
|
@@ -427,6 +518,9 @@ export function startTelegramChannel(
|
|
|
427
518
|
}
|
|
428
519
|
} else if (event.type === 'tool_result') {
|
|
429
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
|
+
}
|
|
430
524
|
} else {
|
|
431
525
|
return;
|
|
432
526
|
}
|
|
@@ -458,7 +552,6 @@ export function startTelegramChannel(
|
|
|
458
552
|
}
|
|
459
553
|
const footer = completionFooter(result.stopReason, result.rounds, toolCalls, durationMs);
|
|
460
554
|
await edit(chatId, message.message_id, `${body}\n\n— ${footer}`);
|
|
461
|
-
await sendResumenDeVoz(chatId, fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }));
|
|
462
555
|
ctx.store.addMessages(sessionId, newMessages);
|
|
463
556
|
ctx.store.touchSession(sessionId, { provider: providerId, model });
|
|
464
557
|
} catch (error) {
|
|
@@ -665,8 +758,12 @@ export function startTelegramChannel(
|
|
|
665
758
|
let toast = '';
|
|
666
759
|
|
|
667
760
|
if (kind === 'perm') {
|
|
668
|
-
|
|
669
|
-
|
|
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';
|
|
670
767
|
} else if (kind === 'proj') {
|
|
671
768
|
if (a === 'root') {
|
|
672
769
|
await selectProject(chatId, projectRoot());
|
|
@@ -764,7 +861,7 @@ export function startTelegramChannel(
|
|
|
764
861
|
notify(sessionId, event) {
|
|
765
862
|
if (ownSessions.has(sessionId)) return;
|
|
766
863
|
|
|
767
|
-
//
|
|
864
|
+
// Las tools `speak` y `screenshot` entregan en vivo a un chat vinculado.
|
|
768
865
|
if (event.type === 'tool_call') {
|
|
769
866
|
if (event.data?.name !== 'speak') return;
|
|
770
867
|
const linked = ctx.store.getSetting(reverseKey(sessionId));
|
|
@@ -776,6 +873,12 @@ export function startTelegramChannel(
|
|
|
776
873
|
}
|
|
777
874
|
return;
|
|
778
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
|
+
}
|
|
779
882
|
|
|
780
883
|
const targets = notifyTargets(sessionId);
|
|
781
884
|
if (!targets.length) return;
|
|
@@ -784,18 +887,7 @@ export function startTelegramChannel(
|
|
|
784
887
|
|
|
785
888
|
if (event.type === 'done' && notifyConfig.done) {
|
|
786
889
|
const text = `[${project}] ✅ Sesión terminada.\n\n${String(event.data?.content ?? '').slice(0, 3500)}`;
|
|
787
|
-
const
|
|
788
|
-
event.data?.say ??
|
|
789
|
-
fraseFin({
|
|
790
|
-
stopReason: event.data?.stopReason,
|
|
791
|
-
rounds: event.data?.rounds,
|
|
792
|
-
toolCalls: event.data?.toolCalls,
|
|
793
|
-
durationMs: event.data?.durationMs,
|
|
794
|
-
});
|
|
795
|
-
for (const chatId of targets) {
|
|
796
|
-
send(chatId, text).catch(() => {});
|
|
797
|
-
sendResumenDeVoz(chatId, `${project}. ${decir}`);
|
|
798
|
-
}
|
|
890
|
+
for (const chatId of targets) send(chatId, text).catch(() => {});
|
|
799
891
|
} else if (event.type === 'error' && notifyConfig.error) {
|
|
800
892
|
const text = `[${project}] ✗ Error en la sesión: ${event.data?.message ?? ''}`;
|
|
801
893
|
for (const chatId of targets) send(chatId, text).catch(() => {});
|
|
@@ -805,17 +897,10 @@ export function startTelegramChannel(
|
|
|
805
897
|
} else if (event.type === 'permission' && notifyConfig.permission) {
|
|
806
898
|
const askId = event.data?.askId;
|
|
807
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);
|
|
808
902
|
for (const chatId of targets) {
|
|
809
|
-
send(chatId,
|
|
810
|
-
reply_markup: {
|
|
811
|
-
inline_keyboard: [
|
|
812
|
-
[
|
|
813
|
-
{ text: 'Permitir', callback_data: `perm:${askId}:allow` },
|
|
814
|
-
{ text: 'Denegar', callback_data: `perm:${askId}:deny` },
|
|
815
|
-
],
|
|
816
|
-
],
|
|
817
|
-
},
|
|
818
|
-
}).catch(() => {});
|
|
903
|
+
send(chatId, text, { reply_markup: permissionKeyboard(askId) }).catch(() => {});
|
|
819
904
|
}
|
|
820
905
|
}
|
|
821
906
|
},
|
|
@@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|
|
2
2
|
import fsp from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
5
|
-
import { staticServerChip } from '../lib/core.js';
|
|
5
|
+
import { staticServerChip, screenshotsDir } from '../lib/core.js';
|
|
6
6
|
import { isInside, localOk, requireLocal, resolveDirOr400 } from '../lib/workspace.js';
|
|
7
7
|
|
|
8
8
|
const MAX_PREVIEW = 200 * 1024;
|
|
@@ -144,4 +144,28 @@ export function registerFiles(route, ctx) {
|
|
|
144
144
|
port: proc.port,
|
|
145
145
|
});
|
|
146
146
|
});
|
|
147
|
+
|
|
148
|
+
// Capturas del tool `screenshot`: viven en la casa, no en el workspace.
|
|
149
|
+
route('GET', '/api/screenshots/:name', ({ req, res, params }) => {
|
|
150
|
+
const local = localOk(req);
|
|
151
|
+
if (!local && !ctx.authEnabled) {
|
|
152
|
+
return sendError(res, 403, 'Las capturas solo están disponibles en conexiones locales.');
|
|
153
|
+
}
|
|
154
|
+
const name = String(params.name ?? '');
|
|
155
|
+
if (!/^[a-z0-9][a-z0-9-]*\.png$/i.test(name)) return sendError(res, 400, 'Nombre de captura inválido.');
|
|
156
|
+
const file = path.join(screenshotsDir(), name);
|
|
157
|
+
let info;
|
|
158
|
+
try {
|
|
159
|
+
info = fs.statSync(file);
|
|
160
|
+
} catch {
|
|
161
|
+
return sendError(res, 404, 'La captura no existe.');
|
|
162
|
+
}
|
|
163
|
+
if (!info.isFile()) return sendError(res, 404, 'La captura no existe.');
|
|
164
|
+
res.writeHead(200, { 'Content-Type': 'image/png', 'Content-Length': info.size, 'Cache-Control': 'no-store' });
|
|
165
|
+
if (req.method === 'HEAD') {
|
|
166
|
+
res.end();
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
fs.createReadStream(file).pipe(res);
|
|
170
|
+
});
|
|
147
171
|
}
|
|
@@ -6,6 +6,49 @@ import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView
|
|
|
6
6
|
const COMPACT_INSTRUCTION =
|
|
7
7
|
'Resumí la conversación en un texto conciso que preserve decisiones tomadas, archivos tocados, hallazgos y el estado actual, para poder continuar el trabajo sin el historial completo.';
|
|
8
8
|
|
|
9
|
+
const SUMMARY_INSTRUCTION =
|
|
10
|
+
'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.';
|
|
11
|
+
|
|
12
|
+
function sessionTranscript(messages) {
|
|
13
|
+
return messages
|
|
14
|
+
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
|
|
15
|
+
.join('\n');
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Corre un resumen no destructivo de la sesión (no toca el historial).
|
|
20
|
+
* Devuelve `{ summary }` o `{ error, status }`.
|
|
21
|
+
*/
|
|
22
|
+
async function summarizeSession(ctx, session, instruction) {
|
|
23
|
+
const messages = ctx.store.getMessages(session.id);
|
|
24
|
+
if (!messages.length) return { error: 'La sesión no tiene mensajes para resumir.', status: 400 };
|
|
25
|
+
const providerId = session.provider || ctx.effectiveProviderId();
|
|
26
|
+
const model = session.model || ctx.effectiveModel();
|
|
27
|
+
if (!model) return { error: 'Falta el modelo para resumir.', status: 400 };
|
|
28
|
+
|
|
29
|
+
try {
|
|
30
|
+
const { provider, config } = ctx.resolveProvider({
|
|
31
|
+
...ctx.providerSettings(providerId),
|
|
32
|
+
apiKey: ctx.apiKeyFor(providerId),
|
|
33
|
+
});
|
|
34
|
+
const result = await runAgent({
|
|
35
|
+
provider,
|
|
36
|
+
config,
|
|
37
|
+
model,
|
|
38
|
+
messages: [
|
|
39
|
+
{ role: 'system', content: instruction },
|
|
40
|
+
{ role: 'user', content: sessionTranscript(messages).slice(0, 60000) },
|
|
41
|
+
],
|
|
42
|
+
tools: [],
|
|
43
|
+
});
|
|
44
|
+
const summary = result.content?.trim();
|
|
45
|
+
if (!summary) return { error: 'El modelo no devolvió un resumen.', status: 502 };
|
|
46
|
+
return { summary };
|
|
47
|
+
} catch (error) {
|
|
48
|
+
return { error: `No se pudo resumir: ${error?.message ?? error}`, status: 502 };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
9
52
|
export function registerSessions(route, ctx) {
|
|
10
53
|
route('GET', '/api/sessions', ({ res }) => {
|
|
11
54
|
const awaiting = awaitingBySession(ctx.approvals.list());
|
|
@@ -123,40 +166,19 @@ export function registerSessions(route, ctx) {
|
|
|
123
166
|
route('POST', '/api/sessions/:id/compact', async ({ res, params }) => {
|
|
124
167
|
const session = ctx.store.getSession(params.id);
|
|
125
168
|
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
126
|
-
const
|
|
127
|
-
if (
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
const transcript = messages
|
|
134
|
-
.map((m) => `${m.role}: ${typeof m.content === 'string' ? m.content : JSON.stringify(m.content)}`)
|
|
135
|
-
.join('\n');
|
|
169
|
+
const out = await summarizeSession(ctx, session, COMPACT_INSTRUCTION);
|
|
170
|
+
if (out.error) return sendError(res, out.status, out.error);
|
|
171
|
+
ctx.store.clearMessages(params.id);
|
|
172
|
+
ctx.store.touchSession(params.id, { meta: { summary: out.summary } });
|
|
173
|
+
sendJson(res, 200, { summary: out.summary, messages: 0 });
|
|
174
|
+
});
|
|
136
175
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
provider,
|
|
144
|
-
config,
|
|
145
|
-
model,
|
|
146
|
-
messages: [
|
|
147
|
-
{ role: 'system', content: COMPACT_INSTRUCTION },
|
|
148
|
-
{ role: 'user', content: transcript.slice(0, 60000) },
|
|
149
|
-
],
|
|
150
|
-
tools: [],
|
|
151
|
-
});
|
|
152
|
-
const summary = result.content?.trim();
|
|
153
|
-
if (!summary) return sendError(res, 502, 'El modelo no devolvió un resumen.');
|
|
154
|
-
ctx.store.clearMessages(params.id);
|
|
155
|
-
ctx.store.touchSession(params.id, { meta: { summary } });
|
|
156
|
-
sendJson(res, 200, { summary, messages: 0 });
|
|
157
|
-
} catch (error) {
|
|
158
|
-
sendError(res, 502, `No se pudo compactar: ${error?.message ?? error}`);
|
|
159
|
-
}
|
|
176
|
+
route('POST', '/api/sessions/:id/summary', async ({ res, params }) => {
|
|
177
|
+
const session = ctx.store.getSession(params.id);
|
|
178
|
+
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
179
|
+
const out = await summarizeSession(ctx, session, SUMMARY_INSTRUCTION);
|
|
180
|
+
if (out.error) return sendError(res, out.status, out.error);
|
|
181
|
+
sendJson(res, 200, { summary: out.summary });
|
|
160
182
|
});
|
|
161
183
|
|
|
162
184
|
route('DELETE', '/api/sessions/:id', ({ res, params }) => {
|
|
@@ -6,7 +6,7 @@ import { mkdtemp, mkdir, writeFile, readFile, stat } from 'node:fs/promises';
|
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { createTamnoraServer } from '../src/index.js';
|
|
9
|
-
import { setHome, ensureApp, readApp, writeApp, makeUser } from '../../core/src/index.js';
|
|
9
|
+
import { setHome, ensureApp, readApp, writeApp, makeUser, screenshotsDir } from '../../core/src/index.js';
|
|
10
10
|
import { completionFooter } from '../src/lib/channels/telegram.js';
|
|
11
11
|
|
|
12
12
|
const fakeProvider = {
|
|
@@ -1719,6 +1719,40 @@ test('compact summarizes the session and clears its messages', async () => {
|
|
|
1719
1719
|
}
|
|
1720
1720
|
});
|
|
1721
1721
|
|
|
1722
|
+
test('the summary endpoint returns a summary without clearing the messages', async () => {
|
|
1723
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-summary-'));
|
|
1724
|
+
const provider = {
|
|
1725
|
+
id: 'sum',
|
|
1726
|
+
async streamChat(_config, opts) {
|
|
1727
|
+
const system = opts.messages.find((m) => m.role === 'system')?.content ?? '';
|
|
1728
|
+
return { content: system.includes('Resumí la conversación') ? 'RESUMEN FINAL' : 'hola', reasoning: '', toolCalls: [] };
|
|
1729
|
+
},
|
|
1730
|
+
};
|
|
1731
|
+
const { instance, base } = await startServer({ cwd, resolveProvider: () => ({ provider, config: {} }) });
|
|
1732
|
+
const json = { 'Content-Type': 'application/json' };
|
|
1733
|
+
try {
|
|
1734
|
+
const created = await (
|
|
1735
|
+
await fetch(`${base}/api/sessions`, { method: 'POST', headers: json, body: JSON.stringify({ title: 'S' }) })
|
|
1736
|
+
).json();
|
|
1737
|
+
const id = created.session.id;
|
|
1738
|
+
await (await fetch(`${base}/api/chat`, {
|
|
1739
|
+
method: 'POST',
|
|
1740
|
+
headers: json,
|
|
1741
|
+
body: JSON.stringify({ input: 'hola', model: 'm', sessionId: id }),
|
|
1742
|
+
})).text();
|
|
1743
|
+
|
|
1744
|
+
const summary = await (
|
|
1745
|
+
await fetch(`${base}/api/sessions/${id}/summary`, { method: 'POST', headers: json })
|
|
1746
|
+
).json();
|
|
1747
|
+
assert.equal(summary.summary, 'RESUMEN FINAL');
|
|
1748
|
+
|
|
1749
|
+
const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
1750
|
+
assert.ok(detail.messages.length > 0, 'no borra el historial');
|
|
1751
|
+
} finally {
|
|
1752
|
+
await instance.close();
|
|
1753
|
+
}
|
|
1754
|
+
});
|
|
1755
|
+
|
|
1722
1756
|
test('exposes modes and persists the active mode', async () => {
|
|
1723
1757
|
const { instance, base } = await startServer();
|
|
1724
1758
|
const json = { 'Content-Type': 'application/json' };
|
|
@@ -1758,7 +1792,21 @@ test('plan mode exposes only read-only tools and adds guidance', async () => {
|
|
|
1758
1792
|
body: JSON.stringify({ input: 'planificá', model: 'm', mode: 'plan' }),
|
|
1759
1793
|
})
|
|
1760
1794
|
).text();
|
|
1761
|
-
assert.deepEqual(captured.tools, [
|
|
1795
|
+
assert.deepEqual(captured.tools, [
|
|
1796
|
+
'console_errors',
|
|
1797
|
+
'fetch_url',
|
|
1798
|
+
'git_diff',
|
|
1799
|
+
'git_log',
|
|
1800
|
+
'git_status',
|
|
1801
|
+
'glob',
|
|
1802
|
+
'grep',
|
|
1803
|
+
'list',
|
|
1804
|
+
'preview_url',
|
|
1805
|
+
'read',
|
|
1806
|
+
'screenshot',
|
|
1807
|
+
'speak',
|
|
1808
|
+
'task',
|
|
1809
|
+
]);
|
|
1762
1810
|
assert.match(captured.system, /modo PLAN/);
|
|
1763
1811
|
} finally {
|
|
1764
1812
|
await instance.close();
|
|
@@ -1992,16 +2040,38 @@ test('truncates a session from a message id', async () => {
|
|
|
1992
2040
|
const firstId = detail.messages[0].id;
|
|
1993
2041
|
assert.ok(firstId);
|
|
1994
2042
|
|
|
1995
|
-
const res = await fetch(`${base}/api/sessions/${sessionId}/truncate`, {
|
|
1996
|
-
method: 'POST',
|
|
1997
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1998
|
-
body: JSON.stringify({ fromId: firstId }),
|
|
1999
|
-
});
|
|
2000
|
-
assert.equal(res.status, 200);
|
|
2001
|
-
|
|
2002
|
-
detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
|
|
2003
|
-
assert.equal(detail.messages.length, 0);
|
|
2004
|
-
} finally {
|
|
2005
|
-
await instance.close();
|
|
2006
|
-
}
|
|
2043
|
+
const res = await fetch(`${base}/api/sessions/${sessionId}/truncate`, {
|
|
2044
|
+
method: 'POST',
|
|
2045
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2046
|
+
body: JSON.stringify({ fromId: firstId }),
|
|
2047
|
+
});
|
|
2048
|
+
assert.equal(res.status, 200);
|
|
2049
|
+
|
|
2050
|
+
detail = await (await fetch(`${base}/api/sessions/${sessionId}`)).json();
|
|
2051
|
+
assert.equal(detail.messages.length, 0);
|
|
2052
|
+
} finally {
|
|
2053
|
+
await instance.close();
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
test('serves browser screenshots from the home dir, confined to png names', async () => {
|
|
2058
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-shots-'));
|
|
2059
|
+
const { instance, base } = await startServer({ cwd });
|
|
2060
|
+
try {
|
|
2061
|
+
const dir = screenshotsDir();
|
|
2062
|
+
await mkdir(dir, { recursive: true });
|
|
2063
|
+
const png = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
2064
|
+
await writeFile(path.join(dir, 'abc-123.png'), png);
|
|
2065
|
+
|
|
2066
|
+
const ok = await fetch(`${base}/api/screenshots/abc-123.png`);
|
|
2067
|
+
assert.equal(ok.status, 200);
|
|
2068
|
+
assert.equal(ok.headers.get('content-type'), 'image/png');
|
|
2069
|
+
assert.equal((await ok.arrayBuffer()).byteLength, png.length);
|
|
2070
|
+
|
|
2071
|
+
assert.equal((await fetch(`${base}/api/screenshots/bad.txt`)).status, 400);
|
|
2072
|
+
assert.equal((await fetch(`${base}/api/screenshots/missing.png`)).status, 404);
|
|
2073
|
+
} finally {
|
|
2074
|
+
await instance.close();
|
|
2075
|
+
}
|
|
2007
2076
|
});
|
|
2077
|
+
|
|
@@ -6,7 +6,7 @@ import path from 'node:path';
|
|
|
6
6
|
import { Store } from '../../core/src/index.js';
|
|
7
7
|
import { createApprovalBroker } from '../src/lib/approvals.js';
|
|
8
8
|
import { createPresence } from '../src/lib/presence.js';
|
|
9
|
-
import { startTelegramChannel } from '../src/lib/channels/telegram.js';
|
|
9
|
+
import { startTelegramChannel, describePermission } from '../src/lib/channels/telegram.js';
|
|
10
10
|
|
|
11
11
|
const reply = (data) => ({ ok: true, json: async () => data });
|
|
12
12
|
|
|
@@ -227,15 +227,36 @@ test('notify de permiso incluye el proyecto y los botones', async () => {
|
|
|
227
227
|
log: () => {},
|
|
228
228
|
});
|
|
229
229
|
try {
|
|
230
|
-
channel.notify(sessionId, {
|
|
231
|
-
|
|
232
|
-
|
|
230
|
+
channel.notify(sessionId, {
|
|
231
|
+
type: 'permission',
|
|
232
|
+
data: { askId: 'ask-1', sessionId, name: 'bash', args: { command: 'npm test' } },
|
|
233
|
+
});
|
|
234
|
+
await waitFor(() => mock.sent.some((m) => /Permiso:/.test(m.text ?? '')));
|
|
235
|
+
const msg = mock.sent.find((m) => /Permiso:/.test(m.text ?? ''));
|
|
233
236
|
assert.match(msg.text, new RegExp(`\\[${path.basename(cwd)}\\]`));
|
|
237
|
+
assert.match(msg.text, /ejecutar un comando/);
|
|
238
|
+
assert.match(msg.text, /npm test/);
|
|
234
239
|
const datas = msg.reply_markup.inline_keyboard.flat().map((b) => b.callback_data);
|
|
235
240
|
assert.ok(datas.includes('perm:ask-1:allow'));
|
|
241
|
+
assert.ok(datas.includes('perm:ask-1:all'));
|
|
242
|
+
assert.ok(datas.includes('perm:ask-1:deny'));
|
|
236
243
|
} finally {
|
|
237
244
|
channel.stop();
|
|
238
245
|
mock.restore();
|
|
239
246
|
ctx.store.close();
|
|
240
247
|
}
|
|
241
248
|
});
|
|
249
|
+
|
|
250
|
+
test('describePermission explica la accion sin volcar el JSON', () => {
|
|
251
|
+
assert.deepEqual(describePermission('bash', { command: 'npm test' }), {
|
|
252
|
+
action: 'ejecutar un comando en el proyecto',
|
|
253
|
+
detail: 'npm test',
|
|
254
|
+
});
|
|
255
|
+
const write = describePermission('write', { path: 'nota.txt', content: 'hola mundo' });
|
|
256
|
+
assert.match(write.action, /escribir el archivo nota\.txt/);
|
|
257
|
+
assert.match(write.detail, /Contenido: 10 B/);
|
|
258
|
+
assert.doesNotMatch(write.detail, /hola mundo/);
|
|
259
|
+
assert.match(describePermission('edit', { path: 'a.js', oldString: 'const a = 1;' }).detail, /const a = 1;/);
|
|
260
|
+
assert.match(describePermission('screenshot', { url: 'http://127.0.0.1:5173' }).detail, /127\.0\.0\.1/);
|
|
261
|
+
assert.match(describePermission('desconocida', { x: 1 }).action, /desconocida/);
|
|
262
|
+
});
|
|
@@ -107,7 +107,7 @@ function preselect(ctx, cwd, chatId = '7') {
|
|
|
107
107
|
return sessionId;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
-
test('
|
|
110
|
+
test('el cierre del turno de Telegram no manda audio aunque la voz esté activada', async () => {
|
|
111
111
|
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-tgvoz-'));
|
|
112
112
|
const ctx = makeCtx(cwd, { tts: { enabled: true, voice: 'es-AR-ElenaNeural', rate: '+0%', announce: { end: true } } });
|
|
113
113
|
preselect(ctx, cwd);
|
|
@@ -117,10 +117,8 @@ test('con la voz activada, el turno de Telegram termina con una nota de voz', as
|
|
|
117
117
|
const channel = startTelegramChannel(ctx, { token: 'x', allowedChatIds: ['7'], log: () => {} });
|
|
118
118
|
try {
|
|
119
119
|
mock.updates.push({ update_id: 1, message: { chat: { id: 7 }, text: 'hola' } });
|
|
120
|
-
await waitFor(() => mock.
|
|
121
|
-
|
|
122
|
-
assert.equal(nota.chatId, '7');
|
|
123
|
-
assert.match(nota.method, /^send(Voice|Audio)$/);
|
|
120
|
+
await waitFor(() => mock.sent.some((m) => /✓ terminado/.test(m.text ?? '')));
|
|
121
|
+
assert.equal(mock.multipart.length, 0);
|
|
124
122
|
} finally {
|
|
125
123
|
global.WebSocket = original;
|
|
126
124
|
channel.stop();
|
package/package.json
CHANGED