@danieltmn/tamnora 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +83 -0
- package/harness/packages/cli/src/cli.js +1 -1
- package/harness/packages/cli/src/commands/bridge.js +180 -119
- package/harness/packages/cli/src/commands/server.js +26 -50
- package/harness/packages/cli/src/lib/daemon.js +92 -40
- package/harness/packages/cli/test/release.test.js +9 -0
- package/harness/packages/core/src/agent/modes.js +1 -1
- package/harness/packages/core/src/index.js +14 -1
- package/harness/packages/core/src/open.js +43 -0
- package/harness/packages/core/src/pid.js +35 -0
- package/harness/packages/core/src/tools/executors.js +16 -0
- package/harness/packages/core/src/tools/labels.js +2 -0
- package/harness/packages/core/src/tools/permissions.js +1 -0
- package/harness/packages/core/src/tools/schema.js +17 -0
- package/harness/packages/core/src/tts/edge.js +232 -0
- package/harness/packages/core/src/tunnel.js +180 -19
- package/harness/packages/core/test/open.test.js +19 -0
- package/harness/packages/core/test/tools.test.js +12 -0
- package/harness/packages/core/test/tts.test.js +41 -0
- package/harness/packages/server/public/app.js +4715 -3975
- package/harness/packages/server/public/index.html +63 -5
- package/harness/packages/server/public/reconcile.js +121 -0
- package/harness/packages/server/public/relayClient.js +170 -0
- package/harness/packages/server/public/runStream.js +207 -0
- package/harness/packages/server/public/styles.css +142 -5
- package/harness/packages/server/src/auth.js +29 -5
- package/harness/packages/server/src/lib/agentRun.js +77 -0
- package/harness/packages/server/src/lib/announcer.js +92 -0
- package/harness/packages/server/src/lib/approvals.js +25 -3
- package/harness/packages/server/src/lib/channels/telegram.js +85 -20
- package/harness/packages/server/src/lib/ffmpeg.js +48 -0
- package/harness/packages/server/src/lib/procs.js +3 -18
- package/harness/packages/server/src/lib/runManager.js +24 -2
- package/harness/packages/server/src/lib/sessionView.js +43 -0
- package/harness/packages/server/src/lib/tasks.js +4 -9
- package/harness/packages/server/src/lib/ttsSettings.js +42 -0
- package/harness/packages/server/src/lib/workspace.js +37 -1
- package/harness/packages/server/src/routes/chat.js +48 -47
- package/harness/packages/server/src/routes/events.js +35 -0
- package/harness/packages/server/src/routes/files.js +22 -26
- package/harness/packages/server/src/routes/folders.js +9 -17
- package/harness/packages/server/src/routes/git.js +6 -14
- package/harness/packages/server/src/routes/procs.js +14 -16
- package/harness/packages/server/src/routes/sessions.js +12 -19
- package/harness/packages/server/src/routes/shell.js +5 -9
- package/harness/packages/server/src/routes/state.js +32 -0
- package/harness/packages/server/src/routes/tts.js +82 -0
- package/harness/packages/server/src/server.js +21 -1
- package/harness/packages/server/test/announcer.test.js +51 -0
- package/harness/packages/server/test/reconcile.test.js +146 -0
- package/harness/packages/server/test/relay.test.js +219 -0
- package/harness/packages/server/test/runStream.test.js +245 -0
- package/harness/packages/server/test/security.test.js +252 -0
- package/harness/packages/server/test/server.test.js +511 -1
- package/harness/packages/server/test/telegram-voz.test.js +156 -0
- package/harness/packages/server/test/tts.test.js +133 -0
- package/package.json +1 -1
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Conversion MP3 -> OGG/Opus para las notas de voz de Telegram (sendVoice
|
|
2
|
+
// exige OGG con Opus). Telegram acepta MP3 solo como audio normal, asi que sin
|
|
3
|
+
// ffmpeg caemos a ese formato. Se detecta una sola vez por proceso.
|
|
4
|
+
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
|
|
7
|
+
let disponible = null;
|
|
8
|
+
|
|
9
|
+
/** `true` si hay un ffmpeg ejecutable en el PATH (se cachea). */
|
|
10
|
+
export function ffmpegDisponible() {
|
|
11
|
+
if (disponible === null) {
|
|
12
|
+
disponible = new Promise((resolve) => {
|
|
13
|
+
const hijo = spawn('ffmpeg', ['-version'], { stdio: 'ignore', windowsHide: true });
|
|
14
|
+
hijo.on('error', () => resolve(false));
|
|
15
|
+
hijo.on('close', (code) => resolve(code === 0));
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
return disponible;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Convierte un buffer de MP3 a OGG/Opus por stdin/stdout.
|
|
23
|
+
* Devuelve el buffer o `null` si no hay ffmpeg o la conversion falla.
|
|
24
|
+
*/
|
|
25
|
+
export async function mp3AOgg(mp3) {
|
|
26
|
+
if (!Buffer.isBuffer(mp3) || !mp3.length) return null;
|
|
27
|
+
if (!(await ffmpegDisponible())) return null;
|
|
28
|
+
return new Promise((resolve) => {
|
|
29
|
+
const hijo = spawn(
|
|
30
|
+
'ffmpeg',
|
|
31
|
+
['-hide_banner', '-loglevel', 'error', '-i', 'pipe:0', '-c:a', 'libopus', '-b:a', '32k', '-f', 'ogg', 'pipe:1'],
|
|
32
|
+
{ stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true }
|
|
33
|
+
);
|
|
34
|
+
const chunks = [];
|
|
35
|
+
let fallo = false;
|
|
36
|
+
hijo.stdout.on('data', (chunk) => chunks.push(chunk));
|
|
37
|
+
hijo.on('error', () => {
|
|
38
|
+
fallo = true;
|
|
39
|
+
resolve(null);
|
|
40
|
+
});
|
|
41
|
+
hijo.on('close', (code) => {
|
|
42
|
+
if (fallo) return;
|
|
43
|
+
resolve(code === 0 && chunks.length ? Buffer.concat(chunks) : null);
|
|
44
|
+
});
|
|
45
|
+
hijo.stdin.on('error', () => {});
|
|
46
|
+
hijo.stdin.end(mp3);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
@@ -1,30 +1,15 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { DEFAULT_PROCESS_ALLOW } from './core.js';
|
|
4
|
+
import { DEFAULT_PROCESS_ALLOW, killPidTree } from './core.js';
|
|
5
5
|
|
|
6
6
|
const MAX_LOG_LINES = 400;
|
|
7
7
|
|
|
8
8
|
function killTree(child) {
|
|
9
9
|
if (!child || child.killed) return;
|
|
10
|
-
|
|
10
|
+
killPidTree(child.pid);
|
|
11
11
|
try {
|
|
12
|
-
|
|
13
|
-
spawn('taskkill', ['/PID', String(pid), '/T', '/F'], { windowsHide: true });
|
|
14
|
-
} else {
|
|
15
|
-
if (pid) {
|
|
16
|
-
try {
|
|
17
|
-
process.kill(-pid, 'SIGTERM');
|
|
18
|
-
} catch {
|
|
19
|
-
/* sin grupo */
|
|
20
|
-
}
|
|
21
|
-
}
|
|
22
|
-
try {
|
|
23
|
-
child.kill('SIGTERM');
|
|
24
|
-
} catch {
|
|
25
|
-
/* ya murio */
|
|
26
|
-
}
|
|
27
|
-
}
|
|
12
|
+
child.kill('SIGTERM');
|
|
28
13
|
} catch {
|
|
29
14
|
/* ya murio */
|
|
30
15
|
}
|
|
@@ -38,6 +38,25 @@ export function createRunManager({ store }) {
|
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
+
notify(sessionId, type, data, event.id);
|
|
42
|
+
return event;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Ciclo de vida del run hacia los suscriptores globales (`subscribeAll`).
|
|
47
|
+
*
|
|
48
|
+
* A diferencia de `emit`, esto NO se persiste: es una notificacion, no parte
|
|
49
|
+
* del log del run. Persistirla desalinearia el cursor de `?from=` y ademas la
|
|
50
|
+
* borraria `clearSessionEvents` al empezar el siguiente run. El `id: 0` marca
|
|
51
|
+
* "esto no viene del log".
|
|
52
|
+
*/
|
|
53
|
+
function announce(sessionId, phase, extra = {}) {
|
|
54
|
+
const type = phase === 'start' ? 'run_start' : 'run_end';
|
|
55
|
+
notify(sessionId, type, { sessionId, ...extra }, 0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function notify(sessionId, type, data, id) {
|
|
59
|
+
const event = { id, type, data, agentId: null };
|
|
41
60
|
for (const listener of [...listeners]) {
|
|
42
61
|
try {
|
|
43
62
|
listener(sessionId, event);
|
|
@@ -45,7 +64,6 @@ export function createRunManager({ store }) {
|
|
|
45
64
|
// un listener caido no debe frenar el run
|
|
46
65
|
}
|
|
47
66
|
}
|
|
48
|
-
return event;
|
|
49
67
|
}
|
|
50
68
|
|
|
51
69
|
function start(sessionId) {
|
|
@@ -54,6 +72,7 @@ export function createRunManager({ store }) {
|
|
|
54
72
|
const run = { sessionId, controller, subscribers: new Set(), lastEvent: null, startedAt: Date.now() };
|
|
55
73
|
runs.set(sessionId, run);
|
|
56
74
|
store.clearSessionEvents(sessionId);
|
|
75
|
+
announce(sessionId, 'start', { at: run.startedAt });
|
|
57
76
|
return run;
|
|
58
77
|
}
|
|
59
78
|
|
|
@@ -62,6 +81,7 @@ export function createRunManager({ store }) {
|
|
|
62
81
|
if (!run) return;
|
|
63
82
|
run.subscribers.clear();
|
|
64
83
|
runs.delete(sessionId);
|
|
84
|
+
announce(sessionId, 'end', { durationMs: Date.now() - run.startedAt });
|
|
65
85
|
}
|
|
66
86
|
|
|
67
87
|
function cancel(sessionId) {
|
|
@@ -69,8 +89,10 @@ export function createRunManager({ store }) {
|
|
|
69
89
|
}
|
|
70
90
|
|
|
71
91
|
function cancelAll() {
|
|
72
|
-
|
|
92
|
+
const active = [...runs.values()];
|
|
93
|
+
for (const run of active) run.controller.abort();
|
|
73
94
|
runs.clear();
|
|
95
|
+
for (const run of active) announce(run.sessionId, 'end', { durationMs: Date.now() - run.startedAt, reason: 'shutdown' });
|
|
74
96
|
}
|
|
75
97
|
|
|
76
98
|
function isActive(sessionId) {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Vista publica de una sesion, compartida por `/api/sessions` y `/api/state`.
|
|
3
|
+
*
|
|
4
|
+
* Vive aparte para que las dos rutas no diverjan: el cliente reconcilia contra
|
|
5
|
+
* `/api/state` y dibuja el sidebar con esa misma forma, asi que un campo nuevo
|
|
6
|
+
* tiene que salir de un solo lugar.
|
|
7
|
+
*/
|
|
8
|
+
export function sessionView(session) {
|
|
9
|
+
return {
|
|
10
|
+
id: session.id,
|
|
11
|
+
title: session.title,
|
|
12
|
+
provider: session.provider,
|
|
13
|
+
model: session.model,
|
|
14
|
+
cwd: session.cwd,
|
|
15
|
+
goal: session.meta?.goal ?? null,
|
|
16
|
+
hasSummary: Boolean(session.meta?.summary),
|
|
17
|
+
createdAt: session.created_at,
|
|
18
|
+
updatedAt: session.updated_at,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** La sesion + su estado de vida, tal como la ve el cliente. */
|
|
23
|
+
export function sessionState(session, ctx, awaiting = 0) {
|
|
24
|
+
return {
|
|
25
|
+
...sessionView(session),
|
|
26
|
+
running: ctx.runs?.isActive(session.id) ?? false,
|
|
27
|
+
awaiting,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Cuenta los permisos pendientes por sesion.
|
|
33
|
+
*
|
|
34
|
+
* "Trabajando" y "esperandote a vos" son cosas distintas: un run que avanza solo
|
|
35
|
+
* no necesita tu atencion, un permiso si.
|
|
36
|
+
*/
|
|
37
|
+
export function awaitingBySession(pending) {
|
|
38
|
+
const counts = new Map();
|
|
39
|
+
for (const ask of pending) {
|
|
40
|
+
counts.set(ask.sessionId, (counts.get(ask.sessionId) ?? 0) + 1);
|
|
41
|
+
}
|
|
42
|
+
return counts;
|
|
43
|
+
}
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// del subagente se emiten con `agentId` para que la consola los anide, y su
|
|
4
4
|
// resumen final se devuelve como resultado de la tool.
|
|
5
5
|
|
|
6
|
-
import { runAgent, createTools, resolveMode
|
|
6
|
+
import { runAgent, createTools, resolveMode } from './core.js';
|
|
7
|
+
import { toolCallPayload, toolResultPayload } from './agentRun.js';
|
|
7
8
|
|
|
8
9
|
const SUBAGENT_PROMPT = [
|
|
9
10
|
'Sos un subagente de Tamnora. Ejecutá de forma autónoma la tarea encomendada',
|
|
@@ -91,15 +92,9 @@ export function createSubagentTool(ctx, options) {
|
|
|
91
92
|
if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
|
|
92
93
|
else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
|
|
93
94
|
else if (event.type === 'tool_call') {
|
|
94
|
-
emit(
|
|
95
|
-
'tool_call',
|
|
96
|
-
{ id: event.id, name: event.name, args: event.args, summary: summarizeToolCall(event.name, event.args), agentId },
|
|
97
|
-
agentId
|
|
98
|
-
);
|
|
95
|
+
emit('tool_call', { ...toolCallPayload(event), agentId }, agentId);
|
|
99
96
|
} else if (event.type === 'tool_result') {
|
|
100
|
-
|
|
101
|
-
const failed = /^Error|^Permiso denegado/i.test(String(event.result));
|
|
102
|
-
emit('tool_result', { id: event.id, name: event.name, preview, truncated, failed, agentId }, agentId);
|
|
97
|
+
emit('tool_result', { id: event.id, name: event.name, ...toolResultPayload(event), agentId }, agentId);
|
|
103
98
|
}
|
|
104
99
|
},
|
|
105
100
|
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Config de voz (TTS) efectiva: lo guardado en app.json (la casa) sobre los
|
|
2
|
+
// defaults. La leen la ruta de ajustes y el canal de Telegram en el momento de
|
|
3
|
+
// hablar, asi un cambio no necesita recargar nada.
|
|
4
|
+
|
|
5
|
+
import { VOZ_POR_DEFECTO } from './core.js';
|
|
6
|
+
|
|
7
|
+
export const DEFAULT_TTS = {
|
|
8
|
+
enabled: false,
|
|
9
|
+
voice: VOZ_POR_DEFECTO,
|
|
10
|
+
rate: '+0%',
|
|
11
|
+
announce: { start: true, tools: true, end: true },
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** Merge de defaults + lo guardado (con `announce` aplanado a booleans). */
|
|
15
|
+
export function resolveTts(app) {
|
|
16
|
+
const saved = app?.tts && typeof app.tts === 'object' ? app.tts : {};
|
|
17
|
+
const announce = { ...DEFAULT_TTS.announce, ...(saved.announce ?? {}) };
|
|
18
|
+
for (const key of ['start', 'tools', 'end']) announce[key] = announce[key] === true;
|
|
19
|
+
return {
|
|
20
|
+
...DEFAULT_TTS,
|
|
21
|
+
...saved,
|
|
22
|
+
announce,
|
|
23
|
+
enabled: saved.enabled === true,
|
|
24
|
+
voice: typeof saved.voice === 'string' && saved.voice ? saved.voice : DEFAULT_TTS.voice,
|
|
25
|
+
rate: typeof saved.rate === 'string' && saved.rate ? saved.rate : DEFAULT_TTS.rate,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Sanitiza un nombre de voz (ej. "es-AR-ElenaNeural"). */
|
|
30
|
+
export function sanearVoz(value, fallback = VOZ_POR_DEFECTO) {
|
|
31
|
+
const voz = String(value ?? '').trim();
|
|
32
|
+
return /^[a-zA-Z]{2}-[a-zA-Z]{2}-[a-zA-Z]+$/.test(voz) ? voz : fallback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Sanitiza una velocidad: "+20%", "-15%", "20%" -> "+20%". */
|
|
36
|
+
export function sanearRate(value, fallback = '+0%') {
|
|
37
|
+
const match = /^([+-]?)(\d+)%$/.exec(String(value ?? '').trim());
|
|
38
|
+
if (!match) return fallback;
|
|
39
|
+
const numero = Math.min(100, Number(match[2]));
|
|
40
|
+
if (numero === 0) return '+0%';
|
|
41
|
+
return `${match[1] === '-' ? '-' : '+'}${numero}%`;
|
|
42
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import { sendError } from './http.js';
|
|
4
|
+
import { tunnelState } from './core.js';
|
|
3
5
|
|
|
4
6
|
/** True for 127.x / ::1 / ::ffff:127.x addresses. */
|
|
5
7
|
export function isLoopback(address) {
|
|
@@ -7,6 +9,24 @@ export function isLoopback(address) {
|
|
|
7
9
|
return addr === '::1' || addr === '0:0:0:0:0:0:0:1' || addr.startsWith('127.') || addr === '::ffff:127.0.0.1';
|
|
8
10
|
}
|
|
9
11
|
|
|
12
|
+
/**
|
|
13
|
+
* "Local de verdad": el par TCP es loopback (remoteAddress, no localAddress:
|
|
14
|
+
* esa es la direccion donde bindea el server, siempre 127.0.0.1) y no hay un
|
|
15
|
+
* tunel publico activo. Con el tunel abierto las peticiones remotas TAMBIEN
|
|
16
|
+
* llegan desde loopback (el cliente del tunel corre en la misma maquina), asi
|
|
17
|
+
* que la ausencia de tunel es parte del check.
|
|
18
|
+
*/
|
|
19
|
+
export function localOk(req) {
|
|
20
|
+
return isLoopback(req?.socket?.remoteAddress) && !tunnelState();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `localOk` o 403. Devuelve true si la request puede seguir. */
|
|
24
|
+
export function requireLocal(req, res, mensaje = 'Solo disponible en conexiones locales.') {
|
|
25
|
+
if (localOk(req)) return true;
|
|
26
|
+
sendError(res, 403, tunnelState() ? `${mensaje} Hay un túnel público activo.` : mensaje);
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
|
|
10
30
|
/** Filesystem roots (drive letters on Windows, `/` elsewhere). */
|
|
11
31
|
export function rootDirs() {
|
|
12
32
|
if (process.platform !== 'win32') return ['/'];
|
|
@@ -40,6 +60,22 @@ export function resolveExistingDir(input) {
|
|
|
40
60
|
return target;
|
|
41
61
|
}
|
|
42
62
|
|
|
63
|
+
/** `resolveExistingDir` con 400 en vez de throw (patron comun en las routes). */
|
|
64
|
+
export function resolveDirOr400(res, raw, fallback) {
|
|
65
|
+
try {
|
|
66
|
+
return resolveExistingDir(raw || fallback);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
sendError(res, 400, error.message);
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** True si `target` es `root` o vive dentro de el (sin salirse por `..`). */
|
|
74
|
+
export function isInside(root, target) {
|
|
75
|
+
const rel = path.relative(root, target);
|
|
76
|
+
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
77
|
+
}
|
|
78
|
+
|
|
43
79
|
/**
|
|
44
80
|
* Workspace for an HTTP request: the requested `cwd` (loopback-only) or the
|
|
45
81
|
* server's default. Throws on invalid input.
|
|
@@ -47,7 +83,7 @@ export function resolveExistingDir(input) {
|
|
|
47
83
|
export function workspaceForRequest({ req, raw, fallback }) {
|
|
48
84
|
const value = String(raw ?? '').trim();
|
|
49
85
|
if (!value) return fallback;
|
|
50
|
-
if (!
|
|
86
|
+
if (!localOk(req)) {
|
|
51
87
|
throw new Error('Cambiar la carpeta de trabajo solo está permitido en conexiones locales.');
|
|
52
88
|
}
|
|
53
89
|
return resolveExistingDir(value);
|
|
@@ -4,12 +4,19 @@ import {
|
|
|
4
4
|
createTools,
|
|
5
5
|
resolveMode,
|
|
6
6
|
MAX_TOOL_ROUNDS,
|
|
7
|
-
summarizeToolCall,
|
|
8
|
-
previewToolResult,
|
|
9
7
|
} from '../lib/core.js';
|
|
10
8
|
import { openSse, readJson, sendJson, sendError } from '../lib/http.js';
|
|
11
9
|
import { workspaceForRequest, resolveExistingDir } from '../lib/workspace.js';
|
|
12
10
|
import { createSubagentTool } from '../lib/tasks.js';
|
|
11
|
+
import {
|
|
12
|
+
combineToolset,
|
|
13
|
+
composeAgentMessages,
|
|
14
|
+
buildUserMessage,
|
|
15
|
+
resolveProviderFor,
|
|
16
|
+
toolCallPayload,
|
|
17
|
+
toolResultPayload,
|
|
18
|
+
} from '../lib/agentRun.js';
|
|
19
|
+
import { fraseInicio, fraseFin, fraseError, createFiltroTools } from '../lib/announcer.js';
|
|
13
20
|
|
|
14
21
|
/**
|
|
15
22
|
* Emit streaming partials at most once per `ms` window, keeping only the
|
|
@@ -57,6 +64,13 @@ export function registerChat(route, ctx) {
|
|
|
57
64
|
sendJson(res, 200, { ok: true, allow, scope });
|
|
58
65
|
});
|
|
59
66
|
|
|
67
|
+
// Lo pendiente, para que un cliente sin stream abierto (el celular mirando
|
|
68
|
+
// otra sesion) sepa igual que hay un permiso esperando.
|
|
69
|
+
route('GET', '/api/permissions', ({ res, query }) => {
|
|
70
|
+
const sessionId = query.get('sessionId') ?? undefined;
|
|
71
|
+
sendJson(res, 200, { pending: ctx.approvals.list({ sessionId }) });
|
|
72
|
+
});
|
|
73
|
+
|
|
60
74
|
route('POST', '/api/sessions/:id/stop', ({ res, params }) => {
|
|
61
75
|
if (!ctx.runs.isActive(params.id)) return sendJson(res, 200, { ok: true, stopped: false });
|
|
62
76
|
ctx.runs.cancel(params.id);
|
|
@@ -146,7 +160,14 @@ export function registerChat(route, ctx) {
|
|
|
146
160
|
const autoApprove = body.yes ?? ctx.yes;
|
|
147
161
|
const asked = new Set();
|
|
148
162
|
|
|
149
|
-
ctx.runs.emit(sessionId, 'start', {
|
|
163
|
+
ctx.runs.emit(sessionId, 'start', {
|
|
164
|
+
sessionId,
|
|
165
|
+
provider: providerId,
|
|
166
|
+
model,
|
|
167
|
+
mode: mode.id,
|
|
168
|
+
cwd: sessionCwd,
|
|
169
|
+
say: fraseInicio(),
|
|
170
|
+
});
|
|
150
171
|
ctx.runs.emit(sessionId, 'user', { content: input });
|
|
151
172
|
|
|
152
173
|
let mcp = null;
|
|
@@ -165,7 +186,7 @@ export function registerChat(route, ctx) {
|
|
|
165
186
|
asked.add(askId);
|
|
166
187
|
askMeta.set(askId, { sessionId });
|
|
167
188
|
ctx.runs.emit(sessionId, 'permission', { askId, sessionId, name, args });
|
|
168
|
-
const allow = await ctx.approvals.ask(askId);
|
|
189
|
+
const allow = await ctx.approvals.ask(askId, { sessionId, name, args });
|
|
169
190
|
asked.delete(askId);
|
|
170
191
|
askMeta.delete(askId);
|
|
171
192
|
ctx.runs.emit(sessionId, 'permission_resolved', { askId, allow });
|
|
@@ -194,45 +215,24 @@ export function registerChat(route, ctx) {
|
|
|
194
215
|
exclude: admin.definitions.map((d) => d.function.name),
|
|
195
216
|
emit: emitAgent,
|
|
196
217
|
});
|
|
197
|
-
const
|
|
198
|
-
|
|
199
|
-
...tasks.definitions,
|
|
200
|
-
];
|
|
201
|
-
const execute = (name, args, context) => {
|
|
202
|
-
if (tasks.handles(name)) return tasks.execute(name, args);
|
|
203
|
-
if (exposeAdmin && admin.handles(name)) {
|
|
204
|
-
return admin.execute(name, args, { approve: autoApprove ? async () => true : onAsk });
|
|
205
|
-
}
|
|
206
|
-
return local.execute(name, args, context);
|
|
207
|
-
};
|
|
218
|
+
const approve = autoApprove ? async () => true : (name, args) => onAsk(name, args);
|
|
219
|
+
const { definitions, execute } = combineToolset({ local, admin, tasks, exposeAdmin, approve });
|
|
208
220
|
|
|
209
|
-
const { provider, config } = ctx
|
|
210
|
-
...ctx.providerSettings(providerId),
|
|
211
|
-
apiKey: ctx.apiKeyFor(providerId),
|
|
221
|
+
const { provider, config } = resolveProviderFor(ctx, providerId, {
|
|
212
222
|
baseUrl: body.baseUrl || ctx.providerSettings(providerId).baseUrl,
|
|
213
223
|
});
|
|
214
224
|
|
|
215
|
-
const userMessage = images
|
|
216
|
-
? {
|
|
217
|
-
role: 'user',
|
|
218
|
-
content: [
|
|
219
|
-
{ type: 'text', text: input },
|
|
220
|
-
...images.map((url) => ({ type: 'image_url', image_url: { url } })),
|
|
221
|
-
],
|
|
222
|
-
}
|
|
223
|
-
: { role: 'user', content: input };
|
|
225
|
+
const userMessage = buildUserMessage(input, images);
|
|
224
226
|
|
|
225
227
|
const sessionRecord = ctx.store.getSession(sessionId);
|
|
226
|
-
const
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
...(goal ? [{ role: 'system', content: `Objetivo de la sesión: ${goal}` }] : []),
|
|
232
|
-
...(summary ? [{ role: 'system', content: `Resumen de la conversación anterior:\n${summary}` }] : []),
|
|
233
|
-
...history,
|
|
228
|
+
const messages = await composeAgentMessages({
|
|
229
|
+
ctx,
|
|
230
|
+
cwd: sessionCwd,
|
|
231
|
+
mode,
|
|
232
|
+
history,
|
|
234
233
|
userMessage,
|
|
235
|
-
|
|
234
|
+
sessionRecord,
|
|
235
|
+
});
|
|
236
236
|
// Persistimos el mensaje del usuario ya: asi sobrevive a un corte y la
|
|
237
237
|
// reconexion puede reconstruir el turno desde el historial.
|
|
238
238
|
ctx.store.addMessage(sessionId, userMessage);
|
|
@@ -240,6 +240,7 @@ export function registerChat(route, ctx) {
|
|
|
240
240
|
|
|
241
241
|
(async () => {
|
|
242
242
|
const startedAt = Date.now();
|
|
243
|
+
const filtroTools = createFiltroTools();
|
|
243
244
|
const partials = createPartialThrottle(300, (name, payload) => ctx.runs.emit(sessionId, name, payload));
|
|
244
245
|
try {
|
|
245
246
|
const result = await runAgent({
|
|
@@ -275,17 +276,15 @@ export function registerChat(route, ctx) {
|
|
|
275
276
|
ctx.runs.emit(sessionId, 'tools', { toolCalls: event.toolCalls });
|
|
276
277
|
} else if (event.type === 'tool_call') {
|
|
277
278
|
partials.flush();
|
|
278
|
-
|
|
279
|
+
const payload = toolCallPayload(event);
|
|
280
|
+
ctx.runs.emit(sessionId, 'tool_call', { ...payload, say: filtroTools(event.name, payload.summary) });
|
|
281
|
+
} else if (event.type === 'tool_result') {
|
|
282
|
+
partials.flush();
|
|
283
|
+
ctx.runs.emit(sessionId, 'tool_result', {
|
|
279
284
|
id: event.id,
|
|
280
285
|
name: event.name,
|
|
281
|
-
|
|
282
|
-
summary: summarizeToolCall(event.name, event.args),
|
|
286
|
+
...toolResultPayload(event),
|
|
283
287
|
});
|
|
284
|
-
} else if (event.type === 'tool_result') {
|
|
285
|
-
partials.flush();
|
|
286
|
-
const { preview, truncated } = previewToolResult(event.name, event.result);
|
|
287
|
-
const failed = /^Error|^Permiso denegado/i.test(String(event.result));
|
|
288
|
-
ctx.runs.emit(sessionId, 'tool_result', { id: event.id, name: event.name, preview, truncated, failed });
|
|
289
288
|
} else if (event.type === 'repeat') {
|
|
290
289
|
partials.flush();
|
|
291
290
|
ctx.runs.emit(sessionId, 'repeat', { name: event.name, count: event.count });
|
|
@@ -304,7 +303,8 @@ export function registerChat(route, ctx) {
|
|
|
304
303
|
ctx.store.touchSession(sessionId, { provider: providerId, model, title });
|
|
305
304
|
const toolCalls = newMessages.filter((message) => message.role === 'tool').length;
|
|
306
305
|
const durationMs = Date.now() - startedAt;
|
|
307
|
-
if (result.stopReason === 'aborted')
|
|
306
|
+
if (result.stopReason === 'aborted')
|
|
307
|
+
ctx.runs.emit(sessionId, 'aborted', { durationMs, toolCalls, say: fraseFin({ stopReason: 'aborted' }) });
|
|
308
308
|
else
|
|
309
309
|
ctx.runs.emit(sessionId, 'done', {
|
|
310
310
|
sessionId,
|
|
@@ -316,11 +316,12 @@ export function registerChat(route, ctx) {
|
|
|
316
316
|
tokens: result.usage ?? null,
|
|
317
317
|
toolCalls,
|
|
318
318
|
durationMs,
|
|
319
|
+
say: fraseFin({ stopReason: result.stopReason, rounds: result.rounds, toolCalls, durationMs }),
|
|
319
320
|
});
|
|
320
321
|
} catch (error) {
|
|
321
322
|
partials.flush();
|
|
322
|
-
if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', {});
|
|
323
|
-
else ctx.runs.emit(sessionId, 'error', { message: error?.message ?? String(error) });
|
|
323
|
+
if (error?.name === 'AbortError') ctx.runs.emit(sessionId, 'aborted', { say: fraseFin({ stopReason: 'aborted' }) });
|
|
324
|
+
else ctx.runs.emit(sessionId, 'error', { message: error?.message ?? String(error), say: fraseError() });
|
|
324
325
|
} finally {
|
|
325
326
|
unsubscribe();
|
|
326
327
|
sse.close();
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { openSse } from '../lib/http.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Canal global de eventos: un SSE por pestaña de la consola.
|
|
5
|
+
*
|
|
6
|
+
* Sirve para que varias ventanas (o la PC y el celular a la vez) se enteren de
|
|
7
|
+
* que un run arranco en una sesion, sin tener cada una su propio stream por
|
|
8
|
+
* sesion abierto.
|
|
9
|
+
*
|
|
10
|
+
* Solo van eventos de ciclo de vida y **los permisos**. El detalle del turno
|
|
11
|
+
* sigue yendo por `/api/sessions/:id/stream`: filtrar es obligatorio, porque
|
|
12
|
+
* `subscribeAll` ve cada parcial de texto de todos los runs y reenviarlos seria
|
|
13
|
+
* duplicar el stream por sesion.
|
|
14
|
+
*
|
|
15
|
+
* Los permisos van aparte justamente porque son lo unico que, si solo viviera
|
|
16
|
+
* en el stream, un cliente no suscrito a esa sesion no tendria forma de saber
|
|
17
|
+
* que esta esperando una decision suya.
|
|
18
|
+
*
|
|
19
|
+
* Al conectar se manda `snapshot` con las sesiones que ya estan corriendo y con
|
|
20
|
+
* los permisos pendientes: es lo que hace el canal auto-sanador. Si el stream se
|
|
21
|
+
* corta y vuelve, el snapshot le dice al cliente que se quedo a medio camino.
|
|
22
|
+
*/
|
|
23
|
+
const GLOBAL_EVENTS = new Set(['run_start', 'run_end', 'permission', 'permission_resolved']);
|
|
24
|
+
|
|
25
|
+
export function registerEvents(route, ctx) {
|
|
26
|
+
route('GET', '/api/events', ({ req, res }) => {
|
|
27
|
+
const sse = openSse(req, res);
|
|
28
|
+
const unsubscribe = ctx.runs.subscribeAll((sessionId, event) => {
|
|
29
|
+
if (!GLOBAL_EVENTS.has(event.type)) return;
|
|
30
|
+
sse.event(event.type, event.data);
|
|
31
|
+
});
|
|
32
|
+
sse.event('snapshot', { active: ctx.runs.activeSessions(), permissions: ctx.approvals.list() });
|
|
33
|
+
req.on('close', unsubscribe);
|
|
34
|
+
});
|
|
35
|
+
}
|
|
@@ -3,28 +3,20 @@ import fsp from 'node:fs/promises';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
5
5
|
import { staticServerChip } from '../lib/core.js';
|
|
6
|
-
import {
|
|
6
|
+
import { isInside, localOk, requireLocal, resolveDirOr400 } from '../lib/workspace.js';
|
|
7
7
|
|
|
8
8
|
const MAX_PREVIEW = 200 * 1024;
|
|
9
9
|
const IGNORED = new Set(['node_modules', '.git', '.tamnora', '.playwright-mcp', 'dist', '.cache']);
|
|
10
10
|
|
|
11
|
-
function isInside(root, target) {
|
|
12
|
-
const rel = path.relative(root, target);
|
|
13
|
-
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
14
|
-
}
|
|
15
|
-
|
|
16
11
|
/** Resolve `?root` + `?path` into a confined filesystem target, or send an error. */
|
|
17
|
-
function resolveTarget(req, res, query, fallback) {
|
|
18
|
-
if (!isLoopback(req.socket.localAddress)) {
|
|
19
|
-
sendError(res, 403, 'Explorar archivos solo está disponible en conexiones locales.');
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
12
|
+
function resolveTarget(req, res, query, fallback, { allowAnyRoot }) {
|
|
22
13
|
let root;
|
|
23
|
-
|
|
24
|
-
root =
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
14
|
+
if (allowAnyRoot) {
|
|
15
|
+
root = resolveDirOr400(res, query.get('root'), fallback);
|
|
16
|
+
if (!root) return null;
|
|
17
|
+
} else {
|
|
18
|
+
// Sin conexion local de verdad: root fijo al workspace, se ignora ?root.
|
|
19
|
+
root = fallback;
|
|
28
20
|
}
|
|
29
21
|
const rel = String(query.get('path') ?? '').replace(/^[/\\]+/, '');
|
|
30
22
|
const target = path.resolve(root, rel);
|
|
@@ -39,7 +31,11 @@ const toPosix = (value) => value.split(path.sep).join('/');
|
|
|
39
31
|
|
|
40
32
|
export function registerFiles(route, ctx) {
|
|
41
33
|
route('GET', '/api/files', ({ req, res, query }) => {
|
|
42
|
-
const
|
|
34
|
+
const local = localOk(req);
|
|
35
|
+
if (!local && !ctx.authEnabled) {
|
|
36
|
+
return sendError(res, 403, 'Explorar archivos solo está disponible en conexiones locales.');
|
|
37
|
+
}
|
|
38
|
+
const resolved = resolveTarget(req, res, query, ctx.cwd, { allowAnyRoot: local });
|
|
43
39
|
if (!resolved) return;
|
|
44
40
|
|
|
45
41
|
let stat;
|
|
@@ -87,7 +83,11 @@ export function registerFiles(route, ctx) {
|
|
|
87
83
|
});
|
|
88
84
|
|
|
89
85
|
route('GET', '/api/files/content', async ({ req, res, query }) => {
|
|
90
|
-
const
|
|
86
|
+
const local = localOk(req);
|
|
87
|
+
if (!local && !ctx.authEnabled) {
|
|
88
|
+
return sendError(res, 403, 'Explorar archivos solo está disponible en conexiones locales.');
|
|
89
|
+
}
|
|
90
|
+
const resolved = resolveTarget(req, res, query, ctx.cwd, { allowAnyRoot: local });
|
|
91
91
|
if (!resolved) return;
|
|
92
92
|
|
|
93
93
|
let stat;
|
|
@@ -117,19 +117,15 @@ export function registerFiles(route, ctx) {
|
|
|
117
117
|
});
|
|
118
118
|
|
|
119
119
|
route('POST', '/api/files/preview', async ({ req, res }) => {
|
|
120
|
-
if (!
|
|
121
|
-
return
|
|
120
|
+
if (!requireLocal(req, res, 'La vista previa solo está disponible en conexiones locales.')) {
|
|
121
|
+
return;
|
|
122
122
|
}
|
|
123
123
|
if (!ctx.procsEnabled) {
|
|
124
124
|
return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
|
|
125
125
|
}
|
|
126
126
|
const body = await readJson(req);
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
root = resolveExistingDir(body.root || ctx.cwd);
|
|
130
|
-
} catch (error) {
|
|
131
|
-
return sendError(res, 400, error.message);
|
|
132
|
-
}
|
|
127
|
+
const root = resolveDirOr400(res, body.root, ctx.cwd);
|
|
128
|
+
if (!root) return;
|
|
133
129
|
const rel = String(body.path ?? '').replace(/^[/\\]+/, '');
|
|
134
130
|
const target = path.resolve(root, rel);
|
|
135
131
|
if (!isInside(root, target)) return sendError(res, 400, 'La ruta queda fuera del workspace.');
|