boosterarena-mcp 0.3.1 → 0.3.2

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/LEEME.md CHANGED
@@ -77,6 +77,10 @@ ninguna de las dos cosas, pero se las traga sin distinguirlas de las que subiste
77
77
  > devuelve la misma URL y ocupa una sola vez. Para quien repite la misma ilustración en cientos
78
78
  > de clases, eso no es un detalle.
79
79
 
80
+ **Voz de Booster** — `generar_voz` crea el audio de un texto con una de las 10 voces de Booster
81
+ (las mismas del editor, iguales en cualquier máquina) y lo pone en el slide o en una capa, listo
82
+ para `sonar`. Es del plan Pro y gasta letras de la cuota mensual de la cuenta.
83
+
80
84
  **Encargos** — quitar el fondo de una imagen, el Sombrero Mágico y el Transformador se resuelven
81
85
  sin abrir el navegador.
82
86
 
package/dist/nucleo.mjs CHANGED
@@ -3339,8 +3339,8 @@ El ref "${refBase}" es el rengl\xF3n grande; cada uno es tambi\xE9n "${refBase}_
3339
3339
  name: txt(args.nombre) || "Audio",
3340
3340
  vol: AUDIO_VOL_INICIAL
3341
3341
  };
3342
- plantar(b, el, args.ref, void 0);
3343
- b.bitacora.push(`\u{1F50A} Audio \xAB${el.name}\xBB`);
3342
+ const { dentroDe } = plantar(b, el, args.ref, args.capa);
3343
+ b.bitacora.push(`\u{1F50A} Audio \xAB${el.name}\xBB${dentroDe ? ` en la capa \xAB${dentroDe}\xBB` : ""}`);
3344
3344
  return `Puesto el audio "${el.name}" (id ${el.id}). Est\xE1 FUERA del slide, como un mando: para que suene, progr\xE1malo con \`programar\` (al hacer clic \u2192 reproducir audio) o ponlo al iniciar el slide.`;
3345
3345
  }
3346
3346
  case "poner_figura": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boosterarena-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.2",
4
4
  "description": "El MCP de Booster: tu agente crea y edita presentaciones dentro de boosterarena.com",
5
5
  "keywords": [
6
6
  "mcp",
package/servidor.mjs CHANGED
@@ -32,7 +32,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
32
32
  import { createRequire } from 'node:module';
33
33
  import { tokenDeAcceso } from './sesion.mjs';
34
34
  import { abrir, carpetas, crear, equipos, guardar, listar, resolverDestino } from './proyectos.mjs';
35
- import { subirArchivo } from './subidas.mjs';
35
+ import { subirArchivo, subirBytes } from './subidas.mjs';
36
36
  import { rematarEncargo } from './encargos.mjs';
37
37
 
38
38
  // La versión se lee del package.json: escrita a mano se queda vieja en silencio, y es lo que
@@ -228,6 +228,31 @@ const HERRAMIENTAS_PROYECTO = [
228
228
  required: ['ruta'],
229
229
  },
230
230
  },
