boosterarena-mcp 0.3.1 → 0.3.3

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
@@ -1712,6 +1712,8 @@ function conLoDelMcp(lista2, fn) {
1712
1712
  const recolocar = props("recolocar");
1713
1713
  recolocar.x = num("Nuevo borde izquierdo en %. Puede ser NEGATIVO o pasar de 100 para dejar el objeto FUERA del slide y que entre con una trayectoria (el monstruo que entra solo: x = -50).");
1714
1714
  recolocar.y = num("Nuevo borde superior en %. Tambi\xE9n puede quedar fuera del slide, como `x`.");
1715
+ props("poner_figura").alto = num("Alto en % del alto del slide, independiente del ancho. Para los paneles de capa: una elipse blanca de w 82 \xD7 alto 77, o un rect de 83 \xD7 71. Sin esto, la figura guarda su proporci\xF3n de siempre.");
1716
+ recolocar.alto = num("Nuevo alto en %: para cajas (Revelador, Ciclo, QR\u2026), zonas calientes y FIGURAS, independiente del ancho.");
1715
1717
  props("cambiar_estilo").gris_al_tocar = bool("true = en el \u25B6 Player, al tocar el objeto se vuelve gris (y otro toque le devuelve el color). Le dice al coach qu\xE9 botones ya us\xF3. false lo quita.");
1716
1718
  const i = lista2.findIndex((t) => t.function.name === "poner_emoji");
1717
1719
  lista2.splice(i + 1, 0, fn(
@@ -3339,8 +3341,8 @@ El ref "${refBase}" es el rengl\xF3n grande; cada uno es tambi\xE9n "${refBase}_
3339
3341
  name: txt(args.nombre) || "Audio",
3340
3342
  vol: AUDIO_VOL_INICIAL
3341
3343
  };
3342
- plantar(b, el, args.ref, void 0);
3343
- b.bitacora.push(`\u{1F50A} Audio \xAB${el.name}\xBB`);
3344
+ const { dentroDe } = plantar(b, el, args.ref, args.capa);
3345
+ b.bitacora.push(`\u{1F50A} Audio \xAB${el.name}\xBB${dentroDe ? ` en la capa \xAB${dentroDe}\xBB` : ""}`);
3344
3346
  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
3347
  }
3346
3348
  case "poner_figura": {
@@ -3354,7 +3356,9 @@ El ref "${refBase}" es el rengl\xF3n grande; cada uno es tambi\xE9n "${refBase}_
3354
3356
  x: pct(args.x, 40),
3355
3357
  y: pct(args.y, 35),
3356
3358
  w,
3357
- h: def.alto === "cuadrado" ? squareH(w) : squareH(w) * def.alto,
3359
+ // `alto` libre solo desde el MCP: los paneles de capa de los maestros son elipses de 82 × 77
3360
+ // y rectángulos de 83 × 71, estirados a mano en el editor (Planificatron, corpus 2026-09-30).
3361
+ h: b.estricto && num3(args.alto, 0) > 0 ? pct(args.alto, 20) : def.alto === "cuadrado" ? squareH(w) : squareH(w) * def.alto,
3358
3362
  fill: txt(args.color) || def.color,
3359
3363
  strokeW: 0,
3360
3364
  strokeStyle: "solid",
@@ -3655,7 +3659,7 @@ Pide \`describir("${el.widget}")\` para ver la forma exacta y vuelve a intentarl
3655
3659
  case "recolocar": {
3656
3660
  const el = buscarEl(b, txt(args.objetivo));
3657
3661
  if (!el) return no(b, `No encuentro ning\xFAn objeto "${txt(args.objetivo)}".`);
3658
- const altoLibre = el.kind === "widget" && !esMoneda(el) || !!el.hotspot;
3662
+ const altoLibre = el.kind === "widget" && !esMoneda(el) || !!el.hotspot || !!b.estricto && el.kind === "shape";
3659
3663
  if (num3(args.alto, 0) > 0 && !altoLibre) {
3660
3664
  return no(b, `${describirEl(el)} no acepta \`alto\`: se deformar\xEDa. Solo las cajas (Revelador, Ciclo, Label It, QR\u2026) y las zonas calientes. Cambia su tama\xF1o con \`w\` y el alto le sigue.`);
3661
3665
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boosterarena-mcp",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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
+ }