@danieltmn/tamnora 0.22.0 → 0.23.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 +25 -0
- package/README.md +3 -1
- package/SECURITY.md +1 -1
- package/harness/packages/cli/bin/tamnora.mjs +9 -1
- package/harness/packages/cli/src/commands/bridge.js +3 -3
- package/harness/packages/cli/src/commands/doctor.js +42 -4
- package/harness/packages/cli/src/commands/repl.js +1 -0
- package/harness/packages/cli/src/commands/run.js +1 -0
- package/harness/packages/cli/src/commands/tui.js +1 -0
- package/harness/packages/cli/src/commands/update.js +13 -2
- package/harness/packages/cli/src/lib/runtime.js +2 -0
- package/harness/packages/cli/src/tui/app.js +2 -0
- package/harness/packages/cli/test/bin-warning.test.js +27 -0
- package/harness/packages/cli/test/tunnel.test.js +49 -1
- package/harness/packages/core/src/browser/index.js +27 -13
- package/harness/packages/core/src/index.js +1 -1
- package/harness/packages/core/src/tts/edge.js +34 -4
- package/harness/packages/core/src/tunnel.js +32 -9
- package/harness/packages/core/test/agent.test.js +18 -0
- package/harness/packages/core/test/servicios-vivos.test.js +71 -0
- package/harness/packages/core/test/tts.test.js +81 -0
- package/harness/packages/server/bin/tamnora-server.mjs +9 -1
- package/harness/packages/server/public/app.js +4 -3
- package/harness/packages/server/public/cards.js +470 -0
- package/harness/packages/server/public/chat.js +1343 -1893
- package/harness/packages/server/public/chatFormat.js +156 -0
- package/harness/packages/server/public/chrome.js +5 -4
- 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/index.html +22 -23
- package/harness/packages/server/public/settings.js +4 -0
- package/harness/packages/server/public/styles.css +20 -50
- package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
- package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
- package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
- package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
- package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
- package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
- package/harness/packages/server/src/lib/channels/telegram.js +163 -933
- package/harness/packages/server/src/lib/procs.js +4 -1
- package/harness/packages/server/src/lib/scheduler.js +1 -0
- package/harness/packages/server/src/lib/tasks.js +1 -0
- package/harness/packages/server/src/routes/chat.js +1 -1
- package/harness/packages/server/src/routes/providers.js +2 -0
- package/harness/packages/server/src/server.js +6 -0
- package/harness/packages/server/test/chatFormat.test.js +119 -0
- package/harness/packages/server/test/consoleModules.test.js +301 -0
- package/harness/packages/server/test/consoleSmoke.test.js +34 -0
- package/harness/packages/server/test/helpers.js +15 -1
- package/harness/packages/server/test/ops.test.js +9 -5
- package/harness/packages/server/test/procs-manager.test.js +17 -0
- package/harness/packages/server/test/settings.test.js +18 -1
- package/harness/scripts/canary.mjs +24 -0
- package/harness/scripts/self-update.mjs +4 -1
- package/package.json +3 -2
|
@@ -76,7 +76,10 @@ export function createProcManager({ allow } = {}) {
|
|
|
76
76
|
throw new Error(`Argumento no permitido con shell en Windows: "${bad}".`);
|
|
77
77
|
}
|
|
78
78
|
}
|
|
79
|
-
|
|
79
|
+
// Con `shell: true` y array de args Node concatena sin escapar (warning
|
|
80
|
+
// DEP0190): se manda un unico comando (los args ya pasaron SAFE_SHELL_ARG).
|
|
81
|
+
const [cmd, cmdArgs] = shell ? [`${bin} ${list.join(' ')}`, []] : [bin, list];
|
|
82
|
+
const child = spawn(cmd, cmdArgs, {
|
|
80
83
|
cwd,
|
|
81
84
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
82
85
|
windowsHide: true,
|
|
@@ -108,6 +108,7 @@ async function runAgentSchedule(ctx, schedule) {
|
|
|
108
108
|
maxTokens: ctx.effectiveMaxTokens?.() || 0,
|
|
109
109
|
maxDurationMs: ctx.effectiveMaxDurationMs?.() || 0,
|
|
110
110
|
maxOutputTokens: ctx.effectiveMaxOutputTokens?.() || 0,
|
|
111
|
+
promptCaching: ctx.effectivePromptCaching?.() ?? ctx.config.promptCaching === true,
|
|
111
112
|
onEvent(event) {
|
|
112
113
|
if (event.type === 'text') emit('text', { full: event.full });
|
|
113
114
|
else if (event.type === 'reasoning') emit('reasoning', { full: event.full });
|
|
@@ -118,6 +118,7 @@ export function createSubagentTool(ctx, options) {
|
|
|
118
118
|
signal,
|
|
119
119
|
maxRounds: ctx.config.agents?.maxRounds ?? 6,
|
|
120
120
|
maxOutputTokens: ctx.config.agents?.maxOutputTokens ?? ctx.config.maxOutputTokens ?? 0,
|
|
121
|
+
promptCaching: ctx.effectivePromptCaching?.() ?? ctx.config.promptCaching === true,
|
|
121
122
|
onEvent(event) {
|
|
122
123
|
if (event.type === 'text') emit('text', { full: event.full, agentId }, agentId);
|
|
123
124
|
else if (event.type === 'reasoning') emit('reasoning', { full: event.full, agentId }, agentId);
|
|
@@ -346,7 +346,7 @@ export function registerChat(route, ctx) {
|
|
|
346
346
|
maxTokens: Number(body.maxTokens) || ctx.effectiveMaxTokens?.() || 0,
|
|
347
347
|
maxDurationMs: Number(body.maxDurationMs) || ctx.effectiveMaxDurationMs?.() || 0,
|
|
348
348
|
maxOutputTokens: Number(body.maxOutputTokens) || ctx.effectiveMaxOutputTokens?.() || 0,
|
|
349
|
-
promptCaching: ctx.config.promptCaching === true,
|
|
349
|
+
promptCaching: ctx.effectivePromptCaching?.() ?? ctx.config.promptCaching === true,
|
|
350
350
|
stallWarnMs: body.stallWarnMs ?? ctx.config.stallWarnMs,
|
|
351
351
|
contextTokens,
|
|
352
352
|
onEvent(event) {
|
|
@@ -80,6 +80,7 @@ export function registerProviders(route, ctx) {
|
|
|
80
80
|
maxTokens: ctx.effectiveMaxTokens(),
|
|
81
81
|
maxDurationMs: ctx.effectiveMaxDurationMs(),
|
|
82
82
|
maxOutputTokens: ctx.effectiveMaxOutputTokens(),
|
|
83
|
+
promptCaching: ctx.effectivePromptCaching(),
|
|
83
84
|
defaultCwd: ctx.store.getSetting('defaultCwd') || '',
|
|
84
85
|
strictScope: ctx.effectiveStrictScope(),
|
|
85
86
|
mcpMode: ctx.effectiveMcpMode(),
|
|
@@ -98,6 +99,7 @@ export function registerProviders(route, ctx) {
|
|
|
98
99
|
if (body.mode !== undefined) ctx.store.setSetting('activeMode', resolveMode(String(body.mode)).id);
|
|
99
100
|
if (body.defaultCwd !== undefined) ctx.store.setSetting('defaultCwd', String(body.defaultCwd ?? '').trim() || null);
|
|
100
101
|
if (body.strictScope !== undefined) ctx.store.setSetting('strictScope', Boolean(body.strictScope));
|
|
102
|
+
if (body.promptCaching !== undefined) ctx.store.setSetting('promptCaching', Boolean(body.promptCaching));
|
|
101
103
|
if (body.mcpMode !== undefined) {
|
|
102
104
|
const mode = String(body.mcpMode);
|
|
103
105
|
ctx.store.setSetting('mcpMode', ['auto', 'eager', 'lazy'].includes(mode) ? mode : 'auto');
|
|
@@ -181,6 +181,11 @@ export async function createTamnoraServer({
|
|
|
181
181
|
const effectiveMaxDurationMs = () => settingNumber('maxDurationMs', config.maxDurationMs);
|
|
182
182
|
const effectiveMaxOutputTokens = () => settingNumber('maxOutputTokens', config.maxOutputTokens);
|
|
183
183
|
const effectiveStrictScope = () => store.getSetting('strictScope') === true;
|
|
184
|
+
const effectivePromptCaching = () => {
|
|
185
|
+
const stored = store.getSetting('promptCaching');
|
|
186
|
+
if (stored === null || stored === undefined) return config.promptCaching === true;
|
|
187
|
+
return stored === true;
|
|
188
|
+
};
|
|
184
189
|
const MCP_MODES = new Set(['auto', 'eager', 'lazy']);
|
|
185
190
|
const effectiveMcpMode = () => {
|
|
186
191
|
const value = String(store.getSetting('mcpMode') ?? config.mcpMode ?? 'auto');
|
|
@@ -221,6 +226,7 @@ export async function createTamnoraServer({
|
|
|
221
226
|
effectiveMaxDurationMs,
|
|
222
227
|
effectiveMaxOutputTokens,
|
|
223
228
|
effectiveStrictScope,
|
|
229
|
+
effectivePromptCaching,
|
|
224
230
|
effectiveMcpMode,
|
|
225
231
|
effectiveMcpPinned,
|
|
226
232
|
mcpText,
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { test } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import {
|
|
4
|
+
ADVISOR_KINDS,
|
|
5
|
+
advisorMeta,
|
|
6
|
+
basename,
|
|
7
|
+
formatDuration,
|
|
8
|
+
isFailedToolText,
|
|
9
|
+
isReadOnlyTool,
|
|
10
|
+
parseToolArgs,
|
|
11
|
+
previewText,
|
|
12
|
+
screenshotName,
|
|
13
|
+
shortText,
|
|
14
|
+
toolSummary,
|
|
15
|
+
userImages,
|
|
16
|
+
userText,
|
|
17
|
+
} from '../public/chatFormat.js';
|
|
18
|
+
|
|
19
|
+
test('shortText normaliza espacios y corta con punto', () => {
|
|
20
|
+
assert.equal(shortText(' hola mundo '), 'hola mundo');
|
|
21
|
+
assert.equal(shortText('x'.repeat(10), 5), 'xxxx…');
|
|
22
|
+
assert.equal(shortText(null), '');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('isReadOnlyTool distingue las tools de lectura, tambien las MCP', () => {
|
|
26
|
+
assert.ok(isReadOnlyTool('read'));
|
|
27
|
+
assert.ok(isReadOnlyTool('grep'));
|
|
28
|
+
assert.ok(!isReadOnlyTool('write'));
|
|
29
|
+
assert.ok(!isReadOnlyTool('bash'));
|
|
30
|
+
assert.ok(isReadOnlyTool('mcp__postgres__query'));
|
|
31
|
+
assert.ok(isReadOnlyTool('mcp__fs__list_files'));
|
|
32
|
+
assert.ok(!isReadOnlyTool('mcp__fs__write_file'));
|
|
33
|
+
assert.ok(!isReadOnlyTool(undefined));
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('toolSummary resume cada tool en una linea corta', () => {
|
|
37
|
+
assert.equal(toolSummary('read', { path: 'src/a.js', offset: 10, limit: 5 }), 'src/a.js:10-14');
|
|
38
|
+
assert.equal(toolSummary('read', { path: 'src/a.js', offset: 10 }), 'src/a.js:10+');
|
|
39
|
+
assert.equal(toolSummary('read', {}), '.');
|
|
40
|
+
assert.equal(toolSummary('write', { path: 'a.txt', content: 'x' }), 'a.txt');
|
|
41
|
+
assert.equal(toolSummary('bash', { command: 'npm test\notra linea' }), 'npm test');
|
|
42
|
+
assert.equal(toolSummary('grep', { pattern: 'foo', path: 'src' }), '"foo" en src');
|
|
43
|
+
assert.equal(toolSummary('git_diff', { staged: true, path: 'a.js' }), 'staged a.js');
|
|
44
|
+
assert.equal(toolSummary('git_log', { limit: 5 }), '5 commits');
|
|
45
|
+
assert.equal(toolSummary('desconocida', {}), '');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test('parseToolArgs acepta objeto, JSON y basura', () => {
|
|
49
|
+
assert.deepEqual(parseToolArgs({ function: { arguments: '{"a":1}' } }), { a: 1 });
|
|
50
|
+
assert.deepEqual(parseToolArgs({ arguments: { a: 2 } }), { a: 2 });
|
|
51
|
+
assert.deepEqual(parseToolArgs({ arguments: 'no es json' }), {});
|
|
52
|
+
assert.deepEqual(parseToolArgs(null), {});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('isFailedToolText detecta errores y permisos denegados', () => {
|
|
56
|
+
assert.ok(isFailedToolText('Error: se rompio'));
|
|
57
|
+
assert.ok(isFailedToolText('Permiso denegado por el usuario'));
|
|
58
|
+
assert.ok(!isFailedToolText('listo, sin errores'));
|
|
59
|
+
assert.ok(!isFailedToolText(undefined));
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('screenshotName sale del meta o del texto del resultado', () => {
|
|
63
|
+
assert.equal(screenshotName({ meta: { screenshot: { name: 'a.png' } } }, ''), 'a.png');
|
|
64
|
+
assert.equal(screenshotName({}, 'guardado en capturas/abc-123.png'), 'abc-123.png');
|
|
65
|
+
assert.equal(screenshotName({}, 'sin captura'), null);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
test('previewText trunca por lineas y por caracteres', () => {
|
|
69
|
+
const largo = Array.from({ length: 50 }, (_, i) => `linea ${i}`).join('\n');
|
|
70
|
+
const { preview, truncated } = previewText(largo, 5, 100);
|
|
71
|
+
assert.equal(preview.split('\n').length, 5);
|
|
72
|
+
assert.ok(truncated);
|
|
73
|
+
assert.deepEqual(previewText('hola'), { preview: 'hola', truncated: false });
|
|
74
|
+
// Un objeto se serializa con formato.
|
|
75
|
+
assert.match(previewText({ a: 1 }).preview, /"a": 1/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('userText y userImages separan las partes del mensaje multimodal', () => {
|
|
79
|
+
assert.equal(userText('plano'), 'plano');
|
|
80
|
+
assert.equal(
|
|
81
|
+
userText([
|
|
82
|
+
{ type: 'text', text: 'hola' },
|
|
83
|
+
{ type: 'text', text: 'mundo' },
|
|
84
|
+
{ type: 'image_url', image_url: { url: 'data:x' } },
|
|
85
|
+
]),
|
|
86
|
+
'hola\nmundo',
|
|
87
|
+
);
|
|
88
|
+
assert.equal(userText(null), '');
|
|
89
|
+
assert.deepEqual(
|
|
90
|
+
userImages([
|
|
91
|
+
{ type: 'image_url', image_url: { url: 'data:x' } },
|
|
92
|
+
{ type: 'text', text: 'y' },
|
|
93
|
+
{ type: 'image_url', image_url: {} },
|
|
94
|
+
]),
|
|
95
|
+
['data:x'],
|
|
96
|
+
);
|
|
97
|
+
assert.deepEqual(userImages('plano'), []);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test('advisorMeta cae al default con un tipo desconocido', () => {
|
|
101
|
+
assert.equal(advisorMeta('code'), ADVISOR_KINDS.code);
|
|
102
|
+
assert.equal(advisorMeta('nope').label, 'Asesor');
|
|
103
|
+
assert.ok(ADVISOR_KINDS.summary);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test('basename corta el ultimo segmento y los separadores finales', () => {
|
|
107
|
+
assert.equal(basename('a/b/c'), 'c');
|
|
108
|
+
assert.equal(basename('a\\b\\c\\'), 'c');
|
|
109
|
+
// Solo separadores: no hay segmento que devolver.
|
|
110
|
+
assert.equal(basename('/'), '');
|
|
111
|
+
assert.equal(basename(null), '');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('formatDuration da ms cortos y segundos con decimal', () => {
|
|
115
|
+
assert.equal(formatDuration(0), '');
|
|
116
|
+
assert.equal(formatDuration(undefined), '');
|
|
117
|
+
assert.equal(formatDuration(300), '300ms');
|
|
118
|
+
assert.equal(formatDuration(1200), '1.2s');
|
|
119
|
+
});
|
|
@@ -0,0 +1,301 @@
|
|
|
1
|
+
// El grafo de imports de la consola (`public/`), verificado sin navegador.
|
|
2
|
+
//
|
|
3
|
+
// La consola no tiene tests de DOM: si un refactor borra un export, renombra una
|
|
4
|
+
// funcion o mueve una llamada a tiempo de evaluacion dentro de un ciclo, la
|
|
5
|
+
// suite no lo ve y el navegador se queda con la pantalla en blanco. Esto cierra
|
|
6
|
+
// ese hueco: valida que cada import resuelve a un export real y que los ciclos
|
|
7
|
+
// (chrome <-> chat, app <-> explorer) siguen seguros.
|
|
8
|
+
//
|
|
9
|
+
// Es analisis estatico sobre el texto: no importa el modulo, asi que no hace
|
|
10
|
+
// falta emular el DOM.
|
|
11
|
+
import { test } from 'node:test';
|
|
12
|
+
import assert from 'node:assert/strict';
|
|
13
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
import { fileURLToPath } from 'node:url';
|
|
16
|
+
|
|
17
|
+
const DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'public');
|
|
18
|
+
|
|
19
|
+
const lee = (archivo) => readFileSync(path.join(DIR, archivo), 'utf8');
|
|
20
|
+
const modulos = readdirSync(DIR).filter((f) => f.endsWith('.js'));
|
|
21
|
+
|
|
22
|
+
/** Nombres exportados de un modulo: `export function X`, `export { A, B }`, `export const`. */
|
|
23
|
+
function exportsDe(archivo) {
|
|
24
|
+
const src = lee(archivo);
|
|
25
|
+
const nombres = new Set();
|
|
26
|
+
for (const m of src.matchAll(/export\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/g)) nombres.add(m[1]);
|
|
27
|
+
for (const m of src.matchAll(/export\s+(?:const|let|var|class)\s+([A-Za-z_$][\w$]*)/g)) nombres.add(m[1]);
|
|
28
|
+
// `export { a, b } from './x.js'` tambien expone esos nombres.
|
|
29
|
+
for (const m of src.matchAll(/export\s*\{([^}]*)\}\s*from\s*'[^']+'/g)) {
|
|
30
|
+
for (const bruto of m[1].split(',')) {
|
|
31
|
+
const nombre = bruto.trim().split(/\s+as\s+/).pop().trim();
|
|
32
|
+
if (nombre) nombres.add(nombre);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return nombres;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Imports relativos: `{ nombres, de }` por cada `import { ... } from './x.js'`. */
|
|
39
|
+
function importsDe(archivo) {
|
|
40
|
+
const src = lee(archivo);
|
|
41
|
+
const lista = [];
|
|
42
|
+
for (const m of src.matchAll(/import\s*\{([^}]*)\}\s*from\s*'(\.[^']+)'/g)) {
|
|
43
|
+
const nombres = m[1]
|
|
44
|
+
.split(',')
|
|
45
|
+
.map((n) => n.trim().replace(/^type\s+/, '').split(/\s+as\s+/)[0].trim())
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
const de = path.basename(m[2]);
|
|
48
|
+
lista.push({ nombres, de });
|
|
49
|
+
}
|
|
50
|
+
return lista;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const exportsPorModulo = new Map(modulos.map((m) => [m, exportsDe(m)]));
|
|
54
|
+
const importsPorModulo = new Map(modulos.map((m) => [m, importsDe(m)]));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Statements de nivel superior de un modulo, agrupando por balance de llaves y
|
|
58
|
+
* parentesis. Asi `const x = f();` sale como un statement, y un
|
|
59
|
+
* `addEventListener(...)` con un callback multilinea tambien.
|
|
60
|
+
*/
|
|
61
|
+
function statementsTopLevel(src) {
|
|
62
|
+
const stmts = [];
|
|
63
|
+
let actual = '';
|
|
64
|
+
let inicio = 0;
|
|
65
|
+
let llaves = 0;
|
|
66
|
+
let corchetes = 0;
|
|
67
|
+
let parentesis = 0;
|
|
68
|
+
let enCadena = '';
|
|
69
|
+
let pos = 0;
|
|
70
|
+
for (const linea of src.split(/\r?\n/)) {
|
|
71
|
+
if (!actual) inicio = pos;
|
|
72
|
+
actual += `${actual ? '\n' : ''}${linea}`;
|
|
73
|
+
for (let i = 0; i < linea.length; i += 1) {
|
|
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
|
+
}
|
|
84
|
+
if (c === '{') llaves += 1;
|
|
85
|
+
else if (c === '}') llaves -= 1;
|
|
86
|
+
else if (c === '[') corchetes += 1;
|
|
87
|
+
else if (c === ']') corchetes -= 1;
|
|
88
|
+
else if (c === '(') parentesis += 1;
|
|
89
|
+
else if (c === ')') parentesis -= 1;
|
|
90
|
+
}
|
|
91
|
+
pos += linea.length + 1;
|
|
92
|
+
if (llaves <= 0 && corchetes <= 0 && parentesis <= 0) {
|
|
93
|
+
if (actual.trim()) stmts.push({ texto: actual, inicio });
|
|
94
|
+
actual = '';
|
|
95
|
+
llaves = 0;
|
|
96
|
+
corchetes = 0;
|
|
97
|
+
parentesis = 0;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (actual.trim()) stmts.push({ texto: actual, inicio });
|
|
101
|
+
return stmts;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Quita del statement lo que se ejecuta **despues** del arranque: cuerpos de
|
|
106
|
+
* callbacks (`=> {...}`, `=> expr`, `function () {...}`) y literales de datos
|
|
107
|
+
* (`= [...]`, `= {...}`), que a lo sumo guardan referencias. Lo que queda es lo
|
|
108
|
+
* que se evalua al cargar el modulo.
|
|
109
|
+
*
|
|
110
|
+
* Recorre con estado de cadenas: un `=>` dentro de un template no es un arrow.
|
|
111
|
+
*/
|
|
112
|
+
function sinDiferidos(stmt) {
|
|
113
|
+
const s = stmt.replace(/\r?\n/g, ' ');
|
|
114
|
+
let out = '';
|
|
115
|
+
let i = 0;
|
|
116
|
+
let cadena = '';
|
|
117
|
+
const cortarBloque = (desde) => {
|
|
118
|
+
// Corta hasta el cierre balanceado de `{`/`[` abierto en `desde - 1`.
|
|
119
|
+
let prof = 1;
|
|
120
|
+
let j = desde;
|
|
121
|
+
let cad = '';
|
|
122
|
+
for (; j < s.length; j += 1) {
|
|
123
|
+
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
|
+
if (c === '{' || c === '[') prof += 1;
|
|
134
|
+
else if (c === '}' || c === ']') {
|
|
135
|
+
prof -= 1;
|
|
136
|
+
if (prof === 0) return j + 1;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return j;
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
while (i < s.length) {
|
|
143
|
+
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
|
+
const resto = s.slice(i);
|
|
158
|
+
if (c === '=' && s[i + 1] === '>') {
|
|
159
|
+
const despues = s.slice(i + 2).match(/^\s*([[{])/);
|
|
160
|
+
if (despues) {
|
|
161
|
+
const inicio = i + 2 + despues[0].length;
|
|
162
|
+
i = cortarBloque(inicio);
|
|
163
|
+
} else {
|
|
164
|
+
i = s.length; // arrow de expression: su cuerpo corre al invocarlo
|
|
165
|
+
}
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const fn = /^\bfunction\b\s*[\w$]*\s*\([^)]*\)\s*\{/.exec(resto);
|
|
169
|
+
if (fn) {
|
|
170
|
+
out += fn[0].slice(0, -1);
|
|
171
|
+
i = cortarBloque(i + fn[0].length);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
if (c === '=' && /^\s*[[{]/.test(s.slice(i + 1))) {
|
|
175
|
+
const inicio = i + 1 + s.slice(i + 1).match(/^\s*[[{]/)[0].length;
|
|
176
|
+
out += '=';
|
|
177
|
+
i = cortarBloque(inicio);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
out += c;
|
|
181
|
+
i += 1;
|
|
182
|
+
}
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Profundidad de llaves antes del caracter `hasta` (para saber si es nivel 0). */
|
|
187
|
+
function profundidadEn(src, hasta) {
|
|
188
|
+
let prof = 0;
|
|
189
|
+
let cadena = '';
|
|
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
|
+
}
|
|
201
|
+
if (c === '{') prof += 1;
|
|
202
|
+
else if (c === '}') prof -= 1;
|
|
203
|
+
}
|
|
204
|
+
return prof;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
test('cada import de la consola resuelve a un export real', () => {
|
|
208
|
+
const rotos = [];
|
|
209
|
+
for (const [modulo, imports] of importsPorModulo) {
|
|
210
|
+
for (const { nombres, de } of imports) {
|
|
211
|
+
if (!exportsPorModulo.has(de)) {
|
|
212
|
+
rotos.push(`${modulo}: importa de "${de}", que no existe`);
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const expuestos = exportsPorModulo.get(de);
|
|
216
|
+
for (const nombre of nombres) {
|
|
217
|
+
if (!expuestos.has(nombre)) rotos.push(`${modulo}: "${nombre}" no existe en ${de}`);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
assert.deepEqual(rotos, [], `imports rotos:\n ${rotos.join('\n ')}`);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test('los ciclos de la consola son seguros (nada se usa en tiempo de evaluacion)', () => {
|
|
225
|
+
// Construye el grafo dirigido y busca ciclos.
|
|
226
|
+
const grafo = new Map(modulos.map((m) => [m, [...new Set(importsPorModulo.get(m).map((i) => i.de))].filter((d) => exportsPorModulo.has(d))]));
|
|
227
|
+
const ciclos = [];
|
|
228
|
+
const camino = [];
|
|
229
|
+
const enCamino = new Set();
|
|
230
|
+
const visitado = new Set();
|
|
231
|
+
|
|
232
|
+
const dfs = (nodo) => {
|
|
233
|
+
if (enCamino.has(nodo)) {
|
|
234
|
+
const desde = camino.indexOf(nodo);
|
|
235
|
+
ciclos.push([...camino.slice(desde), nodo].join(' -> '));
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
if (visitado.has(nodo)) return;
|
|
239
|
+
visitado.add(nodo);
|
|
240
|
+
enCamino.add(nodo);
|
|
241
|
+
camino.push(nodo);
|
|
242
|
+
for (const vecino of grafo.get(nodo) ?? []) dfs(vecino);
|
|
243
|
+
camino.pop();
|
|
244
|
+
enCamino.delete(nodo);
|
|
245
|
+
};
|
|
246
|
+
for (const nodo of grafo.keys()) dfs(nodo);
|
|
247
|
+
|
|
248
|
+
// Un ciclo es seguro mientras nadie USE un importado durante la evaluacion del
|
|
249
|
+
// modulo. Las funciones y clases estan izadas, asi que referenciarlas en un
|
|
250
|
+
// callback (`addEventListener('click', cerrar)`) es legal: corre despues. Lo
|
|
251
|
+
// que rompe el ciclo es **invocarlas** en nivel superior.
|
|
252
|
+
const enCiclo = new Set(ciclos.flatMap((c) => c.split(' -> ')));
|
|
253
|
+
// `app.js` es el entry point: lo carga el HTML, asi que su codigo de nivel
|
|
254
|
+
// superior corre cuando todo el grafo ya esta evaluado y no hay nada en la
|
|
255
|
+
// zona muerta. Ciclo con el, pero inofensivo.
|
|
256
|
+
const peligrosos = [];
|
|
257
|
+
for (const miembro of enCiclo) {
|
|
258
|
+
if (!miembro.endsWith('.js') || miembro === 'app.js') continue;
|
|
259
|
+
const propios = new Set(importsPorModulo.get(miembro).filter((i) => enCiclo.has(i.de)).flatMap((i) => i.nombres));
|
|
260
|
+
if (!propios.size) continue;
|
|
261
|
+
const src = lee(miembro);
|
|
262
|
+
for (const { texto: stmt, inicio } of statementsTopLevel(src)) {
|
|
263
|
+
// Solo nivel superior: dentro de un `switch` (los `case` de un comando) o
|
|
264
|
+
// de una funcion el codigo corre despues, no al cargar.
|
|
265
|
+
if (profundidadEn(src, inicio) !== 0) continue;
|
|
266
|
+
// Fuera de callbacks y literales: lo que se ejecuta al cargar el modulo.
|
|
267
|
+
const fuera = sinDiferidos(stmt);
|
|
268
|
+
for (const nombre of propios) {
|
|
269
|
+
if (new RegExp(`(^|[^\\w$.])${nombre}\\s*\\(`).test(fuera)) {
|
|
270
|
+
peligrosos.push(`${miembro}: ${stmt.trim().replace(/\s+/g, ' ').slice(0, 70)}`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
assert.deepEqual(peligrosos, [], `un ciclo invoca un importado en tiempo de evaluacion:\n ${peligrosos.join('\n ')}`);
|
|
276
|
+
// Estos ciclos existen a proposito; si desaparecen, cambio el grafo y
|
|
277
|
+
// conviene revisarlo a mano.
|
|
278
|
+
assert.ok(ciclos.length > 0, 'no se encontraron ciclos: cambio el grafo, revisar a mano');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('los modulos de la consola no exportan nombres repetidos entre si', () => {
|
|
282
|
+
// Un `export function x` duplicado en dos modulos suele ser codigo movido dos
|
|
283
|
+
// veces al partir un archivo grande.
|
|
284
|
+
const visto = new Map();
|
|
285
|
+
const duplicados = [];
|
|
286
|
+
for (const [modulo, nombres] of exportsPorModulo) {
|
|
287
|
+
for (const nombre of nombres) {
|
|
288
|
+
if (visto.has(nombre)) duplicados.push(`${nombre}: ${visto.get(nombre)} y ${modulo}`);
|
|
289
|
+
else visto.set(nombre, modulo);
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
// `index`-style re-exports (chat.js re-exporta chatFormat.js) son esperados.
|
|
293
|
+
const reexports = new Set();
|
|
294
|
+
for (const [modulo] of importsPorModulo) {
|
|
295
|
+
const src = lee(modulo);
|
|
296
|
+
for (const m of src.matchAll(/export\s*\{([^}]*)\}\s*from/g)) {
|
|
297
|
+
for (const bruto of m[1].split(',')) reexports.add(bruto.trim().split(/\s+as\s+/).pop().trim());
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
assert.deepEqual(duplicados.filter((d) => !reexports.has(d.split(':')[0])), []);
|
|
301
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Smoke test de la consola con un navegador de verdad.
|
|
2
|
+
//
|
|
3
|
+
// La consola es la parte del harness que ningun test cubria: si un cambio rompe
|
|
4
|
+
// un import, deja un `undefined` al cargar o dispara un error en el arranque,
|
|
5
|
+
// la pantalla queda en blanco y la suite seguia en verde. Esto la abre en el
|
|
6
|
+
// Chrome/Edge del sistema (el mismo navegador headless de las tools `screenshot`
|
|
7
|
+
// y `console_errors`) y exige que **no haya errores de consola**.
|
|
8
|
+
//
|
|
9
|
+
// Se saltea si no hay navegador: es una verificacion de UI, no del engine.
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { findBrowserPath, readConsole } from '../../core/src/browser/index.js';
|
|
13
|
+
import { startServer } from './helpers.js';
|
|
14
|
+
|
|
15
|
+
const HAY_NAVEGADOR = Boolean(findBrowserPath());
|
|
16
|
+
|
|
17
|
+
test('la consola carga sin errores de consola', { skip: HAY_NAVEGADOR ? false : 'sin navegador headless' }, async () => {
|
|
18
|
+
const { instance, base } = await startServer();
|
|
19
|
+
try {
|
|
20
|
+
const { errors } = await readConsole(base, { waitMs: 2500, timeoutMs: 30000, allowPrivate: true });
|
|
21
|
+
// Solo importan los errores de la consola. El favicon y el ruido de red (la
|
|
22
|
+
// consola pide fuentes a Google) no son fallos del codigo: sin conexion el
|
|
23
|
+
// test fallaria por algo que no controlamos.
|
|
24
|
+
const ruido = /favicon|ERR_NAME_NOT_RESOLVED|ERR_INTERNET_DISCONNECTED|ERR_CONNECTION|ERR_NETWORK|Failed to load resource/i;
|
|
25
|
+
const reales = errors.filter((e) => !ruido.test(String(e?.text ?? e?.message ?? e)));
|
|
26
|
+
assert.deepEqual(
|
|
27
|
+
reales.map((e) => e?.text ?? e?.message ?? String(e)),
|
|
28
|
+
[],
|
|
29
|
+
'la consola tiro errores al cargar',
|
|
30
|
+
);
|
|
31
|
+
} finally {
|
|
32
|
+
await instance.close();
|
|
33
|
+
}
|
|
34
|
+
});
|
|
@@ -79,6 +79,20 @@ async function readSse(url, options, until, timeoutMs = 5000) {
|
|
|
79
79
|
return frames;
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Espera una condicion (no un tiempo fijo): los procesos y los servicios tardan
|
|
84
|
+
* lo que tardan, y una espera fija solo hace flaky al test cuando la maquina
|
|
85
|
+
* esta cargada.
|
|
86
|
+
*/
|
|
87
|
+
async function waitFor(cond, timeoutMs = 5000, stepMs = 50) {
|
|
88
|
+
const start = Date.now();
|
|
89
|
+
for (;;) {
|
|
90
|
+
if (await cond()) return true;
|
|
91
|
+
if (Date.now() - start >= timeoutMs) return false;
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, stepMs));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
82
96
|
async function waitIdle(base, id, attempts = 60) {
|
|
83
97
|
for (let i = 0; i < attempts; i += 1) {
|
|
84
98
|
const detail = await (await fetch(`${base}/api/sessions/${id}`)).json();
|
|
@@ -104,4 +118,4 @@ async function readEvents(res, onFrame) {
|
|
|
104
118
|
return buffer;
|
|
105
119
|
}
|
|
106
120
|
|
|
107
|
-
export { fakeProvider, startServer, HAS_GIT, gitInit, readSse, waitIdle, readEvents };
|
|
121
|
+
export { fakeProvider, startServer, HAS_GIT, gitInit, readSse, waitFor, waitIdle, readEvents };
|
|
@@ -7,7 +7,7 @@ import { tmpdir } from 'node:os';
|
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { createTamnoraServer } from '../src/index.js';
|
|
9
9
|
import { setHome, ensureApp, readApp, writeApp, makeUser, screenshotsDir } from '../../core/src/index.js';
|
|
10
|
-
import { fakeProvider, startServer, HAS_GIT, gitInit } from './helpers.js';
|
|
10
|
+
import { fakeProvider, startServer, HAS_GIT, gitInit, waitFor } from './helpers.js';
|
|
11
11
|
|
|
12
12
|
test('serves health, providers and the console', async () => {
|
|
13
13
|
const { instance, base } = await startServer();
|
|
@@ -147,10 +147,14 @@ test('detects, runs and stops dev processes', async () => {
|
|
|
147
147
|
})
|
|
148
148
|
).json();
|
|
149
149
|
assert.ok(started.proc.id);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
150
|
+
// El proceso es rapido, pero arrancarlo no lo es: se espera la condicion, no
|
|
151
|
+
// un tiempo fijo (con la maquina cargada el sleep se quedaba corto y el test
|
|
152
|
+
// fallaba a veces).
|
|
153
|
+
const logs = async () => {
|
|
154
|
+
const data = await (await fetch(`${base}/api/procs/${started.proc.id}/logs`)).json();
|
|
155
|
+
return data.logs.map((l) => l.text).join('');
|
|
156
|
+
};
|
|
157
|
+
assert.ok(await waitFor(async () => /42/.test(await logs()), 10000), `no llego la salida a los logs: "${await logs()}"`);
|
|
154
158
|
|
|
155
159
|
const stopped = await fetch(`${base}/api/procs/${started.proc.id}/stop`, { method: 'POST' });
|
|
156
160
|
assert.equal(stopped.status, 200);
|
|
@@ -29,6 +29,23 @@ test('en Windows los args no admiten metacaracteres del shell', { skip: process.
|
|
|
29
29
|
}
|
|
30
30
|
});
|
|
31
31
|
|
|
32
|
+
test('un comando bare con args no dispara el warning DEP0190 de Node', { skip: process.platform !== 'win32' }, async () => {
|
|
33
|
+
const cwd = await workspace();
|
|
34
|
+
const procs = createProcManager({ allow: ['node'] });
|
|
35
|
+
const warnings = [];
|
|
36
|
+
const onWarn = (warning) => warnings.push(warning);
|
|
37
|
+
process.on('warning', onWarn);
|
|
38
|
+
let proc;
|
|
39
|
+
try {
|
|
40
|
+
proc = procs.start({ cwd, command: 'node', args: ['-e', 'process.exit(0)'] });
|
|
41
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
42
|
+
} finally {
|
|
43
|
+
process.removeListener('warning', onWarn);
|
|
44
|
+
if (proc) procs.stop(proc.id);
|
|
45
|
+
}
|
|
46
|
+
assert.deepEqual(warnings.filter((w) => w.code === 'DEP0190'), []);
|
|
47
|
+
});
|
|
48
|
+
|
|
32
49
|
test('procs arranca y detiene un proceso permitido', async () => {
|
|
33
50
|
const cwd = await workspace();
|
|
34
51
|
const procs = createProcManager({ allow: ['node'] });
|
|
@@ -199,15 +199,32 @@ test('guarda los límites del agente desde Ajustes', async () => {
|
|
|
199
199
|
const res = await fetch(`${base}/api/settings`, {
|
|
200
200
|
method: 'PUT',
|
|
201
201
|
headers: json,
|
|
202
|
-
body: JSON.stringify({
|
|
202
|
+
body: JSON.stringify({
|
|
203
|
+
maxRounds: 20,
|
|
204
|
+
maxTokens: 50000,
|
|
205
|
+
maxDurationMs: 600000,
|
|
206
|
+
maxOutputTokens: 2048,
|
|
207
|
+
promptCaching: true,
|
|
208
|
+
}),
|
|
203
209
|
});
|
|
204
210
|
const data = await res.json();
|
|
205
211
|
assert.equal(data.settings.maxRounds, 20);
|
|
206
212
|
assert.equal(data.settings.maxTokens, 50000);
|
|
207
213
|
assert.equal(data.settings.maxDurationMs, 600000);
|
|
214
|
+
assert.equal(data.settings.maxOutputTokens, 2048);
|
|
215
|
+
assert.equal(data.settings.promptCaching, true);
|
|
208
216
|
|
|
209
217
|
const again = await (await fetch(`${base}/api/settings`)).json();
|
|
210
218
|
assert.equal(again.settings.maxRounds, 20);
|
|
219
|
+
assert.equal(again.settings.maxOutputTokens, 2048);
|
|
220
|
+
assert.equal(again.settings.promptCaching, true);
|
|
221
|
+
|
|
222
|
+
const off = await fetch(`${base}/api/settings`, {
|
|
223
|
+
method: 'PUT',
|
|
224
|
+
headers: json,
|
|
225
|
+
body: JSON.stringify({ promptCaching: false }),
|
|
226
|
+
});
|
|
227
|
+
assert.equal((await off.json()).settings.promptCaching, false);
|
|
211
228
|
} finally {
|
|
212
229
|
await instance.close();
|
|
213
230
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Canary de los servicios no oficiales (TTS de Edge, STT de Google): los prueba
|
|
3
|
+
// contra la red real, que es lo que la suite normal no hace. Corre con
|
|
4
|
+
// `pnpm canary` y lo agenda el workflow de GitHub (canary.yml).
|
|
5
|
+
import { spawn } from 'node:child_process';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
|
|
9
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
10
|
+
const test = path.join(root, 'harness', 'packages', 'core', 'test', 'servicios-vivos.test.js');
|
|
11
|
+
|
|
12
|
+
console.log('Canary de servicios vivos (red real): TTS de Edge + STT de Google\n');
|
|
13
|
+
const hijo = spawn(
|
|
14
|
+
process.execPath,
|
|
15
|
+
['--disable-warning=ExperimentalWarning', '--test', test],
|
|
16
|
+
{ stdio: 'inherit', env: { ...process.env, TAMNORA_LIVE: '1' } },
|
|
17
|
+
);
|
|
18
|
+
hijo.on('exit', (code, signal) => {
|
|
19
|
+
process.exitCode = signal ? 1 : (code ?? 1);
|
|
20
|
+
if (process.exitCode) {
|
|
21
|
+
console.error('\nEl canary fallo: un servicio externo cambio o esta caido.');
|
|
22
|
+
console.error('Si es el TTS, revisa EDGE_VERSION/TRUSTED_CLIENT_TOKEN en core/src/tts/edge.js.');
|
|
23
|
+
}
|
|
24
|
+
});
|