@danieltmn/tamnora 0.22.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.md +3 -1
  3. package/SECURITY.md +1 -1
  4. package/harness/packages/cli/bin/tamnora.mjs +7 -1
  5. package/harness/packages/cli/src/commands/doctor.js +42 -4
  6. package/harness/packages/cli/src/commands/repl.js +1 -0
  7. package/harness/packages/cli/src/commands/run.js +1 -0
  8. package/harness/packages/cli/src/commands/tui.js +1 -0
  9. package/harness/packages/cli/src/lib/runtime.js +2 -0
  10. package/harness/packages/cli/src/tui/app.js +2 -0
  11. package/harness/packages/core/src/browser/index.js +27 -13
  12. package/harness/packages/core/src/tts/edge.js +34 -4
  13. package/harness/packages/core/test/agent.test.js +18 -0
  14. package/harness/packages/core/test/servicios-vivos.test.js +71 -0
  15. package/harness/packages/core/test/tts.test.js +81 -0
  16. package/harness/packages/server/bin/tamnora-server.mjs +7 -1
  17. package/harness/packages/server/public/app.js +4 -3
  18. package/harness/packages/server/public/cards.js +470 -0
  19. package/harness/packages/server/public/chat.js +1343 -1893
  20. package/harness/packages/server/public/chatFormat.js +156 -0
  21. package/harness/packages/server/public/chrome.js +5 -4
  22. package/harness/packages/server/public/icons/logo_dark.png +0 -0
  23. package/harness/packages/server/public/icons/logo_light.png +0 -0
  24. package/harness/packages/server/public/index.html +22 -23
  25. package/harness/packages/server/public/settings.js +4 -0
  26. package/harness/packages/server/public/styles.css +20 -50
  27. package/harness/packages/server/src/lib/channels/telegram/api.js +102 -0
  28. package/harness/packages/server/src/lib/channels/telegram/commands.js +309 -0
  29. package/harness/packages/server/src/lib/channels/telegram/media.js +64 -0
  30. package/harness/packages/server/src/lib/channels/telegram/permissions.js +0 -0
  31. package/harness/packages/server/src/lib/channels/telegram/projects.js +102 -0
  32. package/harness/packages/server/src/lib/channels/telegram/run.js +206 -0
  33. package/harness/packages/server/src/lib/channels/telegram/text.js +57 -0
  34. package/harness/packages/server/src/lib/channels/telegram.js +163 -933
  35. package/harness/packages/server/src/lib/scheduler.js +1 -0
  36. package/harness/packages/server/src/lib/tasks.js +1 -0
  37. package/harness/packages/server/src/routes/chat.js +1 -1
  38. package/harness/packages/server/src/routes/providers.js +2 -0
  39. package/harness/packages/server/src/server.js +6 -0
  40. package/harness/packages/server/test/chatFormat.test.js +119 -0
  41. package/harness/packages/server/test/consoleModules.test.js +301 -0
  42. package/harness/packages/server/test/consoleSmoke.test.js +34 -0
  43. package/harness/packages/server/test/helpers.js +15 -1
  44. package/harness/packages/server/test/ops.test.js +9 -5
  45. package/harness/packages/server/test/settings.test.js +18 -1
  46. package/harness/scripts/canary.mjs +24 -0
  47. package/package.json +3 -2
@@ -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
- await new Promise((resolve) => setTimeout(resolve, 500));
151
-
152
- const logs = await (await fetch(`${base}/api/procs/${started.proc.id}/logs`)).json();
153
- assert.match(logs.logs.map((l) => l.text).join(''), /42/);
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);
@@ -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({ maxRounds: 20, maxTokens: 50000, maxDurationMs: 600000 }),
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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danieltmn/tamnora",
3
- "version": "0.22.0",
3
+ "version": "0.23.0",
4
4
  "description": "Harness local para IAs: agente CLI-first con TUI, consola web y evaluacion. Node puro, sin dependencias.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@11.25.0",
@@ -31,7 +31,7 @@
31
31
  "tamnora-server": "harness/packages/server/bin/tamnora-server.mjs"
32
32
  },
33
33
  "engines": {
34
- "node": ">=22.5"
34
+ "node": ">=22.13"
35
35
  },
36
36
  "files": [
37
37
  "bin",
@@ -47,6 +47,7 @@
47
47
  "tamnora": "node --disable-warning=ExperimentalWarning harness/packages/cli/bin/tamnora.mjs",
48
48
  "serve": "node --disable-warning=ExperimentalWarning harness/packages/server/bin/tamnora-server.mjs",
49
49
  "mock": "node harness/scripts/mock-provider.mjs",
50
+ "canary": "node harness/scripts/canary.mjs",
50
51
  "test": "node --disable-warning=ExperimentalWarning --test \"harness/packages/*/test/*.test.js\"",
51
52
  "lint": "eslint .",
52
53
  "release": "node scripts/release.mjs",