@danieltmn/tamnora 0.24.2 → 0.25.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 +20 -0
- package/harness/packages/cli/src/commands/repl.js +18 -2
- package/harness/packages/cli/src/commands/run.js +5 -1
- package/harness/packages/cli/src/commands/tui.js +5 -1
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +22 -2
- package/harness/packages/cli/src/tui/layout.js +1 -1
- package/harness/packages/core/src/agent/runAgent.js +5 -2
- package/harness/packages/core/src/agents/index.js +141 -0
- package/harness/packages/core/src/config/index.js +1 -0
- package/harness/packages/core/src/index.js +10 -0
- package/harness/packages/core/src/providers/index.js +3 -1
- package/harness/packages/core/src/providers/ollama.js +4 -1
- package/harness/packages/core/src/providers/openaiCompatible.js +6 -1
- package/harness/packages/core/src/providers/openrouter.js +5 -1
- package/harness/packages/core/src/providers/presets.js +4 -3
- package/harness/packages/core/src/providers/reasoning.js +58 -0
- package/harness/packages/core/test/agents.test.js +111 -0
- package/harness/packages/core/test/reasoning.test.js +43 -0
- package/harness/packages/server/public/app.js +868 -834
- package/harness/packages/server/public/appearance.js +4 -0
- package/harness/packages/server/public/boot.js +49 -0
- package/harness/packages/server/public/chat.js +1 -0
- package/harness/packages/server/public/chrome.js +946 -852
- package/harness/packages/server/public/commands.js +448 -405
- package/harness/packages/server/public/core.js +229 -216
- package/harness/packages/server/public/favicon.ico +0 -0
- package/harness/packages/server/public/icons/icon-128x128.png +0 -0
- package/harness/packages/server/public/icons/icon-144x144.png +0 -0
- package/harness/packages/server/public/icons/icon-152x152.png +0 -0
- package/harness/packages/server/public/icons/icon-192x192.png +0 -0
- package/harness/packages/server/public/icons/icon-384x384.png +0 -0
- package/harness/packages/server/public/icons/icon-512x512.png +0 -0
- package/harness/packages/server/public/icons/icon-72x72.png +0 -0
- package/harness/packages/server/public/icons/icon-96x96.png +0 -0
- package/harness/packages/server/public/icons/logo.png +0 -0
- package/harness/packages/server/public/icons/logo_dark.png +0 -0
- package/harness/packages/server/public/icons/logo_light.png +0 -0
- package/harness/packages/server/public/icons/tamnora.ico +0 -0
- package/harness/packages/server/public/icons (1).zip +0 -0
- package/harness/packages/server/public/index.html +161 -101
- package/harness/packages/server/public/landing/boot.js +16 -0
- package/harness/packages/server/public/landing/index.html +123 -59
- package/harness/packages/server/public/landing/landing.js +11 -9
- package/harness/packages/server/public/logo.png +0 -0
- package/harness/packages/server/public/markdown.js +1 -1
- package/harness/packages/server/public/sessions.js +1 -0
- package/harness/packages/server/public/settings.js +1322 -1080
- package/harness/packages/server/public/settingsNav.js +82 -0
- package/harness/packages/server/public/styles.css +2807 -2525
- package/harness/packages/server/public/system.js +2 -2
- package/harness/packages/server/src/lib/agents.js +74 -0
- package/harness/packages/server/src/lib/appearance.js +4 -0
- package/harness/packages/server/src/lib/channels/telegram/run.js +1 -0
- package/harness/packages/server/src/lib/prompt.js +11 -3
- package/harness/packages/server/src/lib/restart.js +20 -0
- package/harness/packages/server/src/lib/scheduler.js +4 -0
- package/harness/packages/server/src/lib/securityHeaders.js +53 -0
- package/harness/packages/server/src/lib/sessionView.js +1 -0
- package/harness/packages/server/src/lib/skills.js +13 -0
- package/harness/packages/server/src/lib/tasks.js +71 -23
- package/harness/packages/server/src/routes/agents.js +88 -0
- package/harness/packages/server/src/routes/chat.js +9 -1
- package/harness/packages/server/src/routes/providers.js +7 -0
- package/harness/packages/server/src/routes/reload.js +46 -0
- package/harness/packages/server/src/routes/sessions.js +3 -1
- package/harness/packages/server/src/server.js +13 -1
- package/harness/packages/server/test/accessibility.test.js +72 -0
- package/harness/packages/server/test/agents.test.js +194 -0
- package/harness/packages/server/test/appearance.test.js +4 -0
- package/harness/packages/server/test/consoleModules.test.js +133 -57
- package/harness/packages/server/test/consoleTabs.test.js +228 -0
- package/harness/packages/server/test/docsSync.test.js +73 -0
- package/harness/packages/server/test/landingLogin.test.js +89 -0
- package/harness/packages/server/test/markdownSecurity.test.js +80 -0
- package/harness/packages/server/test/reload.test.js +95 -0
- package/harness/packages/server/test/securityHeaders.test.js +109 -0
- package/harness/packages/server/test/settingsNav.test.js +90 -0
- package/harness/packages/server/test/themeCss.js +110 -0
- package/harness/packages/server/test/themes.test.js +72 -0
- package/harness/scripts/restart.mjs +101 -0
- package/harness/tamnora.example.json +1 -0
- package/package.json +1 -1
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Recargas operativas desde la consola: reiniciar el server y (re)abrir el
|
|
2
|
+
// puente remoto (tailscale). Son privilegiadas, igual que update/shell/git:
|
|
3
|
+
// solo conexion local. En ambos casos la respuesta vuelve rapido y la accion
|
|
4
|
+
// corre aparte (script detached / `tailscale serve --bg`).
|
|
5
|
+
import { sendJson, sendError } from '../lib/http.js';
|
|
6
|
+
import { requireLocal } from '../lib/workspace.js';
|
|
7
|
+
import { spawnRestart } from '../lib/restart.js';
|
|
8
|
+
import { startTailscaleServe, stopTailscaleServe } from '../lib/core.js';
|
|
9
|
+
|
|
10
|
+
export function registerReload(route, ctx) {
|
|
11
|
+
route('POST', '/api/server/restart', ({ req, res }) => {
|
|
12
|
+
if (!requireLocal(req, res, 'Reiniciar el servidor solo esta permitido en la conexion local.')) return;
|
|
13
|
+
|
|
14
|
+
const spawnRestarter = ctx.spawnRestarter ?? spawnRestart;
|
|
15
|
+
const started = spawnRestarter({
|
|
16
|
+
pid: process.pid,
|
|
17
|
+
argv: process.argv.slice(1),
|
|
18
|
+
});
|
|
19
|
+
if (!started.ok) return sendError(res, 500, started.error);
|
|
20
|
+
|
|
21
|
+
sendJson(res, 202, { ok: true, restarting: true });
|
|
22
|
+
// Soltar el puerto antes de que el server nuevo intente entrar.
|
|
23
|
+
setTimeout(() => {
|
|
24
|
+
Promise.resolve(ctx.shutdown?.()).catch(() => process.exit(0));
|
|
25
|
+
}, 800);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
route('POST', '/api/bridge/reload', async ({ req, res }) => {
|
|
29
|
+
if (!requireLocal(req, res, 'Abrir el puente solo esta permitido en la conexion local.')) return;
|
|
30
|
+
|
|
31
|
+
const port = ctx.serverInfo?.port;
|
|
32
|
+
if (!port) return sendError(res, 500, 'No se en que puerto corre el server.');
|
|
33
|
+
|
|
34
|
+
// Reinicio puntual: apagar el handler de la consola (443) y volver a
|
|
35
|
+
// publicarlo. Un `serve reset` borraria tambien el preview en 8443.
|
|
36
|
+
const stop = ctx.tunnelStop ?? stopTailscaleServe;
|
|
37
|
+
const start = ctx.tunnelStart ?? startTailscaleServe;
|
|
38
|
+
try {
|
|
39
|
+
await stop().catch(() => {});
|
|
40
|
+
const tun = await start(port);
|
|
41
|
+
sendJson(res, 200, { ok: true, url: tun.url, provider: tun.provider ?? 'tailscale' });
|
|
42
|
+
} catch (error) {
|
|
43
|
+
sendError(res, 502, String(error?.message ?? error));
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readJson, sendJson, sendError } from '../lib/http.js';
|
|
2
|
-
import { resolveMode } from '../lib/core.js';
|
|
2
|
+
import { resolveMode, normalizeEffort } from '../lib/core.js';
|
|
3
3
|
import { workspaceForRequest } from '../lib/workspace.js';
|
|
4
4
|
import { sessionView, sessionState, awaitingBySession } from '../lib/sessionView.js';
|
|
5
5
|
import { summarizeSession, compactSession, SUMMARY_INSTRUCTION } from '../lib/compact.js';
|
|
@@ -49,6 +49,7 @@ export function registerSessions(route, ctx) {
|
|
|
49
49
|
const meta = {};
|
|
50
50
|
if (body.goal) meta.goal = String(body.goal);
|
|
51
51
|
if (body.mode) meta.mode = resolveMode(String(body.mode)).id;
|
|
52
|
+
if ('effort' in body) meta.effort = normalizeEffort(body.effort);
|
|
52
53
|
const id = ctx.store.createSession({
|
|
53
54
|
title: String(body.title || 'Nueva sesión').slice(0, 80),
|
|
54
55
|
provider: body.provider || ctx.effectiveProviderId(),
|
|
@@ -105,6 +106,7 @@ export function registerSessions(route, ctx) {
|
|
|
105
106
|
const meta = {};
|
|
106
107
|
if ('goal' in body) meta.goal = body.goal ? String(body.goal) : undefined;
|
|
107
108
|
if ('mode' in body) meta.mode = resolveMode(String(body.mode)).id;
|
|
109
|
+
if ('effort' in body) meta.effort = normalizeEffort(body.effort);
|
|
108
110
|
if (Object.keys(meta).length) patch.meta = meta;
|
|
109
111
|
ctx.store.touchSession(params.id, patch);
|
|
110
112
|
sendJson(res, 200, { session: sessionView(ctx.store.getSession(params.id)) });
|
|
@@ -17,12 +17,15 @@ import {
|
|
|
17
17
|
MCP_AUTO_THRESHOLD,
|
|
18
18
|
readPackageVersion,
|
|
19
19
|
scopeDirective,
|
|
20
|
+
normalizeEffort,
|
|
20
21
|
} from './lib/core.js';
|
|
21
22
|
import { sendJson, sendError } from './lib/http.js';
|
|
22
23
|
import { requireTrustedHost, requireSameOrigin } from './lib/workspace.js';
|
|
24
|
+
import { applySecurityHeaders, cspMode } from './lib/securityHeaders.js';
|
|
23
25
|
import { createPromptBuilder, DEFAULT_SYSTEM_PROMPT } from './lib/prompt.js';
|
|
24
26
|
import { profilePrompt, resolveProfile } from './lib/profile.js';
|
|
25
27
|
import { skillsBlock } from './lib/skills.js';
|
|
28
|
+
import { agentsIndexPrompt } from './lib/agents.js';
|
|
26
29
|
import { createApprovalBroker } from './lib/approvals.js';
|
|
27
30
|
import { createQuestionBroker } from './lib/questions.js';
|
|
28
31
|
import { createPresence } from './lib/presence.js';
|
|
@@ -41,12 +44,14 @@ import { registerModels } from './routes/models.js';
|
|
|
41
44
|
import { registerProviders } from './routes/providers.js';
|
|
42
45
|
import { registerMcp } from './routes/mcp.js';
|
|
43
46
|
import { registerSkills } from './routes/skills.js';
|
|
47
|
+
import { registerAgents } from './routes/agents.js';
|
|
44
48
|
import { registerMemory } from './routes/memory.js';
|
|
45
49
|
import { registerChannels } from './routes/channels.js';
|
|
46
50
|
import { registerProfile } from './routes/profile.js';
|
|
47
51
|
import { registerTts } from './routes/tts.js';
|
|
48
52
|
import { registerTranscribe } from './routes/transcribe.js';
|
|
49
53
|
import { registerUpdate } from './routes/update.js';
|
|
54
|
+
import { registerReload } from './routes/reload.js';
|
|
50
55
|
import { registerAdvisor } from './routes/advisor.js';
|
|
51
56
|
import { registerProcs } from './routes/procs.js';
|
|
52
57
|
import { registerShell } from './routes/shell.js';
|
|
@@ -181,6 +186,7 @@ export async function createTamnoraServer({
|
|
|
181
186
|
const effectiveMaxDurationMs = () => settingNumber('maxDurationMs', config.maxDurationMs);
|
|
182
187
|
const effectiveMaxOutputTokens = () => settingNumber('maxOutputTokens', config.maxOutputTokens);
|
|
183
188
|
const effectiveStrictScope = () => store.getSetting('strictScope') === true;
|
|
189
|
+
const effectiveEffort = () => normalizeEffort(store.getSetting('activeEffort') ?? config.effort);
|
|
184
190
|
const effectivePromptCaching = () => {
|
|
185
191
|
const stored = store.getSetting('promptCaching');
|
|
186
192
|
if (stored === null || stored === undefined) return config.promptCaching === true;
|
|
@@ -226,6 +232,7 @@ export async function createTamnoraServer({
|
|
|
226
232
|
effectiveMaxDurationMs,
|
|
227
233
|
effectiveMaxOutputTokens,
|
|
228
234
|
effectiveStrictScope,
|
|
235
|
+
effectiveEffort,
|
|
229
236
|
effectivePromptCaching,
|
|
230
237
|
effectiveMcpMode,
|
|
231
238
|
effectiveMcpPinned,
|
|
@@ -267,6 +274,7 @@ export async function createTamnoraServer({
|
|
|
267
274
|
files: config.instructions,
|
|
268
275
|
profileText: () => profilePrompt(resolveProfile(ctx.app)),
|
|
269
276
|
skillsText: () => skillsBlock(ctx),
|
|
277
|
+
agentsText: () => agentsIndexPrompt(ctx),
|
|
270
278
|
mcpText: () => ctx.mcpText(),
|
|
271
279
|
scopeText: () => scopeDirective(ctx.effectiveStrictScope()),
|
|
272
280
|
ruleFiles: config.rules ?? [],
|
|
@@ -324,6 +332,7 @@ export async function createTamnoraServer({
|
|
|
324
332
|
registerProviders(route, ctx);
|
|
325
333
|
registerMcp(route, ctx);
|
|
326
334
|
registerSkills(route, ctx);
|
|
335
|
+
registerAgents(route, ctx);
|
|
327
336
|
registerMemory(route, ctx);
|
|
328
337
|
registerChannels(route, ctx);
|
|
329
338
|
registerProfile(route, ctx);
|
|
@@ -331,6 +340,7 @@ export async function createTamnoraServer({
|
|
|
331
340
|
registerTranscribe(route, ctx);
|
|
332
341
|
registerAdvisor(route, ctx);
|
|
333
342
|
registerUpdate(route, ctx);
|
|
343
|
+
registerReload(route, ctx);
|
|
334
344
|
registerProcs(route, ctx);
|
|
335
345
|
registerShell(route, ctx);
|
|
336
346
|
const preview = registerPreview(route, ctx);
|
|
@@ -376,8 +386,10 @@ export async function createTamnoraServer({
|
|
|
376
386
|
const sockets = new Set();
|
|
377
387
|
const server = http.createServer(async (req, res) => {
|
|
378
388
|
const url = new URL(req.url, `http://${req.headers.host ?? 'localhost'}`);
|
|
389
|
+
const isApi = url.pathname.startsWith('/api/');
|
|
390
|
+
applySecurityHeaders(res, { api: isApi, csp: cspMode(config) });
|
|
379
391
|
|
|
380
|
-
if (
|
|
392
|
+
if (isApi) {
|
|
381
393
|
const matched = matchRoute(req.method, url.pathname);
|
|
382
394
|
if (!matched) return sendError(res, 404, `Ruta no encontrada: ${req.method} ${url.pathname}`);
|
|
383
395
|
// Guardas de request SIEMPRE activas (con o sin auth): DNS-rebinding y
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// Accesibilidad de la consola que se verifica sobre el texto de los archivos.
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import assert from 'node:assert/strict';
|
|
4
|
+
import { readdirSync, readFileSync } from 'node:fs';
|
|
5
|
+
import { readStyles } from './themeCss.js';
|
|
6
|
+
|
|
7
|
+
const PUBLIC = new URL('../public/', import.meta.url);
|
|
8
|
+
const css = readStyles();
|
|
9
|
+
|
|
10
|
+
/** Contenido del bloque `@media (...)` que empieza en `inicio`. */
|
|
11
|
+
function bloqueMedia(inicio) {
|
|
12
|
+
const abre = css.indexOf('{', inicio);
|
|
13
|
+
let prof = 0;
|
|
14
|
+
for (let i = abre; i < css.length; i += 1) {
|
|
15
|
+
if (css[i] === '{') prof += 1;
|
|
16
|
+
else if (css[i] === '}') {
|
|
17
|
+
prof -= 1;
|
|
18
|
+
if (prof === 0) return css.slice(abre + 1, i);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
throw new Error('bloque sin cerrar');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
test('prefers-reduced-motion apaga transiciones y los pulsos decorativos', () => {
|
|
25
|
+
const inicio = css.indexOf('@media (prefers-reduced-motion: reduce)');
|
|
26
|
+
assert.ok(inicio >= 0, 'falta el bloque de movimiento reducido');
|
|
27
|
+
const bloque = bloqueMedia(inicio);
|
|
28
|
+
assert.match(bloque, /transition-duration:\s*0\.01ms/);
|
|
29
|
+
assert.match(bloque, /scroll-behavior:\s*auto/);
|
|
30
|
+
assert.match(bloque, /animation:\s*none/);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('toda animacion infinita que no es un indicador de progreso se apaga con movimiento reducido', () => {
|
|
34
|
+
// Quedan fuera los loaders y el spinner: indican que el agente esta trabajando.
|
|
35
|
+
const PROGRESO = new Set(['loader-thinking', 'loader-working', 'spin']);
|
|
36
|
+
const bloque = bloqueMedia(css.indexOf('@media (prefers-reduced-motion: reduce)'));
|
|
37
|
+
|
|
38
|
+
const reglas = [...css.matchAll(/([^{}]+)\{([^{}]*animation:\s*([a-z-]+)[^;}]*infinite[^;}]*;?[^{}]*)\}/g)];
|
|
39
|
+
assert.ok(reglas.length >= 5, 'no se leyeron las animaciones');
|
|
40
|
+
for (const [, selectores, , nombre] of reglas) {
|
|
41
|
+
if (PROGRESO.has(nombre)) continue;
|
|
42
|
+
for (const selector of selectores.split(',').map((s) => s.trim()).filter(Boolean)) {
|
|
43
|
+
const limpio = selector.split('\n').pop().trim();
|
|
44
|
+
assert.ok(bloque.includes(limpio), `"${limpio}" anima (${nombre}) y no se apaga con movimiento reducido`);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('el scroll animado de JS respeta el movimiento reducido', () => {
|
|
50
|
+
for (const archivo of readdirSync(PUBLIC).filter((f) => f.endsWith('.js'))) {
|
|
51
|
+
const src = readFileSync(new URL(archivo, PUBLIC), 'utf8');
|
|
52
|
+
assert.ok(!/behavior:\s*['"]smooth['"]/.test(src), `${archivo} fuerza scroll animado: usar scrollBehavior()`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('en pantallas tactiles los campos van a 16px (iOS hace zoom con menos)', () => {
|
|
57
|
+
const inicio = css.indexOf('@media (pointer: coarse), (max-width: 480px)');
|
|
58
|
+
assert.ok(inicio >= 0, 'falta el bloque tactil');
|
|
59
|
+
const bloque = bloqueMedia(inicio);
|
|
60
|
+
assert.match(bloque, /select,[\s\S]*font-size:\s*16px/);
|
|
61
|
+
assert.match(bloque, /min-height:\s*40px/);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('el foco de teclado es visible en botones, enlaces y elementos enfocables', () => {
|
|
65
|
+
assert.match(css, /button:focus-visible, a:focus-visible, summary:focus-visible, \[tabindex\]:focus-visible \{\s*outline: 2px solid var\(--accent\)/);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('las regiones que cambian solas se anuncian a lectores de pantalla', () => {
|
|
69
|
+
const html = readFileSync(new URL('index.html', PUBLIC), 'utf8');
|
|
70
|
+
assert.ok(/id="toasts"[^>]*aria-live="polite"/.test(html), 'los avisos (toasts) deberian ser aria-live');
|
|
71
|
+
assert.ok(/id="sb-activity"[^>]*(role="status"|aria-live)/.test(html), 'la actividad del agente deberia ser un status');
|
|
72
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtemp } from 'node:fs/promises';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import { startServer } from './helpers.js';
|
|
7
|
+
|
|
8
|
+
const json = { 'Content-Type': 'application/json' };
|
|
9
|
+
|
|
10
|
+
test('manages subagents from the console (CRUD + toggle)', async () => {
|
|
11
|
+
const { instance, base } = await startServer();
|
|
12
|
+
try {
|
|
13
|
+
let data = await (await fetch(`${base}/api/agents`)).json();
|
|
14
|
+
assert.deepEqual(data.agents, []);
|
|
15
|
+
|
|
16
|
+
let res = await fetch(`${base}/api/agents/revisor`, {
|
|
17
|
+
method: 'PUT',
|
|
18
|
+
headers: json,
|
|
19
|
+
body: JSON.stringify({
|
|
20
|
+
name: 'Revisor',
|
|
21
|
+
description: 'Busca bugs',
|
|
22
|
+
provider: 'openai',
|
|
23
|
+
model: 'gpt',
|
|
24
|
+
mode: 'plan',
|
|
25
|
+
effort: 'high',
|
|
26
|
+
maxRounds: 4,
|
|
27
|
+
tools: ['read', 'grep'],
|
|
28
|
+
skills: ['code-review'],
|
|
29
|
+
body: '# Rol\nMirá el diff.',
|
|
30
|
+
}),
|
|
31
|
+
});
|
|
32
|
+
assert.equal(res.status, 200);
|
|
33
|
+
data = await res.json();
|
|
34
|
+
const agent = data.agents.find((item) => item.id === 'revisor');
|
|
35
|
+
assert.equal(agent.name, 'Revisor');
|
|
36
|
+
assert.equal(agent.effort, 'high');
|
|
37
|
+
assert.equal(agent.mode, 'plan');
|
|
38
|
+
assert.equal(agent.maxRounds, 4);
|
|
39
|
+
assert.deepEqual(agent.tools, ['read', 'grep']);
|
|
40
|
+
assert.deepEqual(agent.skills, ['code-review']);
|
|
41
|
+
assert.equal(agent.enabled, true);
|
|
42
|
+
|
|
43
|
+
const detail = await (await fetch(`${base}/api/agents/revisor`)).json();
|
|
44
|
+
assert.match(detail.agent.body, /Mirá el diff/);
|
|
45
|
+
|
|
46
|
+
res = await fetch(`${base}/api/agents/revisor/toggle`, { method: 'POST' });
|
|
47
|
+
data = await res.json();
|
|
48
|
+
assert.equal(data.enabled, false);
|
|
49
|
+
data = await (await fetch(`${base}/api/agents`)).json();
|
|
50
|
+
assert.equal(data.agents[0].enabled, false);
|
|
51
|
+
|
|
52
|
+
res = await fetch(`${base}/api/agents/revisor/toggle`, { method: 'POST' });
|
|
53
|
+
assert.equal((await res.json()).enabled, true);
|
|
54
|
+
|
|
55
|
+
res = await fetch(`${base}/api/agents/revisor`, { method: 'DELETE' });
|
|
56
|
+
assert.equal(res.status, 200);
|
|
57
|
+
data = await res.json();
|
|
58
|
+
assert.deepEqual(data.agents, []);
|
|
59
|
+
} finally {
|
|
60
|
+
await instance.close();
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test('rejects invalid subagent payloads', async () => {
|
|
65
|
+
const { instance, base } = await startServer();
|
|
66
|
+
try {
|
|
67
|
+
let res = await fetch(`${base}/api/agents/${encodeURIComponent('../escape')}`, {
|
|
68
|
+
method: 'PUT',
|
|
69
|
+
headers: json,
|
|
70
|
+
body: JSON.stringify({ body: 'x' }),
|
|
71
|
+
});
|
|
72
|
+
assert.equal(res.status, 400);
|
|
73
|
+
|
|
74
|
+
res = await fetch(`${base}/api/agents/vacio`, {
|
|
75
|
+
method: 'PUT',
|
|
76
|
+
headers: json,
|
|
77
|
+
body: JSON.stringify({ mode: 'plan' }),
|
|
78
|
+
});
|
|
79
|
+
assert.equal(res.status, 400);
|
|
80
|
+
assert.match((await res.json()).error, /cuerpo o una descripción/);
|
|
81
|
+
} finally {
|
|
82
|
+
await instance.close();
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('effort persists globally and per session', async () => {
|
|
87
|
+
const { instance, base } = await startServer();
|
|
88
|
+
try {
|
|
89
|
+
let data = await (await fetch(`${base}/api/settings`)).json();
|
|
90
|
+
assert.equal(data.settings.effort, '');
|
|
91
|
+
|
|
92
|
+
let res = await fetch(`${base}/api/settings`, { method: 'PUT', headers: json, body: JSON.stringify({ effort: 'max' }) });
|
|
93
|
+
data = await res.json();
|
|
94
|
+
assert.equal(data.settings.effort, 'max');
|
|
95
|
+
|
|
96
|
+
res = await fetch(`${base}/api/settings`, { method: 'PUT', headers: json, body: JSON.stringify({ effort: 'auto' }) });
|
|
97
|
+
assert.equal((await res.json()).settings.effort, '');
|
|
98
|
+
|
|
99
|
+
res = await fetch(`${base}/api/sessions`, {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: json,
|
|
102
|
+
body: JSON.stringify({ title: 'con esfuerzo', model: 'm', effort: 'low' }),
|
|
103
|
+
});
|
|
104
|
+
const created = await res.json();
|
|
105
|
+
assert.equal(created.session.effort, 'low');
|
|
106
|
+
|
|
107
|
+
res = await fetch(`${base}/api/sessions/${created.session.id}`, {
|
|
108
|
+
method: 'PUT',
|
|
109
|
+
headers: json,
|
|
110
|
+
body: JSON.stringify({ effort: 'high' }),
|
|
111
|
+
});
|
|
112
|
+
assert.equal((await res.json()).session.effort, 'high');
|
|
113
|
+
} finally {
|
|
114
|
+
await instance.close();
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('a named subagent runs with its own model, effort and instructions', async () => {
|
|
119
|
+
const cwd = await mkdtemp(path.join(tmpdir(), 'tamnora-subagent-'));
|
|
120
|
+
const calls = { parent: 0 };
|
|
121
|
+
const sub = {};
|
|
122
|
+
const parent = {
|
|
123
|
+
id: 'parent',
|
|
124
|
+
async streamChat() {
|
|
125
|
+
calls.parent += 1;
|
|
126
|
+
if (calls.parent === 1) {
|
|
127
|
+
return {
|
|
128
|
+
content: '',
|
|
129
|
+
reasoning: '',
|
|
130
|
+
toolCalls: [
|
|
131
|
+
{
|
|
132
|
+
id: 't1',
|
|
133
|
+
type: 'function',
|
|
134
|
+
function: {
|
|
135
|
+
name: 'task',
|
|
136
|
+
arguments: JSON.stringify({ agent: 'revisor', description: 'revisión', prompt: 'revisá el cambio' }),
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
return { content: 'listo', reasoning: '', toolCalls: [] };
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
const revisor = {
|
|
146
|
+
id: 'revisor',
|
|
147
|
+
async streamChat(_config, { model, messages, reasoningEffort }) {
|
|
148
|
+
Object.assign(sub, { model, reasoningEffort, system: messages?.[0]?.content ?? '' });
|
|
149
|
+
return { content: 'resumen del revisor', reasoning: '', toolCalls: [] };
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
const providers = { parent, revisor };
|
|
153
|
+
const { instance, base } = await startServer({
|
|
154
|
+
cwd,
|
|
155
|
+
resolveProvider: ({ providerId }) => ({ provider: providers[providerId] ?? parent, config: {} }),
|
|
156
|
+
});
|
|
157
|
+
try {
|
|
158
|
+
await fetch(`${base}/api/agents/revisor`, {
|
|
159
|
+
method: 'PUT',
|
|
160
|
+
headers: json,
|
|
161
|
+
body: JSON.stringify({
|
|
162
|
+
name: 'Revisor',
|
|
163
|
+
description: 'Busca bugs',
|
|
164
|
+
provider: 'revisor',
|
|
165
|
+
model: 'revisor-model',
|
|
166
|
+
mode: 'plan',
|
|
167
|
+
effort: 'high',
|
|
168
|
+
body: 'MARCADOR_REVISOR',
|
|
169
|
+
}),
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const created = await (
|
|
173
|
+
await fetch(`${base}/api/sessions`, {
|
|
174
|
+
method: 'POST',
|
|
175
|
+
headers: json,
|
|
176
|
+
body: JSON.stringify({ title: 'delegar', model: 'parent-model', provider: 'parent' }),
|
|
177
|
+
})
|
|
178
|
+
).json();
|
|
179
|
+
await (
|
|
180
|
+
await fetch(`${base}/api/chat`, {
|
|
181
|
+
method: 'POST',
|
|
182
|
+
headers: json,
|
|
183
|
+
body: JSON.stringify({ sessionId: created.session.id, input: 'delegá al revisor', model: 'parent-model', provider: 'parent' }),
|
|
184
|
+
})
|
|
185
|
+
).text();
|
|
186
|
+
|
|
187
|
+
assert.equal(sub.model, 'revisor-model');
|
|
188
|
+
assert.equal(sub.reasoningEffort, 'high');
|
|
189
|
+
assert.match(sub.system, /MARCADOR_REVISOR/);
|
|
190
|
+
assert.equal(calls.parent >= 2, true);
|
|
191
|
+
} finally {
|
|
192
|
+
await instance.close();
|
|
193
|
+
}
|
|
194
|
+
});
|
|
@@ -79,10 +79,14 @@ test('la validacion del server coincide con el registro del cliente', () => {
|
|
|
79
79
|
'solarized-dark',
|
|
80
80
|
'bosque',
|
|
81
81
|
'vino',
|
|
82
|
+
'arena',
|
|
83
|
+
'hielo',
|
|
82
84
|
'vercel',
|
|
83
85
|
'dark-plus',
|
|
84
86
|
'github',
|
|
85
87
|
'github-dark',
|
|
88
|
+
'mocha',
|
|
89
|
+
'ebano',
|
|
86
90
|
].sort()
|
|
87
91
|
);
|
|
88
92
|
assert.equal(resolveThemeId('solarized-dark'), 'solarized-dark');
|
|
@@ -53,34 +53,147 @@ function importsDe(archivo) {
|
|
|
53
53
|
const exportsPorModulo = new Map(modulos.map((m) => [m, exportsDe(m)]));
|
|
54
54
|
const importsPorModulo = new Map(modulos.map((m) => [m, importsDe(m)]));
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Copia del source con cadenas, comentarios y literales regex **vaciados**
|
|
58
|
+
* (mismos largos y saltos de linea). Contar llaves/parentesis o buscar llamadas
|
|
59
|
+
* sobre esta copia no se confunde con un `}` de un string, un `(` de un
|
|
60
|
+
* comentario ni con los grupos de un regex. Los templates se vacian enteros
|
|
61
|
+
* salvo sus interpolaciones (`${...}`, que es codigo real): un barrido naive
|
|
62
|
+
* cierra en el backtick del template anidado (`${x.map((v) => `${v}`)}`) y
|
|
63
|
+
* desbalancea todo lo que viene despues.
|
|
64
|
+
*/
|
|
65
|
+
function sinCadenas(src) {
|
|
66
|
+
const out = [...src];
|
|
67
|
+
const vaciar = (i) => {
|
|
68
|
+
if (out[i] !== '\n') out[i] = ' ';
|
|
69
|
+
return i + 1;
|
|
70
|
+
};
|
|
71
|
+
// Un `/` abre regex salvo que venga de un valor (division): `a / b` vs
|
|
72
|
+
// `return /re/`, `if (/re/)`, `x = /re/`.
|
|
73
|
+
const TRAS_REGEX = new Set(['return', 'typeof', 'instanceof', 'in', 'of', 'delete', 'void', 'new', 'do', 'else', 'yield', 'await', 'case']);
|
|
74
|
+
const PUNCT_REGEX = new Set(['(', '[', '{', '=', ':', ',', ';', '!', '?', '&', '|', '+', '-', '*', '%', '^', '~', '<', '>', '']);
|
|
75
|
+
let ultimaPalabra = '';
|
|
76
|
+
let ultimoChar = '';
|
|
77
|
+
|
|
78
|
+
function plantilla(i) {
|
|
79
|
+
while (i < src.length) {
|
|
80
|
+
const c = src[i];
|
|
81
|
+
if (c === '\\') i = vaciar(vaciar(i));
|
|
82
|
+
else if (c === '`') return vaciar(i);
|
|
83
|
+
else if (c === '$' && src[i + 1] === '{') {
|
|
84
|
+
i = vaciar(vaciar(i));
|
|
85
|
+
i = codigo(i, true);
|
|
86
|
+
if (i < src.length) i = vaciar(i);
|
|
87
|
+
} else i = vaciar(i);
|
|
88
|
+
}
|
|
89
|
+
return i;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function literarRegex(i) {
|
|
93
|
+
let enClase = false;
|
|
94
|
+
i = vaciar(i);
|
|
95
|
+
while (i < src.length && src[i] !== '\n') {
|
|
96
|
+
const c = src[i];
|
|
97
|
+
if (c === '\\') {
|
|
98
|
+
i = vaciar(vaciar(i));
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (c === '[') enClase = true;
|
|
102
|
+
else if (c === ']') enClase = false;
|
|
103
|
+
else if (c === '/' && !enClase) return vaciar(i) + saltarFlags(i + 1);
|
|
104
|
+
i = vaciar(i);
|
|
105
|
+
}
|
|
106
|
+
return i + saltarFlags(i);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function saltarFlags(i) {
|
|
110
|
+
let j = i;
|
|
111
|
+
while (j < src.length && /[a-z]/i.test(src[j])) j = vaciar(j);
|
|
112
|
+
return j - i;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function codigo(i, cierraInterp) {
|
|
116
|
+
let prof = 0;
|
|
117
|
+
while (i < src.length) {
|
|
118
|
+
const c = src[i];
|
|
119
|
+
const dos = src[i + 1];
|
|
120
|
+
if (c === '/' && dos === '/') {
|
|
121
|
+
while (i < src.length && src[i] !== '\n') i = vaciar(i);
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
if (c === '/' && dos === '*') {
|
|
125
|
+
i = vaciar(vaciar(i));
|
|
126
|
+
while (i < src.length && !(src[i] === '*' && src[i + 1] === '/')) i = vaciar(i);
|
|
127
|
+
if (i < src.length) i = vaciar(vaciar(i));
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
if (c === '"' || c === "'") {
|
|
131
|
+
i = vaciar(i);
|
|
132
|
+
while (i < src.length && src[i] !== c) i = src[i] === '\\' ? vaciar(vaciar(i)) : vaciar(i);
|
|
133
|
+
if (i < src.length) i = vaciar(i);
|
|
134
|
+
ultimaPalabra = '';
|
|
135
|
+
ultimoChar = ')';
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (c === '`') {
|
|
139
|
+
i = plantilla(vaciar(i));
|
|
140
|
+
ultimaPalabra = '';
|
|
141
|
+
ultimoChar = ')';
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const trasPalabra = ultimaPalabra && ultimoChar === ultimaPalabra[ultimaPalabra.length - 1];
|
|
145
|
+
const abreRegex = trasPalabra ? TRAS_REGEX.has(ultimaPalabra) : PUNCT_REGEX.has(ultimoChar);
|
|
146
|
+
if (c === '/' && abreRegex) {
|
|
147
|
+
i = literarRegex(i);
|
|
148
|
+
ultimaPalabra = '';
|
|
149
|
+
ultimoChar = ')';
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (cierraInterp) {
|
|
153
|
+
if (c === '{') prof += 1;
|
|
154
|
+
else if (c === '}') {
|
|
155
|
+
if (prof === 0) return i; // el `}` de cierre lo vacia el llamador
|
|
156
|
+
prof -= 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (/[A-Za-z0-9_$]/.test(c)) {
|
|
160
|
+
ultimaPalabra += c;
|
|
161
|
+
ultimoChar = c;
|
|
162
|
+
} else if (!/\s/.test(c)) {
|
|
163
|
+
ultimaPalabra = '';
|
|
164
|
+
ultimoChar = c;
|
|
165
|
+
}
|
|
166
|
+
i += 1;
|
|
167
|
+
}
|
|
168
|
+
return i;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
codigo(0, false);
|
|
172
|
+
return out.join('');
|
|
173
|
+
}
|
|
174
|
+
|
|
56
175
|
/**
|
|
57
176
|
* Statements de nivel superior de un modulo, agrupando por balance de llaves y
|
|
58
177
|
* parentesis. Asi `const x = f();` sale como un statement, y un
|
|
59
|
-
* `addEventListener(...)` con un callback multilinea tambien.
|
|
178
|
+
* `addEventListener(...)` con un callback multilinea tambien. El texto se
|
|
179
|
+
* devuelve crudo (para los mensajes de error) pero el balance se cuenta sobre
|
|
180
|
+
* `sinCadenas`, que preserva largos y offsets.
|
|
60
181
|
*/
|
|
61
182
|
function statementsTopLevel(src) {
|
|
183
|
+
const limpio = sinCadenas(src);
|
|
62
184
|
const stmts = [];
|
|
63
185
|
let actual = '';
|
|
64
186
|
let inicio = 0;
|
|
65
187
|
let llaves = 0;
|
|
66
188
|
let corchetes = 0;
|
|
67
189
|
let parentesis = 0;
|
|
68
|
-
let enCadena = '';
|
|
69
190
|
let pos = 0;
|
|
70
|
-
for (const linea of src.
|
|
191
|
+
for (const linea of src.matchAll(/[^\n]*(?:\r?\n|$)/g)) {
|
|
192
|
+
if (!linea[0]) continue; // match vacio al final
|
|
193
|
+
const texto = linea[0].replace(/\r?\n$/, '');
|
|
71
194
|
if (!actual) inicio = pos;
|
|
72
|
-
actual += `${actual ? '\n' : ''}${
|
|
73
|
-
for (
|
|
74
|
-
const c = linea[i];
|
|
75
|
-
const previo = linea[i - 1];
|
|
76
|
-
if (enCadena) {
|
|
77
|
-
if (c === enCadena && previo !== '\\') enCadena = '';
|
|
78
|
-
continue;
|
|
79
|
-
}
|
|
80
|
-
if (c === '"' || c === "'" || c === '`') {
|
|
81
|
-
enCadena = c;
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
195
|
+
actual += `${actual ? '\n' : ''}${texto}`;
|
|
196
|
+
for (const c of limpio.slice(pos, pos + texto.length)) {
|
|
84
197
|
if (c === '{') llaves += 1;
|
|
85
198
|
else if (c === '}') llaves -= 1;
|
|
86
199
|
else if (c === '[') corchetes += 1;
|
|
@@ -88,7 +201,7 @@ function statementsTopLevel(src) {
|
|
|
88
201
|
else if (c === '(') parentesis += 1;
|
|
89
202
|
else if (c === ')') parentesis -= 1;
|
|
90
203
|
}
|
|
91
|
-
pos += linea.length
|
|
204
|
+
pos += linea[0].length;
|
|
92
205
|
if (llaves <= 0 && corchetes <= 0 && parentesis <= 0) {
|
|
93
206
|
if (actual.trim()) stmts.push({ texto: actual, inicio });
|
|
94
207
|
actual = '';
|
|
@@ -106,54 +219,28 @@ function statementsTopLevel(src) {
|
|
|
106
219
|
* callbacks (`=> {...}`, `=> expr`, `function () {...}`) y literales de datos
|
|
107
220
|
* (`= [...]`, `= {...}`), que a lo sumo guardan referencias. Lo que queda es lo
|
|
108
221
|
* que se evalua al cargar el modulo.
|
|
109
|
-
*
|
|
110
|
-
* Recorre con estado de cadenas: un `=>` dentro de un template no es un arrow.
|
|
111
222
|
*/
|
|
112
223
|
function sinDiferidos(stmt) {
|
|
113
|
-
const s = stmt.replace(/\r?\n/g, ' ');
|
|
224
|
+
const s = sinCadenas(stmt).replace(/\r?\n/g, ' ');
|
|
114
225
|
let out = '';
|
|
115
226
|
let i = 0;
|
|
116
|
-
let cadena = '';
|
|
117
227
|
const cortarBloque = (desde) => {
|
|
118
228
|
// Corta hasta el cierre balanceado de `{`/`[` abierto en `desde - 1`.
|
|
119
229
|
let prof = 1;
|
|
120
230
|
let j = desde;
|
|
121
|
-
|
|
122
|
-
for (; j < s.length; j += 1) {
|
|
231
|
+
while (j < s.length) {
|
|
123
232
|
const c = s[j];
|
|
124
|
-
const previo = s[j - 1];
|
|
125
|
-
if (cad) {
|
|
126
|
-
if (c === cad && previo !== '\\') cad = '';
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
if (c === '"' || c === "'" || c === '`') {
|
|
130
|
-
cad = c;
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
233
|
if (c === '{' || c === '[') prof += 1;
|
|
134
234
|
else if (c === '}' || c === ']') {
|
|
135
235
|
prof -= 1;
|
|
136
236
|
if (prof === 0) return j + 1;
|
|
137
237
|
}
|
|
238
|
+
j += 1;
|
|
138
239
|
}
|
|
139
240
|
return j;
|
|
140
241
|
};
|
|
141
|
-
|
|
142
242
|
while (i < s.length) {
|
|
143
243
|
const c = s[i];
|
|
144
|
-
const previo = s[i - 1];
|
|
145
|
-
if (cadena) {
|
|
146
|
-
out += c;
|
|
147
|
-
if (c === cadena && previo !== '\\') cadena = '';
|
|
148
|
-
i += 1;
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (c === '"' || c === "'" || c === '`') {
|
|
152
|
-
cadena = c;
|
|
153
|
-
out += c;
|
|
154
|
-
i += 1;
|
|
155
|
-
continue;
|
|
156
|
-
}
|
|
157
244
|
const resto = s.slice(i);
|
|
158
245
|
if (c === '=' && s[i + 1] === '>') {
|
|
159
246
|
const despues = s.slice(i + 2).match(/^\s*([[{])/);
|
|
@@ -186,18 +273,7 @@ function sinDiferidos(stmt) {
|
|
|
186
273
|
/** Profundidad de llaves antes del caracter `hasta` (para saber si es nivel 0). */
|
|
187
274
|
function profundidadEn(src, hasta) {
|
|
188
275
|
let prof = 0;
|
|
189
|
-
|
|
190
|
-
for (let i = 0; i < hasta; i += 1) {
|
|
191
|
-
const c = src[i];
|
|
192
|
-
const previo = src[i - 1];
|
|
193
|
-
if (cadena) {
|
|
194
|
-
if (c === cadena && previo !== '\\') cadena = '';
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
if (c === '"' || c === "'" || c === '`') {
|
|
198
|
-
cadena = c;
|
|
199
|
-
continue;
|
|
200
|
-
}
|
|
276
|
+
for (const c of sinCadenas(src).slice(0, hasta)) {
|
|
201
277
|
if (c === '{') prof += 1;
|
|
202
278
|
else if (c === '}') prof -= 1;
|
|
203
279
|
}
|