231
+ {
232
+ name: 'generar_voz',
233
+ description: 'Genera un audio con la VOZ DE BOOSTER (siempre la misma en cualquier máquina, no la del '
234
+ + 'navegador) y lo PONE en el slide o en una capa, listo para sonar. Es lo que hace el editor con '
235
+ + '«Leer en voz alta 🔊». Después prográmalo con `programar` y accion `sonar` (destino = su ref): al '
236
+ + 'tocar algo, en un clickStep, o con evento layerShown para que suene al abrirse la capa. Prefiérelo '
237
+ + 'a `leerEnVoz`, que depende de las voces del equipo donde se proyecte. ⚠️ Es del plan Pro y gasta '
238
+ + 'letras de la cuota mensual de la cuenta (2.000 por audio, 50.000 al mes).',
239
+ inputSchema: {
240
+ type: 'object',
241
+ properties: {
242
+ texto: { type: 'string', description: 'Lo que dice. En inglés. Hasta 2.000 letras.' },
243
+ voz: {
244
+ type: 'string',
245
+ description: 'La voz. EE. UU.: af_heart (mujer, la mejor), af_bella, af_nicole, am_michael (hombre), '
246
+ + 'am_fenrir, am_puck. Reino Unido: bf_emma (mujer), bf_isabella, bm_george (hombre), bm_lewis. '
247
+ + 'Sin esto, af_heart.',
248
+ },
249
+ ref: { type: 'string', description: 'Etiqueta corta para programarlo después en esta tanda. Ej: "voz_cat".' },
250
+ nombre: { type: 'string', description: 'Cómo se llama el chip del audio en el editor. Sin esto, el principio del texto.' },
251
+ capa: { type: 'string', description: 'El `ref` o el nombre de una capa para ponerlo DENTRO de ella. Vacío = en el slide.' },
252
+ },
253
+ required: ['texto'],
254
+ },
255
+ },
231
256
  {
232
257
  name: 'transformar_en',
233
258
  description: 'Hace que un objeto se CONVIERTA en otras imágenes al hacerle clic en el ▶ Player '
@@ -399,6 +424,36 @@ async function llamar(nombre, args = {}) {
399
424
  + `Dónde: ${nombre === 'crear_proyecto' ? donde : (p.team_id ? `equipo ${p.team_id}` : 'tu espacio Personal')} — se abre desde la galería de https://boosterarena.com\n`
400
425
  + `Plan de la cuenta: ${yo.plan}. ${yo.borrador.doc.length} escena(s).\n\n${resumenDelDoc(yo.borrador.doc)}`;
401
426
  }
427
+ // La voz de Booster: la MISMA función que usa el editor (`/api/voz`, que pone el candado Pro y
428
+ // descuenta las letras en el servidor), luego se sube el MP3 y se planta como un audio normal.
429
+ if (nombre === 'generar_voz') {
430
+ exigeProyecto();
431
+ const texto = String(args.texto ?? '').trim();
432
+ if (!texto) throw new Error('Falta el `texto`.');
433
+ if (texto.length > 2000) throw new Error(`El texto tiene ${texto.length} letras y el máximo son 2.000. Pártelo en dos audios.`);
434
+ const t = await token();
435
+ const r = await fetch('https://boosterarena.com/api/voz', {
436
+ method: 'POST',
437
+ headers: { Authorization: `Bearer ${t}`, 'Content-Type': 'application/json' },
438
+ body: JSON.stringify({ texto, voz: String(args.voz ?? '').trim() || 'af_heart' }),
439
+ });
440
+ if (!r.ok) {
441
+ const info = await r.json().catch(() => ({}));
442
+ const m = info.error;
443
+ throw new Error(m === 'PRO_REQUIRED' ? 'La voz de Booster es del plan Pro y esta cuenta no lo tiene. Díselo a tu usuario: se activa desde boosterarena.com. Mientras tanto, `subir_audio` con un audio propio.'
444
+ : m === 'MONTHLY_LIMIT' ? 'Se acabaron las letras de voz de este mes en esta cuenta. Mientras tanto, `subir_audio` con un audio propio.'
445
+ : m === 'VOZ_DESCONOCIDA' ? `No existe la voz "${info.voz}". Usa una de la lista de \`voz\`.`
446
+ : m === 'NO_AUTH' ? 'La sesión ya no vale. Corre `node mcp/sesion.mjs entrar`.'
447
+ : `El servidor de voz contestó ${r.status}: ${info.detail ?? m ?? 'sin detalle'}.`);
448
+ }
449
+ const restantes = Number(r.headers.get('X-Voz-Restantes') ?? -1);
450
+ const url = await subirBytes(t, Buffer.from(await r.arrayBuffer()), 'mp3');
451
+ yo.borrador.fallo = false;
452
+ const nombreChip = String(args.nombre ?? '').trim() || texto.slice(0, 28).trim();
453
+ const dijo = nucleo.ejecutar(yo.borrador, 'poner_audio', { url, ref: args.ref, nombre: nombreChip, capa: args.capa }, fabricas());
454
+ if (yo.borrador.fallo) throw new Error(dijo);
455
+ return `Voz generada y puesta. ${dijo}\nURL: ${url}${restantes >= 0 ? `\nLetras de voz que le quedan este mes a la cuenta: ${restantes}.` : ''}`;
456
+ }
402
457
  if (nombre === 'subir_imagen' || nombre === 'subir_audio') {
403
458
  const t = await token();
404
459
  const r = await subirArchivo(t, args.ruta, nombre === 'subir_imagen' ? 'imagen' : 'audio');
package/subidas.mjs CHANGED
@@ -3,8 +3,8 @@
3
3
  //
4
4
  // Es la pieza que le faltaba a la fábrica de Planificatron. Ellos generan FUERA de Booster
5
5
  // —las imágenes con Flux en su propia gráfica, la voz con edge-tts— y el resultado entra por
6
- // aquí. Booster no genera ninguna de las dos cosas (se retiró por costo, y el audio nunca se
7
- // generó) y esto no lo cambia: la fábrica no le añade capacidades a Booster, le añade un
6
+ // aquí. Booster no genera imágenes (se retiró por costo); la voz sí, desde el 2026-09-21, con
7
+ // `generar_voz`, que también sube por aquí (`subirBytes`). La fábrica no le añade capacidades a Booster, le añade un
8
8
  // PROVEEDOR DE ARCHIVOS. Un MP3 de un TTS y un MP3 que subió un maestro son el mismo elemento
9
9
  // con una URL de R2, y el documento no puede notar la diferencia.
10
10
  //
@@ -56,18 +56,7 @@ export async function subirArchivo(token, ruta, familia) {
56
56
  }
57
57
 
58
58
  const cuerpo = await readFile(ruta);
59
- const r = await fetch(SUBIDA, {
60
- method: 'POST',
61
- headers: { Authorization: `Bearer ${token}`, 'Content-Type': tipo, 'X-Ext': ext },
62
- body: cuerpo,
63
- });
64
- if (!r.ok) {
65
- let detalle = String(r.status);
66
- try { detalle = (await r.json())?.detail || detalle; } catch { /* respuesta no-JSON */ }
67
- if (r.status === 401) throw new Error('La sesión ya no vale. Corre `node mcp/sesion.mjs entrar`.');
68
- throw new Error(`No se pudo subir: ${detalle}`);
69
- }
70
- const { url } = await r.json();
59
+ const url = await subirBytes(token, cuerpo, ext);
71
60
 
72
61
  const kb = Math.round(info.size / 1024);
73
62
  const pesado = familia === 'imagen' && ['png', 'jpg', 'jpeg'].includes(ext) && info.size > AVISO_PESO;
@@ -82,3 +71,20 @@ export async function subirArchivo(token, ruta, familia) {
82
71
  : '',
83
72
  };
84
73
  }
74
+
75
+ /** Sube unos bytes ya en memoria (la voz recién generada) y devuelve su URL. Mismo camino que
76
+ * `subirArchivo`: `functions/api/upload.ts`, con el token del maestro. */
77
+ export async function subirBytes(token, cuerpo, ext) {
78
+ const r = await fetch(SUBIDA, {
79
+ method: 'POST',
80
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': TIPOS[ext] ?? 'application/octet-stream', 'X-Ext': ext },
81
+ body: cuerpo,
82
+ });
83
+ if (!r.ok) {
84
+ let detalle = String(r.status);
85
+ try { detalle = (await r.json())?.detail || detalle; } catch { /* respuesta no-JSON */ }
86
+ if (r.status === 401) throw new Error('La sesión ya no vale. Corre `node mcp/sesion.mjs entrar`.');
87
+ throw new Error(`No se pudo subir: ${detalle}`);
88
+ }
89
+ return (await r.json()).url;
90
+ }