@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
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.23.0] - 2026-10-28
9
+
10
+ ### Cambios
11
+
12
+ - feat(ajustes): expone maxOutputTokens y promptCaching en la pestana Limites
13
+ - feat(consola): el logo acompana el esquema del tema, y el browser puede emular color y correr un pre-script
14
+ - refactor(consola): extrae las tarjetas de chat.js a cards.js
15
+ - test(consola): valida el grafo de modulos y la carga real en navegador headless
16
+ - feat: canary de servicios vivos en pnpm y en cron de GitHub
17
+ - feat(doctor): --voz prueba el servicio de voz y distingue voz rota de servicio caido
18
+ - fix(voz): reintenta un fallo de conexion de la sintesis, sin reintentar rechazos
19
+ - fix(test): el arranque de procs espera la condicion, no un tiempo fijo
20
+ - refactor(consola): extrae el formato puro de chat.js a chatFormat.js, con tests
21
+ - refactor(telegram): parte el canal en api, text, permissions, media, projects, run y commands
22
+ - fix(voz): EDGE_VERSION acompana al endpoint nuevo y agrega un canary de servicios vivos
23
+ - chore: quita document.md desactualizado (decia 0.11.3) y normaliza los fin de linea
24
+ - fix: node:sqlite necesita Node >= 22.13 y el warning experimental se filtra en los bins
25
+ - refactor(consola): unifica la navegacion en el menu mas y devuelve el foco al cerrarlo
26
+
8
27
  ## [0.22.0] - 2026-10-22
9
28
 
10
29
  ### Cambios
package/README.md CHANGED
@@ -40,7 +40,7 @@ npx @danieltmn/tamnora --help
40
40
  npm i -g @danieltmn/tamnora
