@danieltmn/tamnora 0.8.0 → 0.9.1
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 +89 -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 +4665 -3975
- package/harness/packages/server/public/index.html +62 -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { mkdir } from 'node:fs/promises';
|
|
3
3
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
4
|
-
import {
|
|
4
|
+
import { requireLocal, rootDirs, listSubdirs, resolveDirOr400 } from '../lib/workspace.js';
|
|
5
5
|
|
|
6
6
|
const INVALID_NAME = /[\\/:*?"<>|]/;
|
|
7
7
|
|
|
@@ -12,8 +12,8 @@ function validFolderName(name) {
|
|
|
12
12
|
|
|
13
13
|
export function registerFolders(route) {
|
|
14
14
|
route('GET', '/api/folders', ({ req, res, query }) => {
|
|
15
|
-
if (!
|
|
16
|
-
return
|
|
15
|
+
if (!requireLocal(req, res, 'El navegador de carpetas solo está disponible en conexiones locales.')) {
|
|
16
|
+
return;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
const raw = String(query.get('path') ?? '').trim();
|
|
@@ -26,12 +26,8 @@ export function registerFolders(route) {
|
|
|
26
26
|
});
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
dir = resolveExistingDir(raw);
|
|
32
|
-
} catch (error) {
|
|
33
|
-
return sendError(res, 400, error.message);
|
|
34
|
-
}
|
|
29
|
+
const dir = resolveDirOr400(res, raw);
|
|
30
|
+
if (!dir) return;
|
|
35
31
|
|
|
36
32
|
let dirs;
|
|
37
33
|
try {
|
|
@@ -49,8 +45,8 @@ export function registerFolders(route) {
|
|
|
49
45
|
});
|
|
50
46
|
|
|
51
47
|
route('POST', '/api/folders', async ({ req, res }) => {
|
|
52
|
-
if (!
|
|
53
|
-
return
|
|
48
|
+
if (!requireLocal(req, res, 'Crear carpetas solo está disponible en conexiones locales.')) {
|
|
49
|
+
return;
|
|
54
50
|
}
|
|
55
51
|
const body = await readJson(req);
|
|
56
52
|
const name = String(body.name ?? '').trim();
|
|
@@ -61,12 +57,8 @@ export function registerFolders(route) {
|
|
|
61
57
|
if (!String(body.parent ?? '').trim()) {
|
|
62
58
|
return sendError(res, 400, 'Elegí la carpeta contenedora primero.');
|
|
63
59
|
}
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
parent = resolveExistingDir(body.parent);
|
|
67
|
-
} catch (error) {
|
|
68
|
-
return sendError(res, 400, error.message);
|
|
69
|
-
}
|
|
60
|
+
const parent = resolveDirOr400(res, body.parent);
|
|
61
|
+
if (!parent) return;
|
|
70
62
|
|
|
71
63
|
const target = path.join(parent, name);
|
|
72
64
|
try {
|
|
@@ -1,25 +1,17 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { gitStatus, gitDiff } from '../lib/core.js';
|
|
3
3
|
import { sendJson, sendError } from '../lib/http.js';
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
-
function isInside(root, target) {
|
|
7
|
-
const rel = path.relative(root, target);
|
|
8
|
-
return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
|
|
9
|
-
}
|
|
4
|
+
import { isInside, localOk, requireLocal, resolveDirOr400 } from '../lib/workspace.js';
|
|
10
5
|
|
|
11
6
|
export function registerGit(route, ctx) {
|
|
12
7
|
const resolveRoot = (req, res, raw) => {
|
|
13
|
-
if (!
|
|
14
|
-
sendError(res, 403, 'El estado de git solo está disponible en conexiones locales.');
|
|
15
|
-
return null;
|
|
16
|
-
}
|
|
17
|
-
try {
|
|
18
|
-
return resolveExistingDir(raw || ctx.cwd);
|
|
19
|
-
} catch (error) {
|
|
20
|
-
sendError(res, 400, error.message);
|
|
8
|
+
if (!ctx.authEnabled && !requireLocal(req, res, 'El estado de git solo está disponible en conexiones locales.')) {
|
|
21
9
|
return null;
|
|
22
10
|
}
|
|
11
|
+
// Con auth (sesion ya validada) se permite por el tunel, pero el cwd
|
|
12
|
+
// pedido queda confinado al workspace salvo conexion local de verdad.
|
|
13
|
+
const root = resolveDirOr400(res, localOk(req) ? raw : null, ctx.cwd);
|
|
14
|
+
return root;
|
|
23
15
|
};
|
|
24
16
|
|
|
25
17
|
route('GET', '/api/git/status', async ({ req, res, query }) => {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { detectProcs, killPort } from '../lib/core.js';
|
|
2
2
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
-
import {
|
|
3
|
+
import { requireLocal, resolveDirOr400 } from '../lib/workspace.js';
|
|
4
4
|
|
|
5
5
|
export function registerProcs(route, ctx) {
|
|
6
6
|
route('GET', '/api/procs', ({ res }) => {
|
|
@@ -8,24 +8,19 @@ export function registerProcs(route, ctx) {
|
|
|
8
8
|
});
|
|
9
9
|
|
|
10
10
|
route('GET', '/api/procs/detect', ({ res, query }) => {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
dir = resolveExistingDir(query.get('cwd') || ctx.cwd);
|
|
14
|
-
} catch (error) {
|
|
15
|
-
return sendError(res, 400, error.message);
|
|
16
|
-
}
|
|
11
|
+
const dir = resolveDirOr400(res, query.get('cwd'), ctx.cwd);
|
|
12
|
+
if (!dir) return;
|
|
17
13
|
sendJson(res, 200, { cwd: dir, chips: detectProcs(dir) });
|
|
18
14
|
});
|
|
19
15
|
|
|
20
16
|
route('POST', '/api/procs/start', async ({ req, res }) => {
|
|
17
|
+
if (!ctx.authEnabled && !requireLocal(req, res, 'Iniciar procesos solo está disponible en conexiones locales.')) {
|
|
18
|
+
return;
|
|
19
|
+
}
|
|
21
20
|
if (!ctx.procsEnabled) return sendError(res, 403, 'Los procesos están deshabilitados (processes.enabled = false).');
|
|
22
21
|
const body = await readJson(req);
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
dir = resolveExistingDir(body.cwd || ctx.cwd);
|
|
26
|
-
} catch (error) {
|
|
27
|
-
return sendError(res, 400, error.message);
|
|
28
|
-
}
|
|
22
|
+
const dir = resolveDirOr400(res, body.cwd, ctx.cwd);
|
|
23
|
+
if (!dir) return;
|
|
29
24
|
try {
|
|
30
25
|
const proc = ctx.procs.start({ cwd: dir, command: body.command, args: body.args, port: body.port });
|
|
31
26
|
sendJson(res, 201, { proc });
|
|
@@ -40,14 +35,17 @@ export function registerProcs(route, ctx) {
|
|
|
40
35
|
sendJson(res, 200, data);
|
|
41
36
|
});
|
|
42
37
|
|
|
43
|
-
route('POST', '/api/procs/:id/stop', ({ res, params }) => {
|
|
38
|
+
route('POST', '/api/procs/:id/stop', ({ req, res, params }) => {
|
|
39
|
+
if (!ctx.authEnabled && !requireLocal(req, res, 'Detener procesos solo está disponible en conexiones locales.')) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
44
42
|
if (!ctx.procs.stop(params.id)) return sendError(res, 404, 'Proceso no encontrado.');
|
|
45
43
|
sendJson(res, 200, { ok: true });
|
|
46
44
|
});
|
|
47
45
|
|
|
48
46
|
route('POST', '/api/procs/kill', async ({ req, res }) => {
|
|
49
|
-
if (!
|
|
50
|
-
return
|
|
47
|
+
if (!requireLocal(req, res, 'Matar procesos solo está disponible en conexiones locales.')) {
|
|
48
|
+
return;
|
|
51
49
|
}
|
|
52
50
|
const body = await readJson(req);
|
|
53
51
|
try {
|
|
@@ -1,27 +1,16 @@
|
|
|
1
1
|
import { runAgent } from '../lib/core.js';
|
|
2
2
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
3
|
import { workspaceForRequest } from '../lib/workspace.js';
|
|
4
|
-
|
|
5
|
-
function view(session) {
|
|
6
|
-
return {
|
|
7
|
-
id: session.id,
|
|
8
|
-
title: session.title,
|
|
9
|
-
provider: session.provider,
|
|
10
|
-
model: session.model,
|
|
11
|
-
cwd: session.cwd,
|
|
12
|
-
goal: session.meta?.goal ?? null,
|
|
13
|
-
hasSummary: Boolean(session.meta?.summary),
|
|
14
|
-
createdAt: session.created_at,
|
|
15
|
-
updatedAt: session.updated_at,
|
|
16
|
-
};
|
|
17
|
-
}
|
|
4
|
+
import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
|
|
18
5
|
|
|
19
6
|
const COMPACT_INSTRUCTION =
|
|
20
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.';
|
|
21
8
|
|
|
22
9
|
export function registerSessions(route, ctx) {
|
|
23
10
|
route('GET', '/api/sessions', ({ res }) => {
|
|
24
|
-
|
|
11
|
+
const awaiting = awaitingBySession(ctx.approvals.list());
|
|
12
|
+
const sessions = ctx.store.listSessions(50).map((session) => sessionState(session, ctx, awaiting.get(session.id) ?? 0));
|
|
13
|
+
sendJson(res, 200, { sessions });
|
|
25
14
|
});
|
|
26
15
|
|
|
27
16
|
route('GET', '/api/search', ({ res, query }) => {
|
|
@@ -38,7 +27,7 @@ export function registerSessions(route, ctx) {
|
|
|
38
27
|
return text.replace(/\s+/g, ' ').trim().slice(0, 200);
|
|
39
28
|
};
|
|
40
29
|
sendJson(res, 200, {
|
|
41
|
-
sessions: ctx.store.searchSessions(q, limit).map(
|
|
30
|
+
sessions: ctx.store.searchSessions(q, limit).map(sessionView),
|
|
42
31
|
messages: ctx.store.searchMessages(q, limit).map((m) => ({
|
|
43
32
|
id: m.id,
|
|
44
33
|
sessionId: m.sessionId,
|
|
@@ -66,17 +55,21 @@ export function registerSessions(route, ctx) {
|
|
|
66
55
|
cwd,
|
|
67
56
|
meta,
|
|
68
57
|
});
|
|
69
|
-
sendJson(res, 201, { session:
|
|
58
|
+
sendJson(res, 201, { session: sessionView(ctx.store.getSession(id)) });
|
|
70
59
|
});
|
|
71
60
|
|
|
72
61
|
route('GET', '/api/sessions/:id', ({ res, params }) => {
|
|
73
62
|
const session = ctx.store.getSession(params.id);
|
|
74
63
|
if (!session) return sendError(res, 404, 'Sesión no encontrada.');
|
|
75
64
|
sendJson(res, 200, {
|
|
76
|
-
session:
|
|
65
|
+
session: sessionView(session),
|
|
77
66
|
tokens: ctx.store.sessionTokens(params.id),
|
|
78
67
|
messages: ctx.store.getMessages(params.id),
|
|
79
68
|
running: ctx.runs?.isActive(params.id) ?? false,
|
|
69
|
+
// Un permiso pendiente NO se reconstruye desde los mensajes (no son
|
|
70
|
+
// mensajes), asi que va aparte: sin esto, recargar la pagina dejaba el run
|
|
71
|
+
// esperando una decision que no se podia tomar.
|
|
72
|
+
permissions: ctx.approvals.list({ sessionId: params.id }),
|
|
80
73
|
});
|
|
81
74
|
});
|
|
82
75
|
|
|
@@ -104,7 +97,7 @@ export function registerSessions(route, ctx) {
|
|
|
104
97
|
if ('title' in body) patch.title = String(body.title || '').slice(0, 80) || session.title;
|
|
105
98
|
if ('goal' in body) patch.meta = { goal: body.goal ? String(body.goal) : undefined };
|
|
106
99
|
ctx.store.touchSession(params.id, patch);
|
|
107
|
-
sendJson(res, 200, { session:
|
|
100
|
+
sendJson(res, 200, { session: sessionView(ctx.store.getSession(params.id)) });
|
|
108
101
|
});
|
|
109
102
|
|
|
110
103
|
route('DELETE', '/api/sessions/:id/messages', ({ res, params }) => {
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
import { createTools } from '../lib/core.js';
|
|
2
2
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
3
|
-
import {
|
|
3
|
+
import { requireLocal, resolveDirOr400 } from '../lib/workspace.js';
|
|
4
4
|
|
|
5
5
|
export function registerShell(route, ctx) {
|
|
6
6
|
route('POST', '/api/shell', async ({ req, res }) => {
|
|
7
|
-
if (!
|
|
8
|
-
return
|
|
7
|
+
if (!requireLocal(req, res, 'El shell solo está disponible en conexiones locales.')) {
|
|
8
|
+
return;
|
|
9
9
|
}
|
|
10
10
|
const body = await readJson(req);
|
|
11
11
|
const command = String(body.command ?? '').trim();
|
|
12
12
|
if (!command) return sendError(res, 400, 'Falta "command".');
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
root = resolveExistingDir(body.cwd || ctx.cwd);
|
|
16
|
-
} catch (error) {
|
|
17
|
-
return sendError(res, 400, error.message);
|
|
18
|
-
}
|
|
13
|
+
const root = resolveDirOr400(res, body.cwd, ctx.cwd);
|
|
14
|
+
if (!root) return;
|
|
19
15
|
const tools = createTools({ root, enabled: ['bash'], yes: true });
|
|
20
16
|
const output = await tools.execute('bash', { command });
|
|
21
17
|
sendJson(res, 200, { command, cwd: root, output: String(output ?? '') });
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { sendJson } from '../lib/http.js';
|
|
2
|
+
import { sessionState, awaitingBySession } from '../lib/sessionView.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Snapshot de todo lo que el cliente necesita para reconciliar, en un solo
|
|
6
|
+
* request.
|
|
7
|
+
*
|
|
8
|
+
* Este endpoint es la **verdad**: los streams SSE son un atajo de latencia que
|
|
9
|
+
* se cae en cualquier enlace movil (y que iOS corta cuando manda la pestana a
|
|
10
|
+
* background). Por eso no lleva mensajes ni tool results — solo liveness, que es
|
|
11
|
+
* chico y se puede pedir seguido. El detalle de una sesion sigue bajo demanda
|
|
12
|
+
* en `/api/sessions/:id`.
|
|
13
|
+
*/
|
|
14
|
+
export function registerState(route, ctx) {
|
|
15
|
+
route('GET', '/api/state', ({ res }) => {
|
|
16
|
+
const permissions = ctx.approvals.list();
|
|
17
|
+
const awaiting = awaitingBySession(permissions);
|
|
18
|
+
const sessions = ctx.store.listSessions(50).map((session) => sessionState(session, ctx, awaiting.get(session.id) ?? 0));
|
|
19
|
+
sendJson(res, 200, {
|
|
20
|
+
sessions,
|
|
21
|
+
permissions: permissions.map(({ askId, sessionId, name, args, requestedAt }) => ({
|
|
22
|
+
askId,
|
|
23
|
+
sessionId,
|
|
24
|
+
name,
|
|
25
|
+
args,
|
|
26
|
+
requestedAt,
|
|
27
|
+
})),
|
|
28
|
+
// Para que el cliente pueda detectar que el server va en otro reloj.
|
|
29
|
+
now: Date.now(),
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// Voz (TTS): sintesis bajo demanda de la consola + ajustes persistidos en
|
|
2
|
+
// app.json (la casa). La config no necesita recarga: cada consumidor la lee
|
|
3
|
+
// cuando va a hablar.
|
|
4
|
+
|
|
5
|
+
import { sintetizar, listarVoces, MAX_TEXTO } from '../lib/core.js';
|
|
6
|
+
import { readApp, writeApp } from '../lib/core.js';
|
|
7
|
+
import { ffmpegDisponible } from '../lib/ffmpeg.js';
|
|
8
|
+
import { resolveTts, sanearVoz, sanearRate } from '../lib/ttsSettings.js';
|
|
9
|
+
import { sendJson, sendError, readJson } from '../lib/http.js';
|
|
10
|
+
|
|
11
|
+
const cacheVoces = { at: 0, data: null, filtro: 'es-' };
|
|
12
|
+
|
|
13
|
+
async function catalogoVoces(filtro) {
|
|
14
|
+
const now = Date.now();
|
|
15
|
+
if (cacheVoces.data && cacheVoces.filtro === filtro && now - cacheVoces.at < 600000) {
|
|
16
|
+
return cacheVoces.data;
|
|
17
|
+
}
|
|
18
|
+
const voces = await listarVoces(filtro);
|
|
19
|
+
cacheVoces.at = now;
|
|
20
|
+
cacheVoces.data = voces;
|
|
21
|
+
cacheVoces.filtro = filtro;
|
|
22
|
+
return voces;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerTts(route, ctx) {
|
|
26
|
+
route('GET', '/api/tts/settings', async ({ res }) => {
|
|
27
|
+
const config = resolveTts(ctx.app);
|
|
28
|
+
sendJson(res, 200, { ...config, ffmpeg: await ffmpegDisponible() });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
route('PUT', '/api/tts/settings', async ({ req, res }) => {
|
|
32
|
+
const body = await readJson(req);
|
|
33
|
+
const app = readApp();
|
|
34
|
+
const actual = resolveTts(ctx.app);
|
|
35
|
+
const next = { ...actual };
|
|
36
|
+
|
|
37
|
+
if ('enabled' in body) next.enabled = body.enabled === true;
|
|
38
|
+
if ('voice' in body) next.voice = sanearVoz(body.voice, actual.voice);
|
|
39
|
+
if ('rate' in body) next.rate = sanearRate(body.rate, actual.rate);
|
|
40
|
+
if (body.announce && typeof body.announce === 'object') {
|
|
41
|
+
const announce = { ...actual.announce };
|
|
42
|
+
for (const key of ['start', 'tools', 'end']) {
|
|
43
|
+
if (key in body.announce) announce[key] = body.announce[key] === true;
|
|
44
|
+
}
|
|
45
|
+
next.announce = announce;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
app.tts = next;
|
|
49
|
+
writeApp(app);
|
|
50
|
+
ctx.app = app;
|
|
51
|
+
sendJson(res, 200, { ...next, ffmpeg: await ffmpegDisponible() });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
route('GET', '/api/tts/voices', async ({ res, query }) => {
|
|
55
|
+
const filtro = /^[a-zA-Z]{2}-?$/.test(String(query.get('filter') ?? 'es-')) ? query.get('filter') : 'es-';
|
|
56
|
+
try {
|
|
57
|
+
const voces = await catalogoVoces(filtro);
|
|
58
|
+
sendJson(res, 200, { voices: voces });
|
|
59
|
+
} catch (error) {
|
|
60
|
+
sendError(res, 502, `No pude listar las voces: ${error?.message ?? error}`);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
route('GET', '/api/tts', async ({ res, query }) => {
|
|
65
|
+
const config = resolveTts(ctx.app);
|
|
66
|
+
const texto = String(query.get('texto') ?? '').trim().slice(0, MAX_TEXTO);
|
|
67
|
+
if (!texto) return sendError(res, 400, 'Falta "texto".');
|
|
68
|
+
const voz = sanearVoz(query.get('voice'), config.voice);
|
|
69
|
+
const rate = sanearRate(query.get('rate'), config.rate);
|
|
70
|
+
try {
|
|
71
|
+
const mp3 = await sintetizar(texto, { voice: voz, rate });
|
|
72
|
+
res.writeHead(200, {
|
|
73
|
+
'Content-Type': 'audio/mpeg',
|
|
74
|
+
'Content-Length': mp3.length,
|
|
75
|
+
'Cache-Control': 'no-store',
|
|
76
|
+
});
|
|
77
|
+
res.end(mp3);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
sendError(res, 502, `No pude sintetizar: ${error?.message ?? error}`);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -34,10 +34,13 @@ import { registerProviders } from './routes/providers.js';
|
|
|
34
34
|
import { registerMcp } from './routes/mcp.js';
|
|
35
35
|
import { registerSkills } from './routes/skills.js';
|
|
36
36
|
import { registerChannels } from './routes/channels.js';
|
|
37
|
+
import { registerTts } from './routes/tts.js';
|
|
37
38
|
import { registerProcs } from './routes/procs.js';
|
|
38
39
|
import { registerShell } from './routes/shell.js';
|
|
39
40
|
import { registerPreview } from './routes/preview.js';
|
|
40
41
|
import { registerChat } from './routes/chat.js';
|
|
42
|
+
import { registerEvents } from './routes/events.js';
|
|
43
|
+
import { registerState } from './routes/state.js';
|
|
41
44
|
import { registerEval } from './routes/eval.js';
|
|
42
45
|
|
|
43
46
|
const VERSION = readPackageVersion();
|
|
@@ -220,10 +223,13 @@ export async function createTamnoraServer({
|
|
|
220
223
|
registerMcp(route, ctx);
|
|
221
224
|
registerSkills(route, ctx);
|
|
222
225
|
registerChannels(route, ctx);
|
|
226
|
+
registerTts(route, ctx);
|
|
223
227
|
registerProcs(route, ctx);
|
|
224
228
|
registerShell(route, ctx);
|
|
225
229
|
const preview = registerPreview(route, ctx);
|
|
226
230
|
registerChat(route, ctx);
|
|
231
|
+
registerEvents(route, ctx);
|
|
232
|
+
registerState(route, ctx);
|
|
227
233
|
registerEval(route, ctx);
|
|
228
234
|
|
|
229
235
|
function matchRoute(method, pathname) {
|
|
@@ -247,6 +253,10 @@ export async function createTamnoraServer({
|
|
|
247
253
|
|
|
248
254
|
const serveStatic = createStaticHandler(staticDir ?? DEFAULT_PUBLIC_DIR);
|
|
249
255
|
|
|
256
|
+
// Los SSE (turno, stream de sesion, canal global) son conexiones que no
|
|
257
|
+
// terminan solas: sin registrarlas, `server.close()` espera indefinidamente y
|
|
258
|
+
// `tamnora server --stop` queda colgado.
|
|
259
|
+
const sockets = new Set();
|
|
250
260
|
const server = http.createServer(async (req, res) => {
|
|
251
261
|
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
|
|
252
262
|
|
|
@@ -286,6 +296,11 @@ export async function createTamnoraServer({
|
|
|
286
296
|
}
|
|
287
297
|
});
|
|
288
298
|
|
|
299
|
+
server.on('connection', (socket) => {
|
|
300
|
+
sockets.add(socket);
|
|
301
|
+
socket.on('close', () => sockets.delete(socket));
|
|
302
|
+
});
|
|
303
|
+
|
|
289
304
|
const listen = (options = {}) =>
|
|
290
305
|
new Promise((resolve, reject) => {
|
|
291
306
|
server.once('error', reject);
|
|
@@ -315,12 +330,17 @@ export async function createTamnoraServer({
|
|
|
315
330
|
} catch {
|
|
316
331
|
// ignore MCP shutdown errors
|
|
317
332
|
}
|
|
333
|
+
// Cortar las conexiones abiertas ANTES de esperar al server, y la base
|
|
334
|
+
// DESPUES: un run abortado termina en otro tick y emite `aborted`.
|
|
335
|
+
for (const socket of sockets) socket.destroy();
|
|
336
|
+
sockets.clear();
|
|
337
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
338
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
318
339
|
try {
|
|
319
340
|
store.close();
|
|
320
341
|
} catch {
|
|
321
342
|
// already closed
|
|
322
343
|
}
|
|
323
|
-
await new Promise((resolve) => server.close(() => resolve()));
|
|
324
344
|
};
|
|
325
345
|
|
|
326
346
|
return { server, config, store, ctx, listen, close, staticDir: staticDir ?? DEFAULT_PUBLIC_DIR };
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { fraseTool, fraseInicio, fraseFin, fraseError, createFiltroTools } from '../src/lib/announcer.js';
|
|
4
|
+
|
|
5
|
+
test('fraseTool arma verbos con el resumen de la tool', () => {
|
|
6
|
+
assert.equal(fraseTool('read', 'src/app.js'), 'Leyendo src/app.js');
|
|
7
|
+
assert.equal(fraseTool('write', 'a/b.txt'), 'Escribiendo a/b.txt');
|
|
8
|
+
assert.equal(fraseTool('bash', 'npm test'), 'Ejecutando npm test');
|
|
9
|
+
assert.equal(fraseTool('grep', '"foo" *.js'), 'Buscando "foo" *.js');
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('fraseTool sin resumen, tools mudas y MCP', () => {
|
|
13
|
+
assert.equal(fraseTool('read', ''), 'Leyendo');
|
|
14
|
+
assert.equal(fraseTool('desconocida', 'x'), '');
|
|
15
|
+
assert.equal(fraseTool('git_status', ''), 'Mirando el estado de git');
|
|
16
|
+
assert.equal(fraseTool('git_commit', 'feat: algo'), 'Commiteando: feat: algo');
|
|
17
|
+
assert.equal(fraseTool('mcp__db__query', 'x'), 'Usando una herramienta de db');
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('fraseTool recorta frases largas sin cortar palabras', () => {
|
|
21
|
+
const frase = fraseTool('bash', 'x'.repeat(400));
|
|
22
|
+
assert.ok(frase.length <= 200, frase.length);
|
|
23
|
+
assert.ok(frase.endsWith('…'));
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test('fraseFin resume el cierre del turno', () => {
|
|
27
|
+
assert.equal(fraseFin({ stopReason: 'aborted' }), 'Cancelado.');
|
|
28
|
+
assert.match(fraseFin({ stopReason: 'end_turn', rounds: 3, toolCalls: 5, durationMs: 12000 }), /^Listo\. 3 rondas, 5 herramientas, 12 segundos\.$/);
|
|
29
|
+
assert.equal(fraseFin({ stopReason: 'end_turn', rounds: 1, toolCalls: 0, durationMs: 500 }), 'Listo. Respondí sin usar herramientas.');
|
|
30
|
+
assert.match(
|
|
31
|
+
fraseFin({ stopReason: 'max_rounds', rounds: 8, toolCalls: 12, durationMs: 60000 }),
|
|
32
|
+
/corte de seguridad tras 8 rondas, 60 segundos/
|
|
33
|
+
);
|
|
34
|
+
assert.match(fraseInicio(), /Empecé/);
|
|
35
|
+
assert.match(fraseError(), /error/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test('createFiltroTools deduplica y aplica la ventana entre frases', () => {
|
|
39
|
+
const filtro = createFiltroTools({ minGapMs: 60000 });
|
|
40
|
+
assert.equal(filtro('read', 'a.js'), 'Leyendo a.js');
|
|
41
|
+
assert.equal(filtro('read', 'b.js'), '');
|
|
42
|
+
assert.equal(filtro('write', 'b.js'), '');
|
|
43
|
+
const ahora = Date.now();
|
|
44
|
+
const original = Date.now;
|
|
45
|
+
Date.now = () => ahora + 61000;
|
|
46
|
+
try {
|
|
47
|
+
assert.equal(filtro('write', 'b.js'), 'Escribiendo b.js');
|
|
48
|
+
} finally {
|
|
49
|
+
Date.now = original;
|
|
50
|
+
}
|
|
51
|
+
});
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { diffState, pollDelayMs, localState, summarizeArgs, humanSize } from '../public/reconcile.js';
|
|
4
|
+
|
|
5
|
+
const pane = (over = {}) => ({ working: false, attached: false, ...over });
|
|
6
|
+
const ask = (over = {}) => ({ askId: 'a1', sessionId: 's1', name: 'bash', args: {}, requestedAt: 1, ...over });
|
|
7
|
+
|
|
8
|
+
test('engancha una sesion abierta que esta trabajando sin stream propio', () => {
|
|
9
|
+
const diff = diffState({ panes: [{ id: 's1', working: false, attached: false }], asks: [] }, {
|
|
10
|
+
sessions: [{ id: 's1', running: true, awaiting: 0 }],
|
|
11
|
+
permissions: [],
|
|
12
|
+
});
|
|
13
|
+
assert.deepEqual(diff.toAttach, ['s1']);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test('engancha aunque no haya run si hay un permiso pendiente', () => {
|
|
17
|
+
// El permiso no se reconstruye desde los mensajes: vive en /api/state.
|
|
18
|
+
const diff = diffState({ panes: [{ id: 's1', working: false, attached: false }], asks: [] }, {
|
|
19
|
+
sessions: [{ id: 's1', running: false, awaiting: 1 }],
|
|
20
|
+
permissions: [ask()],
|
|
21
|
+
});
|
|
22
|
+
assert.deepEqual(diff.toAttach, ['s1']);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('desengancha una sesion que figuraba activa y ya no', () => {
|
|
26
|
+
const diff = diffState({ panes: [{ id: 's1', working: true, attached: true }], asks: [] }, {
|
|
27
|
+
sessions: [{ id: 's1', running: false, awaiting: 0 }],
|
|
28
|
+
permissions: [],
|
|
29
|
+
});
|
|
30
|
+
assert.deepEqual(diff.toDetach, ['s1']);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('no toca una sesion que ya esta enganchada', () => {
|
|
34
|
+
const diff = diffState({ panes: [{ id: 's1', working: true, attached: true }], asks: [] }, {
|
|
35
|
+
sessions: [{ id: 's1', running: true, awaiting: 0 }],
|
|
36
|
+
permissions: [],
|
|
37
|
+
});
|
|
38
|
+
assert.deepEqual(diff.toAttach, []);
|
|
39
|
+
assert.deepEqual(diff.toDetach, []);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('detecta permisos nuevos y resueltos', () => {
|
|
43
|
+
const diff = diffState({ panes: [], asks: ['a1', 'a2'] }, {
|
|
44
|
+
sessions: [],
|
|
45
|
+
permissions: [ask({ askId: 'a1' }), ask({ askId: 'a3' })],
|
|
46
|
+
});
|
|
47
|
+
assert.deepEqual(diff.newAsks.map((a) => a.askId), ['a3']);
|
|
48
|
+
assert.deepEqual(diff.resolvedAsks, ['a2']);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test('un permiso repetido no vuelve a notificarse', () => {
|
|
52
|
+
const state = { panes: [], asks: [] };
|
|
53
|
+
const remote = { sessions: [], permissions: [ask()] };
|
|
54
|
+
const first = diffState(state, remote);
|
|
55
|
+
assert.equal(first.newAsks.length, 1);
|
|
56
|
+
const second = diffState({ panes: [], asks: ['a1'] }, remote);
|
|
57
|
+
assert.equal(second.newAsks.length, 0);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('noPropone ninguna accion cuando nada cambio', () => {
|
|
61
|
+
// Esto importa: un re-render por nada borra la tarjeta de permiso.
|
|
62
|
+
const local = { panes: [{ id: 's1', working: true, attached: true }], asks: ['a1'] };
|
|
63
|
+
const remote = { sessions: [{ id: 's1', running: true, awaiting: 1 }], permissions: [ask()] };
|
|
64
|
+
const diff = diffState(local, remote);
|
|
65
|
+
assert.deepEqual(diff.toAttach, []);
|
|
66
|
+
assert.deepEqual(diff.toDetach, []);
|
|
67
|
+
assert.deepEqual(diff.gone, []);
|
|
68
|
+
assert.deepEqual(diff.newAsks, []);
|
|
69
|
+
assert.deepEqual(diff.resolvedAsks, []);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('avisa las sesiones que el server ya no conoce', () => {
|
|
73
|
+
const diff = diffState({ panes: [{ id: 'borrada', working: false, attached: false }], asks: [] }, {
|
|
74
|
+
sessions: [{ id: 's1', running: false, awaiting: 0 }],
|
|
75
|
+
permissions: [],
|
|
76
|
+
});
|
|
77
|
+
assert.deepEqual(diff.gone, ['borrada']);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
test('ignora las sesiones que el usuario no tiene abiertas', () => {
|
|
81
|
+
const diff = diffState({ panes: [], asks: [] }, {
|
|
82
|
+
sessions: [{ id: 's1', running: true, awaiting: 0 }],
|
|
83
|
+
permissions: [],
|
|
84
|
+
});
|
|
85
|
+
assert.deepEqual(diff.toAttach, []);
|
|
86
|
+
assert.deepEqual(diff.remoteActive, ['s1']);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('la cadencia se apura cuando algo espera tu decision', () => {
|
|
90
|
+
const idle = { sessions: [{ running: false, awaiting: 0 }], permissions: [] };
|
|
91
|
+
const running = { sessions: [{ running: true, awaiting: 0 }], permissions: [] };
|
|
92
|
+
const waiting = { sessions: [{ running: false, awaiting: 1 }], permissions: [ask()] };
|
|
93
|
+
assert.equal(pollDelayMs(idle), 25000);
|
|
94
|
+
assert.equal(pollDelayMs(running), 10000);
|
|
95
|
+
assert.equal(pollDelayMs(waiting), 5000);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test('localState aplana los Map del cliente', () => {
|
|
99
|
+
const flat = localState({ panes: new Map([['s1', pane({ working: true, attached: true })]]), asks: new Set(['a1']) });
|
|
100
|
+
assert.deepEqual(flat, { panes: [{ id: 's1', working: true, attached: true }], asks: ['a1'] });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('tolera un remoto vacio o corrupto sin romper', () => {
|
|
104
|
+
const diff = diffState({ panes: [{ id: 's1', working: false, attached: false }], asks: [] }, {});
|
|
105
|
+
assert.deepEqual(diff.toAttach, []);
|
|
106
|
+
assert.deepEqual(diff.pending, []);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('el resumen de un permiso muestra el comando, no el JSON', () => {
|
|
110
|
+
const command = 'powershell -NoProfile -Command "Get-ChildItem -Recurse"';
|
|
111
|
+
assert.equal(summarizeArgs('bash', { command }), command);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('el resumen de write muestra ruta y tamano, no el archivo entero', () => {
|
|
115
|
+
// Esto era medio archivo en pantalla con los \n escapados a la vista.
|
|
116
|
+
const summary = summarizeArgs('write', { path: 'AGENTS.md', content: 'x'.repeat(1840) });
|
|
117
|
+
assert.equal(summary, 'AGENTS.md · 1.8 kB');
|
|
118
|
+
assert.ok(!summary.includes('\\n'));
|
|
119
|
+
assert.ok(summary.length < 60);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('el resumen de read y fetch_url muestra la ruta y la url', () => {
|
|
123
|
+
assert.equal(summarizeArgs('read', { path: 'src/app.js' }), 'src/app.js');
|
|
124
|
+
assert.equal(summarizeArgs('fetch_url', { url: 'https://x.dev/a' }), 'https://x.dev/a');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test('el resumen de task usa la descripcion', () => {
|
|
128
|
+
assert.equal(summarizeArgs('task', { description: 'auditar el repo', prompt: 'busca TODOs' }), 'auditar el repo');
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test('una tool desconocida cae a JSON en una sola linea', () => {
|
|
132
|
+
const summary = summarizeArgs('git_diff', { path: 'a.js', staged: false, note: 'uno\ndos' });
|
|
133
|
+
assert.ok(!summary.includes('\\n'), 'no debe mostrar saltos escapados');
|
|
134
|
+
assert.match(summary, /git_diff|a\.js/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('sin argumentos el resumen queda vacio', () => {
|
|
138
|
+
assert.equal(summarizeArgs('git_status', {}), '');
|
|
139
|
+
assert.equal(summarizeArgs('git_status', null), '');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('humanSize usa la unidad que corresponde', () => {
|
|
143
|
+
assert.equal(humanSize('hola'), '4 B');
|
|
144
|
+
assert.equal(humanSize('x'.repeat(2048)), '2.0 kB');
|
|
145
|
+
assert.equal(humanSize('x'.repeat(3 * 1024 * 1024)), '3.0 MB');
|
|
146
|
+
});
|