41
41
  ```
42
42
 
43
- Requiere **Node.js >= 22.5** (por `node:sqlite`).
43
+ Requiere **Node.js >= 22.13** (por `node:sqlite`).
44
44
 
45
45
  ## Uso rapido
46
46
 
@@ -184,6 +184,8 @@ Los tres se renderizan en vivo y se reconstruyen al recargar la consola.
184
184
  server resume lo viejo y sigue con el resumen + lo reciente, sin borrar nada.
185
185
  - **`max_tokens` por ronda** (`maxOutputTokens`) y **prompt caching** opt-in
186
186
  (`promptCaching`, `cache_control` en Anthropic y `prompt_cache_key` en OpenAI).
187
+ Ambos se ajustan en la pestaña **Límites** de la consola o con `PUT /api/settings`
188
+ (no solo por `tamnora.json`).
187
189
 
188
190
  ### Hooks y plugins
189
191
 
package/SECURITY.md CHANGED
@@ -59,7 +59,7 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
59
59
 
60
60
  ## Buenas practicas
61
61
 
62
- - Mantene Node actualizado (`node:sqlite` requiere Node >= 22.5).
62
+ - Mantene Node actualizado (`node:sqlite` requiere Node >= 22.13).
63
63
  - No commitees `.env`, `tamnora.json`, bases `.db` ni `.npmrc`; estan en
64
64
  `.gitignore`.
65
65
  - Rota cualquier credencial que se haya compartido en texto plano.
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { runCli } from '../src/cli.js';
2
+ // node:sqlite sigue siendo "experimental" en Node 22, que avisa aunque ande. El
3
+ // aviso no le aporta nada al usuario, asi que se filtra aca (lo que no sea un
4
+ // warning de Node se imprime igual).
5
+ process.on('warning', (warning) => {
6
+ if (warning.name !== 'ExperimentalWarning') console.warn(warning.stack ?? warning);
7
+ });
8
+ const { runCli } = await import('../src/cli.js');
3
9
 
4
10
  runCli(process.argv.slice(2))
5
11
  .then((code) => {
@@ -10,23 +10,28 @@ import {
10
10
  readPackageVersion,
11
11
  compareVersions,
12
12
  fetchLatestVersion,
13
+ sintetizar,
13
14
  } from '../../../core/src/index.js';
14
15
  import { parse } from '../lib/util.js';
15
16
  import { pidAlive, readRuntime } from '../lib/daemon.js';
16
17
 
17
- const OPTIONS = { home: { type: 'string' }, help: { type: 'boolean', short: 'h' } };
18
+ const OPTIONS = {
19
+ home: { type: 'string' },
20
+ voz: { type: 'boolean' },
21
+ help: { type: 'boolean', short: 'h' },
22
+ };
18
23
 
19
24
  export async function doctorCommand(argv) {
20
25
  const { values } = parse(argv, OPTIONS);
21
26
  if (values.help) {
22
- console.log('Uso: tamnora doctor [--home <path>]\n\nVerifica Node, la casa, el puerto y la base.');
27
+ console.log('Uso: tamnora doctor [--home <path>] [--voz]\n\nVerifica Node, la casa, el puerto y la base. Con --voz prueba tambien el servicio de voz.');
23
28
  return 0;
24
29
  }
25
30
 
26
31
  const [maj, min] = process.versions.node.split('.').map((n) => parseInt(n, 10));
27
- const nodeOk = maj > 22 || (maj === 22 && min >= 5);
32
+ const nodeOk = maj > 22 || (maj === 22 && min >= 13);
28
33
  const checks = [
29
- [`Node ${process.versions.node}`, nodeOk, nodeOk ? '' : 'requiere Node >= 22.5 (node:sqlite)'],
34
+ [`Node ${process.versions.node}`, nodeOk, nodeOk ? '' : 'requiere Node >= 22.13 (node:sqlite)'],
30
35
  ];
31
36
 
32
37
  const cfgOk = homeExists();
@@ -69,6 +74,10 @@ export async function doctorCommand(argv) {
69
74
  if (serverLive) checks.push([`server (pid ${rt.pid})`, true, rt.url || '']);
70
75
  checks.push([`logs`, true, serverLogPath()]);
71
76
 
77
+ // El servicio de voz es un servicio externo sin contrato: se prueba solo si se
78
+ // pide, porque toca la red y tarda un par de segundos.
79
+ if (values.voz) checks.push(await checkVoz());
80
+
72
81
  let ok = true;
73
82
  for (const [name, pass, hint] of checks) {
74
83
  console.log(`${pass ? ' OK ' : ' FALTA'} ${name}${hint ? ` -> ${hint}` : ''}`);
@@ -85,3 +94,32 @@ function isPortFree(port, host) {
85
94
  server.listen(port, host);
86
95
  });
87
96
  }
97
+
98
+ /**
99
+ * Prueba el servicio de voz sintetizando una frase. Si la voz configurada no
100
+ * responde, se prueba la voz por defecto: asi se distingue "el servicio esta
101
+ * caido" de "esta voz ya no existe".
102
+ *
103
+ * Entre intentos hay que esperar: el servicio se queja (fallo de handshake) si
104
+ * se le abren varias conexiones seguidas, y sin la pausa un fallo transitorio
105
+ * hace parecer que la voz no existe.
106
+ */
107
+ async function checkVoz() {
108
+ const voz = readApp()?.tts?.voice || '';
109
+ const etiqueta = voz ? `voz (${voz})` : 'voz';
110
+ const esperar = () => new Promise((resolve) => setTimeout(resolve, 3000));
111
+ try {
112
+ const mp3 = await sintetizar('prueba de voz', voz ? { voice: voz } : {});
113
+ return [etiqueta, true, `${mp3.length} bytes`];
114
+ } catch (error) {
115
+ const detalle = error?.message ?? error;
116
+ if (!voz) return [etiqueta, false, `el servicio no responde: ${detalle}`];
117
+ await esperar();
118
+ try {
119
+ await sintetizar('prueba de voz');
120
+ return [etiqueta, false, `esa voz no responde (${detalle}); la voz por defecto si: cambia la voz en Ajustes`];
121
+ } catch {
122
+ return [etiqueta, false, `el servicio no responde: ${detalle}`];
123
+ }
124
+ }
125
+ }
@@ -166,6 +166,7 @@ export async function replCommand(argv) {
166
166
  maxTokens: config.maxTokens,
167
167
  maxDurationMs: config.maxDurationMs,
168
168
  maxOutputTokens: config.maxOutputTokens,
169
+ promptCaching: config.promptCaching === true,
169
170
  tools: toolDefs,
170
171
  execute,
171
172
  onEvent: printer.onEvent,
@@ -119,6 +119,7 @@ export async function runCommand(argv) {
119
119
  maxTokens: config.maxTokens,
120
120
  maxDurationMs: config.maxDurationMs,
121
121
  maxOutputTokens: config.maxOutputTokens,
122
+ promptCaching: config.promptCaching === true,
122
123
  signal: controller.signal,
123
124
  tools: toolDefs,
124
125
  execute,
@@ -101,6 +101,7 @@ export async function tuiCommand(argv) {
101
101
  maxTokens: config.maxTokens,
102
102
  maxDurationMs: config.maxDurationMs,
103
103
  maxOutputTokens: config.maxOutputTokens,
104
+ promptCaching: config.promptCaching === true,
104
105
  toolOptions: {
105
106
  root: cwd,
106
107
  enabled: enabledFor(mode),
@@ -77,6 +77,7 @@ export async function runTurn({
77
77
  maxTokens,
78
78
  maxDurationMs,
79
79
  maxOutputTokens,
80
+ promptCaching,
80
81
  stallWarnMs,
81
82
  signal,
82
83
  execute,
@@ -104,6 +105,7 @@ export async function runTurn({
104
105
  maxTokens,
105
106
  maxDurationMs,
106
107
  maxOutputTokens,
108
+ promptCaching,
107
109
  stallWarnMs,
108
110
  signal,
109
111
  onEvent,
@@ -24,6 +24,7 @@ export function runTui({
24
24
  maxTokens,
25
25
  maxDurationMs,
26
26
  maxOutputTokens,
27
+ promptCaching,
27
28
  toolOptions,
28
29
  store,
29
30
  sessionId,
@@ -207,6 +208,7 @@ export function runTui({
207
208
  maxTokens,
208
209
  maxDurationMs,
209
210
  maxOutputTokens,
211
+ promptCaching,
210
212
  signal: controller.signal,
211
213
  tools: toolset.definitions,
212
214
  execute: toolset.execute,
@@ -53,7 +53,7 @@ function delay(ms) {
53
53
  return new Promise((resolve) => setTimeout(resolve, ms));
54
54
  }
55
55
 
56
- function assertAllowedUrl(raw) {
56
+ function assertAllowedUrl(raw, allowPrivate = false) {
57
57
  let parsed;
58
58
  try {
59
59
  parsed = new URL(String(raw));
@@ -63,16 +63,18 @@ function assertAllowedUrl(raw) {
63
63
  if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
64
64
  throw new Error(`Solo se admite http(s): ${raw}`);
65
65
  }
66
- if (isBlockedHost(parsed.hostname)) {
66
+ // `allowPrivate` es para abrir nuestro **propio** server local (el smoke test
67
+ // de la consola). Las tools no lo usan: ahi la guarda anti-SSRF es total.
68
+ if (!allowPrivate && isBlockedHost(parsed.hostname)) {
67
69
  throw new Error(`host bloqueado (${normalizeHost(parsed.hostname)}).`);
68
70
  }
69
71
  }
70
72
 
71
- function isBlockedNavigation(raw) {
73
+ function isBlockedNavigation(raw, allowPrivate = false) {
72
74
  try {
73
75
  const parsed = new URL(String(raw));
74
76
  if (!parsed.hostname) return parsed.protocol === 'file:';
75
- return isBlockedHost(parsed.hostname);
77
+ return !allowPrivate && isBlockedHost(parsed.hostname);
76
78
  } catch {
77
79
  return false;
78
80
  }
@@ -196,7 +198,7 @@ function describeArg(arg) {
196
198
  return String(arg.type ?? '');
197
199
  }
198
200
 
199
- async function openPage(wsUrl, url, { timeoutMs = 20000, collectMessages = true } = {}) {
201
+ async function openPage(wsUrl, url, { timeoutMs = 20000, collectMessages = true, allowPrivate = false } = {}) {
200
202
  const cdp = new CdpConnection(new WebSocket(wsUrl));
201
203
  await cdp.open();
202
204
  const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
@@ -240,7 +242,7 @@ async function openPage(wsUrl, url, { timeoutMs = 20000, collectMessages = true
240
242
  if (message.method !== 'Page.frameNavigated' || message.sessionId !== sessionId) return;
241
243
  const frame = message.params?.frame;
242
244
  if (!frame || frame.parentId || !frame.url || frame.url === 'about:blank') return;
243
- if (isBlockedNavigation(frame.url)) blockedUrl = frame.url;
245
+ if (isBlockedNavigation(frame.url, allowPrivate)) blockedUrl = frame.url;
244
246
  });
245
247
  try {
246
248
  await cdp.send('Page.navigate', { url }, sessionId);
@@ -256,7 +258,7 @@ async function openPage(wsUrl, url, { timeoutMs = 20000, collectMessages = true
256
258
  sessionId
257
259
  );
258
260
  const finalUrl = String(result?.value ?? '');
259
- if (finalUrl && isBlockedNavigation(finalUrl)) blockedUrl = finalUrl;
261
+ if (finalUrl && isBlockedNavigation(finalUrl, allowPrivate)) blockedUrl = finalUrl;
260
262
  } catch {
261
263
  /* sin runtime disponible: nos quedamos con los eventos de navegacion */
262
264
  }
@@ -287,7 +289,7 @@ function killBrowser(child) {
287
289
  }
288
290
 
289
291
  async function withBrowser(url, options, run) {
290
- assertAllowedUrl(url);
292
+ assertAllowedUrl(url, options.allowPrivate === true);
291
293
  const bin = findBrowserPath();
292
294
  if (!bin) {
293
295
  throw new Error('no encontré Chrome/Edge/Chromium; definí TAMNORA_BROWSER con la ruta al ejecutable');
@@ -315,11 +317,23 @@ async function withBrowser(url, options, run) {
315
317
  /**
316
318
  * Captura PNG de una URL. Devuelve `{ buffer, width, height, consoleErrors }`.
317
319
  */
318
- export async function captureScreenshot(url, { fullPage = false, width = 1280, height = 720, waitMs = 800, timeoutMs } = {}) {
319
- return withBrowser(url, { timeoutMs, collectMessages: true }, async ({ cdp, sessionId, messages }) => {
320
+ export async function captureScreenshot(url, { fullPage = false, width = 1280, height = 720, waitMs = 800, timeoutMs, allowPrivate = false, preScript = '', colorScheme = '' } = {}) {
321
+ return withBrowser(url, { timeoutMs, collectMessages: true, allowPrivate }, async ({ cdp, sessionId, messages }) => {
320
322
  await cdp
321
323
  .send('Emulation.setDeviceMetricsOverride', { width, height, deviceScaleFactor: 1, mobile: false }, sessionId)
322
324
  .catch(() => {});
325
+ // Para paginas que se tematizan con `prefers-color-scheme` (el sitio, no la
326
+ // consola): emula el esquema del sistema.
327
+ if (colorScheme) {
328
+ await cdp.send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: colorScheme }] }, sessionId).catch(() => {});
329
+ }
330
+ let preScriptResult = null;
331
+ if (preScript) {
332
+ const evaluado = await cdp
333
+ .send('Runtime.evaluate', { expression: preScript, returnByValue: true, awaitPromise: true }, sessionId)
334
+ .catch(() => null);
335
+ preScriptResult = evaluado?.result?.value ?? evaluado?.value ?? null;
336
+ }
323
337
  if (waitMs > 0) await delay(waitMs);
324
338
  const shot = await cdp.send(
325
339
  'Page.captureScreenshot',
@@ -327,15 +341,15 @@ export async function captureScreenshot(url, { fullPage = false, width = 1280, h
327
341
  sessionId
328
342
  );
329
343
  const errors = messages.filter((m) => m.level === 'error');
330
- return { buffer: Buffer.from(shot.data, 'base64'), width, height, consoleErrors: errors };
344
+ return { buffer: Buffer.from(shot.data, 'base64'), width, height, consoleErrors: errors, preScriptResult };
331
345
  });
332
346
  }
333
347
 
334
348
  /**
335
349
  * Abre una URL y devuelve los mensajes de consola. `{ messages, errors }`.
336
350
  */
337
- export async function readConsole(url, { waitMs = 1500, timeoutMs } = {}) {
338
- return withBrowser(url, { timeoutMs, collectMessages: true }, async ({ messages }) => {
351
+ export async function readConsole(url, { waitMs = 1500, timeoutMs, allowPrivate = false } = {}) {
352
+ return withBrowser(url, { timeoutMs, collectMessages: true, allowPrivate }, async ({ messages }) => {
339
353
  if (waitMs > 0) await delay(waitMs);
340
354
  const errors = messages.filter((m) => m.level === 'error');
341
355
  return { messages, errors };
@@ -18,8 +18,10 @@ import { createHash, randomBytes } from 'node:crypto';
18
18
  /** Token publico del cliente de Edge (via en el codigo del navegador). */
19
19
  export const TRUSTED_CLIENT_TOKEN = '6A5AA1D4EAFF4E9FB37E23D68491D6F4';
20
20
 
21
- /** Version de Edge que declaramos. Si el servicio empieza a rechazar, subirla. */
22
- export const EDGE_VERSION = process.env.TTS_EDGE_VERSION ?? '143.0.3650.75';
21
+ /** Version de Edge que declaramos (UA + `Sec-MS-GEC-Version`). Tiene que acompanar
22
+ * al endpoint que se use: el de `api.msedgeservices.com` espera un Edge moderno.
23
+ * Si el servicio empieza a rechazar, subirla o pisarla con `TTS_EDGE_VERSION`. */
24
+ export const EDGE_VERSION = process.env.TTS_EDGE_VERSION ?? '155.0.3000.102';
23
25
 
24
26
  /** Formato por defecto: MP3 a 24 kHz, 48 kbps, mono (CBR). */
25
27
  export const FORMATO_POR_DEFECTO = 'audio-24khz-48kbitrate-mono-mp3';
@@ -122,6 +124,30 @@ function marcaDeTiempo(ahora = new Date()) {
122
124
  * @returns {Promise<Buffer>} MP3.
123
125
  */
124
126
  export function sintetizar(texto, opciones = {}) {
127
+ // Estas validaciones fallan sin abrir socket, asi que las decide el llamador
128
+ // (y se propagan igual que siempre: en seco, no como promesa rechazada).
129
+ const contenido = String(texto ?? '').slice(0, MAX_TEXTO).trim();
130
+ if (!contenido) throw new Error('El texto está vacío: no hay nada que sintetizar.');
131
+ if (opciones?.signal?.aborted) throw new Error('La síntesis se canceló antes de empezar.');
132
+ return reintentar(() => sintetizarUnaVez(texto, opciones), opciones);
133
+ }
134
+
135
+ /** Reintenta mientras el fallo sea del transporte y no haya llegado audio. */
136
+ function reintentar(intento, opciones) {
137
+ const reintentos = Math.max(0, Number(opciones?.reintentos ?? 1));
138
+ const pausaMs = Math.max(0, Number(opciones?.pausaReintentoMs ?? 500));
139
+ return new Promise((resolve, reject) => {
140
+ const correr = (restante) => {
141
+ intento().then(resolve, (error) => {
142
+ if (!restante || !error?.reintento) return reject(error);
143
+ setTimeout(() => correr(restante - 1), pausaMs);
144
+ });
145
+ };
146
+ correr(reintentos);
147
+ });
148
+ }
149
+
150
+ function sintetizarUnaVez(texto, opciones = {}) {
125
151
  const {
126
152
  voice: voz = VOZ_POR_DEFECTO,
127
153
  rate = '+0%',
@@ -133,14 +159,13 @@ export function sintetizar(texto, opciones = {}) {
133
159
  } = opciones;
134
160
 
135
161
  const contenido = String(texto ?? '').slice(0, MAX_TEXTO).trim();
136
- if (!contenido) throw new Error('El texto está vacío: no hay nada que sintetizar.');
137
- if (signal?.aborted) throw new Error('La síntesis se canceló antes de empezar.');
138
162
  if (typeof WebSocket === 'undefined') {
139
163
  throw new Error('Este Node no trae WebSocket global (se necesita Node 22+).');
140
164
  }
141
165
 
142
166
  return new Promise((resolve, reject) => {
143
167
  const chunks = [];
168
+ let abrio = false;
144
169
  const ws = new WebSocket(urlSintesis(), {
145
170
  origin: ORIGEN_EDGE,
146
171
  headers: {
@@ -166,6 +191,10 @@ export function sintetizar(texto, opciones = {}) {
166
191
  } catch {
167
192
  // ya cerrado
168
193
  }
194
+ // Reintenta solo si el fallo fue del transporte antes de llegar audio: un
195
+ // corte a mitad de stream seria un audio truncado, y el servicio que
196
+ // rechaza un formato lo va a volver a rechazar.
197
+ if (!abrio && chunks.length === 0 && !signal?.aborted) error.reintento = true;
169
198
  reject(error);
170
199
  };
171
200
 
@@ -180,6 +209,7 @@ export function sintetizar(texto, opciones = {}) {
180
209
  vigilar();
181
210
 
182
211
  ws.addEventListener('open', () => {
212
+ abrio = true;
183
213
  ws.send(
184
214
  `X-Timestamp:${marcaDeTiempo()}\r\nContent-Type:application/json; charset=utf-8\r\nPath:speech.config\r\n\r\n` +
185
215
  JSON.stringify({
@@ -376,6 +376,24 @@ test('runAgent propaga maxOutputTokens al proveedor', async () => {
376
376
  assert.deepEqual(seen, [1234]);
377
377
  });
378
378
 
379
+ test('runAgent propaga promptCaching al proveedor', async () => {
380
+ const seen = [];
381
+ const provider = {
382
+ async streamChat(_config, params) {
383
+ seen.push(params.promptCaching);
384
+ return { content: 'ok', reasoning: '', toolCalls: [] };
385
+ },
386
+ };
387
+ await runAgent({
388
+ provider,
389
+ config: {},
390
+ model: 'm',
391
+ messages: [{ role: 'user', content: 'x' }],
392
+ promptCaching: true,
393
+ });
394
+ assert.deepEqual(seen, [true]);
395
+ });
396
+
379
397
  test('runAgent emite el ciclo de vida (run.start/end, tool.before/after)', async () => {
380
398
  const life = [];
381
399
  const provider = {
@@ -0,0 +1,71 @@
1
+ // Canary de los servicios no oficiales (TTS de Edge y STT de Google). Son los
2
+ // unicos que dependen de un servicio externo sin contrato: si cambian el
3
+ // protocolo o la clave, el unico sintoma real es un 403. Los tests con mocks
4
+ // no lo ven, asi que estos prueban el camino real, pero solo con TAMNORA_LIVE=1
5
+ // (piden red y a servicios que pueden estar caidos o barrerte).
6
+ //
7
+ // TAMNORA_LIVE=1 node --test harness/packages/core/test/servicios-vivos.test.js
8
+ //
9
+ // El servicio de Edge solo devuelve MP3 (los formatos riff-/raw- cierran la
10
+ // conexion antes de mandar audio), asi que el round trip se hace con ffmpeg en
11
+ // el medio para convertir el MP3 a WAV.
12
+ import { test } from 'node:test';
13
+ import assert from 'node:assert/strict';
14
+ import { spawn } from 'node:child_process';
15
+ import { generarSecMsGec, listarVoces, reconocerGoogle, sintetizar, VOZ_POR_DEFECTO } from '../src/index.js';
16
+
17
+ const LIVE = process.env.TAMNORA_LIVE === '1';
18
+ const liveOpts = LIVE ? {} : { skip: 'red real: correr con TAMNORA_LIVE=1' };
19
+
20
+ /** MP3 -> WAV 16 kHz mono con ffmpeg. `null` si no hay ffmpeg. */
21
+ async function mp3AWav(mp3) {
22
+ return new Promise((resolve) => {
23
+ const hijo = spawn(
24
+ 'ffmpeg',
25
+ ['-hide_banner', '-loglevel', 'error', '-i', 'pipe:0', '-f', 'wav', '-ar', '16000', '-ac', '1', 'pipe:1'],
26
+ { stdio: ['pipe', 'pipe', 'ignore'], windowsHide: true },
27
+ );
28
+ const salida = [];
29
+ hijo.stdout.on('data', (b) => salida.push(b));
30
+ hijo.on('error', () => resolve(null));
31
+ hijo.on('close', (code) => resolve(code === 0 && salida.length ? Buffer.concat(salida) : null));
32
+ hijo.stdin.on('error', () => {});
33
+ hijo.stdin.end(mp3);
34
+ });
35
+ }
36
+
37
+ test('sintetizar entrega un MP3 real (canary)', liveOpts, async () => {
38
+ const mp3 = await sintetizar('hola', { voice: VOZ_POR_DEFECTO });
39
+ assert.ok(Buffer.isBuffer(mp3), 'deberia devolver un Buffer');
40
+ assert.ok(mp3.length > 1000, `MP3 demasiado chico: ${mp3.length} bytes`);
41
+ assert.equal(mp3[0], 0xff, 'sin frame de MP3: el servicio cambio de formato');
42
+ });
43
+
44
+ test('el catalogo de voces responde con la voz por defecto (canary)', liveOpts, async () => {
45
+ await esperar(3000);
46
+ const voces = await listarVoces('es-');
47
+ assert.ok(Array.isArray(voces) && voces.length > 0, 'el catalogo vino vacio');
48
+ const nombres = voces.map((v) => v.ShortName ?? v.name ?? v);
49
+ assert.ok(nombres.includes(VOZ_POR_DEFECTO), `falta ${VOZ_POR_DEFECTO} en ${JSON.stringify(nombres.slice(0, 3))}`);
50
+ });
51
+
52
+ /** Espera ms antes de abrir otro socket: el servicio de Edge se queja (fallo de
53
+ * handshake) si se le abren varias conexiones seguidas. */
54
+ const esperar = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
55
+
56
+ test('ida y vuelta: el audio del TTS se entiende en el STT (canary)', liveOpts, async () => {
57
+ await esperar(3000);
58
+ const mp3 = await sintetizar('hola mundo de prueba', { voice: VOZ_POR_DEFECTO });
59
+ const wav = await mp3AWav(mp3);
60
+ if (!wav) {
61
+ // Sin ffmpeg no hay WAV con el que hacer el viaje: se probo arriba el camino
62
+ // del TTS, que es el que mas se rompe.
63
+ return;
64
+ }
65
+ const texto = await reconocerGoogle(wav, { lang: 'es-AR' });
66
+ assert.ok(/hola/.test(texto), `se entiende otra cosa: "${texto}"`);
67
+ });
68
+
69
+ test('el anti-abuso de Edge sigue generando tokens de 64 hex', () => {
70
+ assert.match(generarSecMsGec(), /^[0-9A-F]{64}$/);
71
+ });
@@ -39,3 +39,84 @@ test('sintetizar recorta el texto al tope del servicio', () => {
39
39
  const largo = 'x'.repeat(MAX_TEXTO + 100);
40
40
  assert.throws(() => sintetizar(largo, { signal: controller.signal }), /cancel/);
41
41
  });
42
+
43
+ /** WebSocket falso: `guion` dice que hace cada intento ('error' o 'audio'). */
44
+ function mockWebSocket(guion) {
45
+ const original = globalThis.WebSocket;
46
+ let intentos = 0;
47
+ globalThis.WebSocket = class {
48
+ constructor() {
49
+ this.listeners = new Map();
50
+ this.binaryType = 'arraybuffer';
51
+ const paso = guion[Math.min(intentos, guion.length - 1)];
52
+ intentos += 1;
53
+ setTimeout(() => {
54
+ // 'fallo' nunca llega a abrirse (fallo de transporte); 'error' abre la
55
+ // conexion y recien ahi falla (el servicio rechaza algo).
56
+ if (paso !== 'fallo') this.fire('open', {});
57
+ if (paso === 'error' || paso === 'fallo') {
58
+ this.fire('error', {});
59
+ return;
60
+ }
61
+ this.fire('message', new TextEncoder().encode('Path:audio\r\nFRAME').buffer);
62
+ this.fire('message', 'X-RequestId:1\r\nPath:turn.end\r\n');
63
+ }, 0);
64
+ }
65
+ addEventListener(type, fn) {
66
+ if (!this.listeners.has(type)) this.listeners.set(type, new Set());
67
+ this.listeners.get(type).add(fn);
68
+ }
69
+ removeEventListener() {}
70
+ send() {}
71
+ close() {
72
+ this.fire('close', {});
73
+ }
74
+ fire(type, arg) {
75
+ for (const fn of this.listeners.get(type) ?? []) fn({ data: arg, message: 'fake' });
76
+ }
77
+ };
78
+ return {
79
+ get intentos() {
80
+ return intentos;
81
+ },
82
+ restaurar: () => {
83
+ globalThis.WebSocket = original;
84
+ },
85
+ };
86
+ }
87
+
88
+ test('sintetizar reintenta un fallo de conexion y devuelve el audio', async () => {
89
+ const mock = mockWebSocket(['fallo', 'audio']);
90
+ try {
91
+ const mp3 = await sintetizar('hola', { pausaReintentoMs: 5 });
92
+ assert.equal(mp3.toString(), 'FRAME');
93
+ assert.equal(mock.intentos, 2);
94
+ } finally {
95
+ mock.restaurar();
96
+ }
97
+ });
98
+
99
+ test('sintetizar no reintenta cuando el fallo es del propio servicio', async () => {
100
+ // Con la conexion abierta el servicio ya decidio: volver a conectar sirve
101
+ // para un corte de red, no para un rechazo.
102
+ const mock = mockWebSocket(['error']);
103
+ try {
104
+ await assert.rejects(() => sintetizar('hola', { reintentos: 5, pausaReintentoMs: 5 }), /WebSocket/);
105
+ assert.equal(mock.intentos, 1);
106
+ } finally {
107
+ mock.restaurar();
108
+ }
109
+ });
110
+
111
+ test('sintetizar no reintenta si el usuario cancela', async () => {
112
+ const mock = mockWebSocket(['fallo']);
113
+ const controller = new AbortController();
114
+ try {
115
+ const enCurso = sintetizar('hola', { reintentos: 5, pausaReintentoMs: 5, signal: controller.signal });
116
+ controller.abort();
117
+ await assert.rejects(() => enCurso, /cancel/);
118
+ assert.ok(mock.intentos <= 1, `intentos: ${mock.intentos}`);
119
+ } finally {
120
+ mock.restaurar();
121
+ }
122
+ });
@@ -1,5 +1,11 @@
1
1
  #!/usr/bin/env node
2
- import { serveCommand } from '../src/cli.js';
2
+ // node:sqlite sigue siendo "experimental" en Node 22, que avisa aunque ande. El
3
+ // aviso no le aporta nada al usuario, asi que se filtra aca (lo que no sea un
4
+ // warning de Node se imprime igual).
5
+ process.on('warning', (warning) => {
6
+ if (warning.name !== 'ExperimentalWarning') console.warn(warning.stack ?? warning);
7
+ });
8
+ const { serveCommand } = await import('../src/cli.js');
3
9
 
4
10
  serveCommand(process.argv.slice(2))
5
11
  .then((code) => {
@@ -122,7 +122,9 @@ import {
122
122
  closeSkillForm,
123
123
  importMcp,
124
124
  importSkill,
125
+ limitsCachingEl,
125
126
  limitsDurationEl,
127
+ limitsOutputEl,
126
128
  limitsRoundsEl,
127
129
  limitsTokensEl,
128
130
  mcpForm,
@@ -201,9 +203,6 @@ import {
201
203
  trTestBtn,
202
204
  } from './voice.js';
203
205
 
204
- document.querySelectorAll('.tab[data-tab]').forEach((t) =>
205
- t.addEventListener('click', () => activateTab(t.dataset.tab))
206
- );
207
206
  moreTab.addEventListener('click', (event) => {
208
207
  event.stopPropagation();
209
208
  const open = moreMenu.hidden;
@@ -538,6 +537,8 @@ $('#limits-save')?.addEventListener('click', async () => {
538
537
  maxRounds: Math.max(0, Number(limitsRoundsEl?.value) || 0),
539
538
  maxTokens: Math.max(0, Number(limitsTokensEl?.value) || 0),
540
539
  maxDurationMs: Math.max(0, Math.round((Number(limitsDurationEl?.value) || 0) * 1000)),
540
+ maxOutputTokens: Math.max(0, Number(limitsOutputEl?.value) || 0),
541
+ promptCaching: Boolean(limitsCachingEl?.checked),
541
542
  };
542
543
  try {
543
544
  await api('/api/settings', { method: 'PUT', body });