boosterarena-mcp 0.3.3 → 0.3.4

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
@@ -50,7 +50,9 @@ habla con Booster con **tu** sesión.
50
50
  ## Qué puede hacer
51
51
 
52
52
  **Proyectos** — `listar_proyectos`, `abrir_proyecto`, `crear_proyecto`, `guardar_proyecto`,
53
- `ver_slide`. Nada se sube hasta que se llama a `guardar_proyecto`: el agente trabaja sobre un
53
+ `ver_slide`, `ir_a_slide` (volver a un slide ya hecho para corregirlo; `abrir_proyecto` también acepta
54
+ `escena` y `slide`) y `tanda` (muchas órdenes en una sola llamada: es la forma barata de armar un
55
+ slide). Nada se sube hasta que se llama a `guardar_proyecto`: el agente trabaja sobre un
54
56
  borrador y tú decides cuándo se publica.
55
57
 
56
58
  **Contenido** — poner texto, imágenes, figuras y dibujos de la biblioteca (más de 3.000
package/dist/nucleo.mjs CHANGED
@@ -1587,7 +1587,7 @@ function construirCatalogo(opts) {
1587
1587
  segundos: num('Solo con evento "time": a los cu\xE1ntos segundos.'),
1588
1588
  paso: num('Solo con evento "clickStep": en qu\xE9 n\xFAmero de clic (1, 2, 3...).'),
1589
1589
  accion: str(
1590
- "QU\xC9 pasa. abrirCapa = muestra una capa \xB7 irAEscena = salta a otra escena \xB7 irASlide = salta a un slide \xB7 cerrarCapa = cierra la capa abierta \xB7 leerEnVoz = lo lee el navegador en voz alta \xB7 abrirEnlace = abre una URL \xB7 ocultar = el objeto desaparece con una nube de humo \xB7 moverPorTrayectoria = el objeto RECORRE un camino dibujado \xB7 sonar = suena un audio que ya est\xE1 puesto en el slide o en una capa (para \xABque suene al abrirse la capa\xBB: objetivo = ese audio, evento = layerShown, destino = ese mismo audio; para \xABal tocar X\xBB: objetivo = X, destino = el audio) \xB7 reiniciarSecuencia = devuelve el slide a como empez\xF3 \xB7 quitar = le borra todo lo que tuviera programado.",
1590
+ "QU\xC9 pasa. abrirCapa = muestra una capa \xB7 irAEscena = salta a otra escena \xB7 irASlide = salta a un slide \xB7 cerrarCapa = cierra la capa abierta \xB7 leerEnVoz = lo lee el navegador en voz alta \xB7 abrirEnlace = abre una URL \xB7 ocultar = el objeto desaparece con una nube de humo \xB7 moverPorTrayectoria = el objeto RECORRE un camino dibujado \xB7 sonar = suena un audio que ya est\xE1 puesto en el slide o en una capa (para \xABque suene al abrirse la capa\xBB: objetivo = ese audio, evento = layerShown, destino = ese mismo audio; para \xABal tocar X\xBB: objetivo = X, destino = el audio) \xB7 pausarAudio / detenerAudio = pausa o detiene (vuelve al principio) ese audio; destino = el audio, vac\xEDo = el que est\xE9 sonando \xB7 reiniciarSecuencia = devuelve el slide a como empez\xF3 \xB7 quitar = le borra todo lo que tuviera programado.",
1591
1591
  {
1592
1592
  enum: [
1593
1593
  "abrirCapa",
@@ -1599,6 +1599,8 @@ function construirCatalogo(opts) {
1599
1599
  "ocultar",
1600
1600
  "moverPorTrayectoria",
1601
1601
  "sonar",
1602
+ "pausarAudio",
1603
+ "detenerAudio",
1602
1604
  "reiniciarSecuencia",
1603
1605
  "quitar"
1604
1606
  ]
@@ -1694,7 +1696,10 @@ function construirCatalogo(opts) {
1694
1696
  var PUNTO = obj({ x: num("En % del ancho del slide."), y: num("En % del alto del slide.") }, ["x", "y"]);
1695
1697
  function conLoDelMcp(lista2, fn) {
1696
1698
  const props = (nombre) => (lista2.find((t) => t.function.name === nombre)?.function.parameters).properties;
1699
+ const PEDIDO = str('Solo para `borrar` o accion "quitar": lo que te escribi\xF3 la persona pidi\xE9ndolo, COPIADO TAL CUAL (ej: "quita la programaci\xF3n del caldero 3"). Sin una petici\xF3n suya de borrar o quitar, no se borra nada. Nunca lo inventes ni lo escribas t\xFA.');
1700
+ props("borrar").pedido_del_maestro = PEDIDO;
1697
1701
  const programar = props("programar");
1702
+ programar.pedido_del_maestro = PEDIDO;
1698
1703
  programar.trayectoria = str('Con "moverPorTrayectoria" o el evento "trayectoriaTerminada": el NOMBRE del camino ("Trayectoria 1", "a la boca"). Se busca por nombre EXACTO. Un objeto puede tener varios: dale a cada uno el suyo y as\xED cada `clickStep` o cada `trayectoriaTerminada` apunta al que toca. Si el camino no existe todav\xEDa, hace falta `hasta` o `puntos` para trazarlo.');
1699
1704
  programar.hasta = { ...PUNTO, description: 'Solo con "moverPorTrayectoria": a D\xD3NDE llega el CENTRO del objeto, en % del slide. El camino se traza en recta desde donde est\xE9 el objeto \u2014 o, en un `clickStep` 2, 3\u2026, desde donde lo dej\xF3 el paso anterior. Si ya existe un camino con ese `trayectoria`, se retraza.' };
1700
1705
  programar.puntos = { type: "array", items: PUNTO, description: 'Solo con "moverPorTrayectoria": puntos por los que PASA el centro del objeto, en % del slide, para una curva o un recorrido con escalas. El \xFAltimo es la llegada (o, si mandas tambi\xE9n `hasta`, `hasta` va detr\xE1s).' };
@@ -1703,6 +1708,10 @@ function conLoDelMcp(lista2, fn) {
1703
1708
  fondo.url = str("Una imagen ya SUBIDA (con `subir_imagen`), https://. Usa esto, `busca` o `color`: uno solo.");
1704
1709
  fondo.capa = str("El `ref` o el nombre de una CAPA para ponerle el fondo a ella en vez de al slide. Cada capa puede llevar el suyo.");
1705
1710
  props("nueva_capa").fondo = str("Fondo propio de la capa: la URL https:// de una imagen ya subida o un color en hex. Sin esto, la capa deja ver el slide de debajo.");
1711
+ for (const t of lista2) {
1712
+ const p = t.function.parameters.properties;
1713
+ if (p.capa === CAPA) p.capa = str("Vac\xEDo = en el slide. Para ponerlo DENTRO de una capa: su `ref`, su id (el que ense\xF1a `ver_slide`) o su nombre exacto. La capa tiene que existir en el slide donde est\xE1s trabajando (cr\xE9ala con `nueva_capa`; para una de otro slide, ve antes con `ir_a_slide`): si no, es error.");
1714
+ }
1706
1715
  for (const t of lista2) {
1707
1716
  const p = t.function.parameters.properties;
1708
1717
  if (!t.function.name.startsWith("poner_") || !p.x || t.function.name === "poner_texto_plantilla") continue;
@@ -2058,6 +2067,78 @@ var ESQUEMAS_BOOSTERS = {
2058
2067
  ]
2059
2068
  }
2060
2069
  },
2070
+ magicshow: {
2071
+ donde: "wcfg.ext",
2072
+ cfgSchema: {
2073
+ campos: {
2074
+ title: texto("T\xEDtulo en el tel\xF3n: \xABWhat is missing?\xBB.", { max: 40 }),
2075
+ items: lista("Las cartas del show. Cada una lleva imagen, palabra o las dos.", objeto("Una carta.", {
2076
+ id: texto("Identificador propio de la carta. Si lo omites, lo pone el editor."),
2077
+ src: imagen("La imagen de la carta."),
2078
+ text: texto("La palabra de la carta.", { max: 30 }),
2079
+ anim: bool2("`src` es una animaci\xF3n Lottie (la carta se mueve sola) y no una imagen.")
2080
+ }), { min: 2, max: 12, obligatorio: true }),
2081
+ icon: objeto("El bot\xF3n m\xE1gico que se toca en clase. Sin \xE9l sale una varita.", {
2082
+ kind: opcion("Figura (imagen) o animaci\xF3n (Lottie).", ["image", "lottie"], { obligatorio: true }),
2083
+ src: texto("URL de la figura o del Lottie.", { obligatorio: true })
2084
+ }),
2085
+ names: bool2("Mostrar la palabra debajo de la imagen."),
2086
+ shuffle: bool2("Barajar las cartas en cada truco."),
2087
+ sound: bool2("Sonido del show.")
2088
+ }
2089
+ },
2090
+ ejemplo: {
2091
+ title: "What is missing?",
2092
+ names: true,
2093
+ shuffle: true,
2094
+ sound: true,
2095
+ items: [{ text: "apple" }, { text: "banana" }, { text: "orange" }, { text: "grapes" }, { text: "pear" }]
2096
+ }
2097
+ },
2098
+ pizarra: {
2099
+ donde: "wcfg.ext",
2100
+ cfgSchema: {
2101
+ campos: {
2102
+ fondo: opcion(
2103
+ "El fondo de la pizarra. \xABvidrio\xBB = cristal esmerilado sobre el slide; \xABtransparente\xBB = se escribe directo sobre el slide; \xABnegro\xBB y \xABverde\xBB = pizarr\xF3n de tiza.",
2104
+ ["blanco", "rayas", "cuadricula", "puntos", "vidrio", "transparente", "negro", "verde"]
2105
+ ),
2106
+ doc: objeto("Lo que la clase ve escrito al llegar. En el \u25B6 Player se escribe ENCIMA de una copia. Coordenadas en unidades de pizarra: 1600 \xD7 900 (la pizarra cubre el slide entero).", {
2107
+ trazos: lista("Trazos a mano.", objeto("Un trazo.", {
2108
+ id: texto("Identificador propio.", { obligatorio: true }),
2109
+ tipo: opcion("Pluma o resaltador (semitransparente).", ["pluma", "marcador"], { obligatorio: true }),
2110
+ color: color("Color de la tinta."),
2111
+ ancho: num2("Grosor en unidades de pizarra.", { min: 1, max: 120, entero: false, obligatorio: true }),
2112
+ p: lista("Los puntos, planos: x0, y0, x1, y1\u2026", num2("Una coordenada.", { entero: false }), { obligatorio: true })
2113
+ })),
2114
+ textos: lista("Textos tecleados.", objeto("Un texto.", {
2115
+ id: texto("Identificador propio.", { obligatorio: true }),
2116
+ x: num2("Borde izquierdo.", { entero: false, obligatorio: true }),
2117
+ y: num2("Borde de arriba.", { entero: false, obligatorio: true }),
2118
+ texto: texto("Lo escrito. Admite saltos de l\xEDnea.", { obligatorio: true }),
2119
+ color: color("Color del texto."),
2120
+ tam: num2("Tama\xF1o de la letra en unidades de pizarra (62 = medio).", { min: 10, max: 320, entero: false, obligatorio: true })
2121
+ })),
2122
+ imagenes: lista("Im\xE1genes pegadas.", objeto("Una imagen.", {
2123
+ id: texto("Identificador propio.", { obligatorio: true }),
2124
+ src: imagen("La imagen.", { obligatorio: true }),
2125
+ x: num2("Borde izquierdo.", { entero: false, obligatorio: true }),
2126
+ y: num2("Borde de arriba.", { entero: false, obligatorio: true }),
2127
+ w: num2("Ancho.", { entero: false, obligatorio: true }),
2128
+ h: num2("Alto.", { entero: false, obligatorio: true })
2129
+ }))
2130
+ })
2131
+ }
2132
+ },
2133
+ ejemplo: {
2134
+ fondo: "rayas",
2135
+ doc: {
2136
+ trazos: [{ id: "s1", tipo: "marcador", color: "#f59e0b", ancho: 38, p: [110, 200, 700, 200] }],
2137
+ textos: [{ id: "t1", x: 110, y: 120, texto: "Today: the past simple", color: "#111827", tam: 62 }],
2138
+ imagenes: []
2139
+ }
2140
+ }
2141
+ },
2061
2142
  fracplot: {
2062
2143
  donde: "wcfg.ext",
2063
2144
  cfgSchema: {
@@ -2890,8 +2971,27 @@ function listaDestino(b, capa) {
2890
2971
  }
2891
2972
  function capaPorNombre(b, ref) {
2892
2973
  const s = slideDe(b);
2974
+ return capaExacta(b, s, ref) ?? (b.estricto ? void 0 : s.layers.find((l) => l.name.toLowerCase().includes(ref.toLowerCase())));
2975
+ }
2976
+ function capaExacta(b, s, ref) {
2977
+ const q = ref.trim().toLowerCase();
2893
2978
  const id = b.aliasCapa.get(ref);
2894
- return s.layers.find((l) => l.id === id) ?? s.layers.find((l) => l.name.toLowerCase() === ref.toLowerCase()) ?? s.layers.find((l) => l.name.toLowerCase().includes(ref.toLowerCase()));
2979
+ return s.layers.find((l) => l.id === id) ?? s.layers.find((l) => l.id === ref) ?? s.layers.find((l) => l.name.trim().toLowerCase() === q);
2980
+ }
2981
+ function capaQueFalta(b, nombre, args) {
2982
+ const ref = txt(args.capa).trim();
2983
+ if (!ref || !nombre.startsWith("poner_") || nombre === "poner_fondo") return null;
2984
+ const s = slideDe(b);
2985
+ if (capaExacta(b, s, ref)) return null;
2986
+ const q = ref.toLowerCase();
2987
+ for (const [i, sc] of b.doc.entries()) for (const [j, sl] of sc.slides.entries()) {
2988
+ if (sl === s) continue;
2989
+ const l = sl.layers.find((x) => x.id === ref || x.name.trim().toLowerCase() === q);
2990
+ if (l) {
2991
+ return `La capa \xAB${l.name}\xBB es del slide ${j + 1} de la escena ${i + 1}, y est\xE1s trabajando en el slide ${b.slide + 1} de la escena ${b.escena + 1}. Ve antes con \`ir_a_slide\` (escena ${i + 1}, slide ${j + 1}). No puse nada.`;
2992
+ }
2993
+ }
2994
+ return `No hay ninguna capa "${ref}" en este slide (escena ${b.escena + 1}, slide ${b.slide + 1}), y no la creo: un nombre mal escrito esconder\xEDa lo que pones en una capa que nadie abre. ${s.layers.length ? `Las que hay: ${s.layers.map((l) => `\xAB${l.name}\xBB [${l.id}]`).join(", ")}.` : "Este slide no tiene capas."} Cr\xE9ala antes con \`nueva_capa\`. No puse nada.`;
2895
2995
  }
2896
2996
  function slideDelEl(b, el) {
2897
2997
  for (const sc of b.doc) for (const sl of sc.slides) {
@@ -3018,10 +3118,9 @@ function armarAccion(b, a, el) {
3018
3118
  const ref = txt(a.capa).trim();
3019
3119
  if (b.estricto) {
3020
3120
  const sl = slideDelEl(b, el) ?? s;
3021
- const id = b.aliasCapa.get(ref);
3022
- const capa2 = sl.layers.find((l) => l.id === id) ?? sl.layers.find((l) => l.name.trim().toLowerCase() === ref.toLowerCase());
3121
+ const capa2 = capaExacta(b, sl, ref);
3023
3122
  if (!capa2) {
3024
- return { accion: null, fallo: `${ref ? `No hay ninguna capa "${ref}"` : "Falta `capa`"} en el slide de ese objeto, y no la creo: un nombre mal escrito dejar\xEDa un bot\xF3n que abre una capa en blanco. ${sl.layers.length ? `Las que hay: ${sl.layers.map((l) => `\xAB${l.name}\xBB`).join(", ")}.` : "Ese slide no tiene capas."} Cr\xE9ala antes con \`nueva_capa\` o poniendo algo dentro con \`capa\`.` };
3123
+ return { accion: null, fallo: `${ref ? `No hay ninguna capa "${ref}"` : "Falta `capa`"} en el slide de ese objeto, y no la creo: un nombre mal escrito dejar\xEDa un bot\xF3n que abre una capa en blanco. ${sl.layers.length ? `Las que hay: ${sl.layers.map((l) => `\xAB${l.name}\xBB`).join(", ")}.` : "Ese slide no tiene capas."} Cr\xE9ala antes con \`nueva_capa\`.` };
3025
3124
  }
3026
3125
  return { accion: { type: "showLayer", layerId: capa2.id } };
3027
3126
  }
@@ -3108,6 +3207,15 @@ function armarAccion(b, a, el) {
3108
3207
  if (audio2.kind !== "audio") return { accion: null, fallo: `"${d}" no es un audio.` };
3109
3208
  return { accion: { type: "playSound", targetId: audio2.id } };
3110
3209
  }
3210
+ case "pausarAudio":
3211
+ case "detenerAudio": {
3212
+ const d = txt(a.destino).trim();
3213
+ const audio2 = d ? buscarEl(b, d) : el.kind === "audio" ? el : null;
3214
+ if (d && !audio2) return { accion: null, fallo: `No encuentro el audio "${d}".` };
3215
+ if (audio2 && audio2.kind !== "audio") return { accion: null, fallo: `"${d}" no es un audio.` };
3216
+ const type = txt(a.accion) === "pausarAudio" ? "pauseSound" : "stopSound";
3217
+ return { accion: { type, targetId: audio2?.id ?? "" } };
3218
+ }
3111
3219
  case "ocultar": {
3112
3220
  const d = txt(a.destino).trim();
3113
3221
  const otro = d ? buscarEl(b, d) : null;
@@ -3120,6 +3228,10 @@ function armarAccion(b, a, el) {
3120
3228
  }
3121
3229
  function ejecutar(b, nombre, args, fab) {
3122
3230
  const antes = b.creados.length;
3231
+ if (b.estricto) {
3232
+ const falta = capaQueFalta(b, nombre, args);
3233
+ if (falta) return no(b, falta);
3234
+ }
3123
3235
  const dijo = ejecutarUna(b, nombre, args, fab);
3124
3236
  return b.estricto && !b.fallo ? dijo + dejarFuera(b, nombre, args, antes) : dijo;
3125
3237
  }
@@ -3799,7 +3911,7 @@ function describirTrigger(t, b) {
3799
3911
  const cuando = t.event.type === "click" ? "al tocarlo" : t.event.type === "hover" ? "al pasarle por encima" : t.event.type === "slideStart" ? "al entrar al slide" : t.event.type === "layerShown" ? "al abrirse su capa" : t.event.type === "time" ? `a los ${t.event.seconds} s` : t.event.type === "clickStep" ? `en el clic n\xBA ${t.event.step}` : t.event.type === "motionEnd" ? "al terminar su trayectoria" : "al dispararse";
3800
3912
  const a = t.action;
3801
3913
  const nombreCapa = (id) => buscarCapa(b, id)?.name ?? "una capa";
3802
- const qu\u00E9 = a.type === "showLayer" ? `abre \xAB${nombreCapa(a.layerId)}\xBB` : a.type === "goScene" ? `salta a \xAB${b.doc.find((s) => s.id === a.sceneId)?.name ?? "otra escena"}\xBB` : a.type === "goSlide" ? "salta a otro slide" : a.type === "closeLayer" ? "cierra la capa" : a.type === "speak" ? `lee \xAB${a.text}\xBB en voz alta` : a.type === "openUrl" ? "abre un enlace" : a.type === "hideEl" ? "desaparece con humo" : a.type === "playMotion" ? "recorre su trayectoria" : a.type === "playSound" ? "suena el audio" : a.type === "resetSequence" ? "reinicia el slide" : "hace lo suyo";
3914
+ const qu\u00E9 = a.type === "showLayer" ? `abre \xAB${nombreCapa(a.layerId)}\xBB` : a.type === "goScene" ? `salta a \xAB${b.doc.find((s) => s.id === a.sceneId)?.name ?? "otra escena"}\xBB` : a.type === "goSlide" ? "salta a otro slide" : a.type === "closeLayer" ? "cierra la capa" : a.type === "speak" ? `lee \xAB${a.text}\xBB en voz alta` : a.type === "openUrl" ? "abre un enlace" : a.type === "hideEl" ? "desaparece con humo" : a.type === "playMotion" ? "recorre su trayectoria" : a.type === "playSound" ? "suena el audio" : a.type === "pauseSound" ? "pausa el audio" : a.type === "stopSound" ? "detiene el audio" : a.type === "resetSequence" ? "reinicia el slide" : "hace lo suyo";
3803
3915
  return `${cuando}, ${qu\u00E9}`;
3804
3916
  }
3805
3917
 
@@ -3840,13 +3952,22 @@ var WORDSTORM_SAMPLE = [
3840
3952
  var seq = 0;
3841
3953
  var cycleUid = () => `cs-${Date.now().toString(36)}-${seq++}`;
3842
3954
 
3955
+ // ../src/canvas/widgets/MagicShowWidget.datos.ts
3956
+ var seq2 = 0;
3957
+ var magicShowUid = () => `ms-${Date.now().toString(36)}-${seq2++}`;
3958
+ var MAGICSHOW_DEMO = ["apple", "cat", "sun", "book", "star", "fish"];
3959
+
3960
+ // ../src/canvas/widgets/PizarraWidget.datos.ts
3961
+ var pzVacio = () => ({ trazos: [], textos: [], imagenes: [] });
3962
+ var PIZARRA_DEFAULTS = { fondo: "blanco", doc: pzVacio() };
3963
+
3843
3964
  // ../src/canvas/widgets/plot2d.ts
3844
3965
  var CURVE_COLORS = ["#2563eb", "#dc2626", "#16a34a", "#d97706", "#7c3aed", "#db2777", "#0891b2", "#65a30d"];
3845
3966
 
3846
3967
  // ../src/canvas/widgets/EquationPlotterWidget.datos.ts
3847
3968
  var DEFAULT_VIEW = { cx: 0, cy: 0, scale: 46 };
3848
- var seq2 = 0;
3849
- var eid = () => `eq-${Date.now().toString(36)}-${seq2++}`;
3969
+ var seq3 = 0;
3970
+ var eid = () => `eq-${Date.now().toString(36)}-${seq3++}`;
3850
3971
  function defaultPlotterCfg() {
3851
3972
  return {
3852
3973
  eqs: [{ id: eid(), src: "y = x^2", color: CURVE_COLORS[0], visible: true }],
@@ -4126,17 +4247,17 @@ var WIDGETS = [
4126
4247
  // de la interfaz). El precio del recorte malo es peor que el de la GPU: por eso NO hay carril
4127
4248
  // barato de reserva — ver la cabecera de `quitarFondo.ts`.
4128
4249
  { type: "quitafondo", emoji: "\u{1F98E}", label: "Quita Fondos", blurb: "Arr\xE1stralo sobre una imagen y el camale\xF3n se come el fondo. Recorta con el modelo grande: separa mechones de pelo, hojas y patas.", premium: true, ready: true },
4129
- { type: "transformar", emoji: "\u{1F500}", label: "Transformador", blurb: "Arr\xE1stralo sobre un objeto y elige en qu\xE9 se convierte. En el \u25B6 Player cada clic lo transforma en la siguiente imagen \u2014de la oruga a la mariposa\u2014 y la \xFAltima lo devuelve a como estaba.", premium: false, ready: true },
4130
- { type: "magichat", emoji: "\u{1F3A9}", label: "Sombrero M\xE1gico", blurb: "Arr\xE1stralo sobre una imagen (o PNG) para volverla una silueta negra. En el \u25B6 Player, al hacer clic se revela a color con una lluvia de estrellas m\xE1gicas.", premium: false, ready: true },
4250
+ { type: "transformar", emoji: "\u{1F500}", label: "Transformador", blurb: "Arr\xE1stralo sobre un objeto y elige en qu\xE9 se convierte. En el \u25B6 Player cada clic lo transforma en la siguiente imagen \u2014de la oruga a la mariposa\u2014 y la \xFAltima lo devuelve a como estaba.", premium: true, ready: true },
4251
+ { type: "magichat", emoji: "\u{1F3A9}", label: "Sombrero M\xE1gico", blurb: "Arr\xE1stralo sobre una imagen (o PNG) para volverla una silueta negra. En el \u25B6 Player, al hacer clic se revela a color con una lluvia de estrellas m\xE1gicas.", premium: true, ready: true },
4131
4252
  // Tómbola: bombo de bingo que gira y suelta una bola (número/palabra/imagen), sin repetir.
4132
- { type: "tombola", emoji: "\u{1F3B0}", label: "T\xF3mbola", blurb: "Bombo de bingo: gira y suelta una bola al azar con un n\xFAmero, palabra o imagen. Sortea sin repetir hasta vaciarse. Ideal para rifas y elegir al azar.", premium: false, ready: true },
4253
+ { type: "tombola", emoji: "\u{1F3B0}", label: "T\xF3mbola", blurb: "Bombo de bingo: gira y suelta una bola al azar con un n\xFAmero, palabra o imagen. Sortea sin repetir hasta vaciarse. Ideal para rifas y elegir al azar.", premium: true, ready: true },
4133
4254
  // Semáforo de comportamiento: 3 luces que el maestro toca en el Player para dar feedback a la clase.
4134
4255
  { type: "trafficlight", emoji: "\u{1F6A6}", label: "Sem\xE1foro", blurb: "Sem\xE1foro de comportamiento: en el Player, toca una luz (verde/\xE1mbar/rojo) para dar feedback a la clase. El rojo dispara una alerta sonora y visual.", premium: false, ready: true },
4135
4256
  // Graficador de ecuaciones (app-native, rama de MATEMÁTICA): orbe → estudio 2D/3D.
4136
4257
  // three.js entra por lazy() solo si el maestro pulsa 3D.
4137
4258
  // Label It (app-native, rama de CIENCIAS): la lámina del maestro se vuelve actividad de etiquetar.
4138
4259
  // Sirve a biología (célula, flor, corazón), pero también a geografía, anatomía o vocabulario.
4139
- { type: "labelit", emoji: "\u{1F3F7}\uFE0F", label: "Label It \u2014 etiquetar", blurb: "Sube cualquier l\xE1mina (c\xE9lula, flor, coraz\xF3n, mapa, una cara\u2026), toca la imagen para clavar los puntos y escribe el nombre de cada parte. En el \u25B6 Player se juega de tres formas: revelar al tocar, arrastrar las palabras a su punto, o escribirlas.", premium: false, ready: true },
4260
+ { type: "labelit", emoji: "\u{1F3F7}\uFE0F", label: "Label It \u2014 etiquetar", blurb: "Sube cualquier l\xE1mina (c\xE9lula, flor, coraz\xF3n, mapa, una cara\u2026), toca la imagen para clavar los puntos y escribe el nombre de cada parte. En el \u25B6 Player se juega de tres formas: revelar al tocar, arrastrar las palabras a su punto, o escribirlas.", premium: true, ready: true },
4140
4261
  // Ciclo (app-native, rama de CIENCIAS): una rueda de etapas reemplaza cinco slides sueltas.
4141
4262
  // La rueda NO gira (gira el FOCO), así las imágenes quedan siempre derechas en la proyección.
4142
4263
  // 🍕 «Fracciones manipulables» (regletas que se cortan) SE RETIRÓ del catálogo el 2026-08-12:
@@ -4144,21 +4265,26 @@ var WIDGETS = [
4144
4265
  // armar sus láminas. El booster NO se borra —solo su tarjeta—: los slides que ya lo tengan
4145
4266
  // puesto tienen que seguir abriendo y funcionando (su render, su editor y su config siguen
4146
4267
  // cableados más abajo). Para devolverlo al catálogo, basta con volver a poner su entrada aquí.
4147
- { type: "cycle", emoji: "\u{1F504}", label: "Ciclo \u2014 rueda de etapas", blurb: "Rueda con las etapas de un proceso (metamorfosis, mitosis, ciclo del agua, fotos\xEDntesis\u2026), cada una con su imagen y su texto. En el \u25B6 Player, cada clic mueve el foco a la siguiente y el centro cuenta qu\xE9 pasa ah\xED. Puede cerrar en c\xEDrculo o ser una cadena, y los nombres pueden salir ocultos para que la clase adivine.", premium: false, ready: true },
4268
+ { type: "cycle", emoji: "\u{1F504}", label: "Ciclo \u2014 rueda de etapas", blurb: "Rueda con las etapas de un proceso (metamorfosis, mitosis, ciclo del agua, fotos\xEDntesis\u2026), cada una con su imagen y su texto. En el \u25B6 Player, cada clic mueve el foco a la siguiente y el centro cuenta qu\xE9 pasa ah\xED. Puede cerrar en c\xEDrculo o ser una cadena, y los nombres pueden salir ocultos para que la clase adivine.", premium: true, ready: true },
4269
+ // Show de Magia: «¿qué desapareció?» con teatro, sombrero y un botón mágico que elige el maestro.
4270
+ { type: "magicshow", emoji: "\u{1F3A9}", label: "Show de Magia \u2014 \xBFqu\xE9 desapareci\xF3?", blurb: "Carga varias im\xE1genes (con su palabra o sin ella) y ponlas en un escenario de teatro. En el \u25B6 Player, cada clic en el bot\xF3n m\xE1gico mete las cartas al sombrero, las baraja y una desaparece; el siguiente clic la hace reaparecer. Nunca repite hasta que salgan todas. El bot\xF3n puede ser la varita o cualquier figura o animaci\xF3n de la biblioteca.", premium: true, ready: true },
4148
4271
  // Graficador de fracciones (app-native, rama de MATEMÁTICA): HERRAMIENTA de autoría. No es una
4149
4272
  // actividad para la clase: es la moneda con la que el maestro DIBUJA las fracciones de su
4150
4273
  // lámina, una tras otra, sin salir a buscar imágenes fuera.
4151
- { type: "fracplot", emoji: "\u{1F355}", label: "Graficador de fracciones", blurb: "Escribe una fracci\xF3n en sus dos casillas \u2014abajo en cu\xE1ntos pedazos se corta, arriba cu\xE1ntos se pintan\u2014 y la dibuja al instante. Cada \xABInsertar\xBB la estampa en el slide como una figura que se mueve y se estira como cualquier imagen, y el panel se queda abierto para pedir la siguiente: 3/4, luego 2/3, luego 5/8\u2026 Pizza o barra, con la fracci\xF3n escrita debajo. Una impropia como 4/3 sale como dos pizzas.", premium: false, ready: true },
4274
+ { type: "fracplot", emoji: "\u{1F355}", label: "Graficador de fracciones", blurb: "Escribe una fracci\xF3n en sus dos casillas \u2014abajo en cu\xE1ntos pedazos se corta, arriba cu\xE1ntos se pintan\u2014 y la dibuja al instante. Cada \xABInsertar\xBB la estampa en el slide como una figura que se mueve y se estira como cualquier imagen, y el panel se queda abierto para pedir la siguiente: 3/4, luego 2/3, luego 5/8\u2026 Pizza o barra, con la fracci\xF3n escrita debajo. Una impropia como 4/3 sale como dos pizzas.", premium: true, ready: true },
4152
4275
  // QR: el puente entre el slide proyectado y el celular del alumno. Estático a
4153
4276
  // propósito (el enlace va grabado en el dibujo): cero infraestructura, cero costo
4154
4277
  // mensual y sigue funcionando dentro del .zip exportado a LearnPress.
4155
4278
  { type: "qr", emoji: "\u{1F533}", label: "QR \u2014 el que se come el enlace", blurb: "Pon un enlace y sale un c\xF3digo QR que la clase escanea con el celular: la tarea, el formulario, un video, la sala de Booster Live. Al pulsar \xABGenerar\xBB, el c\xF3digo se COME el enlace: abre la boca y se traga a bocados unos c\xF3digos QR peque\xF1os; al cerrarla, los m\xF3dulos que faltaban caen en su sitio. Color, forma de los puntos y r\xF3tulo a tu gusto.", premium: false, ready: true },
4279
+ // Pizarra (app-native): escribir a mano, teclear y pegar imágenes DENTRO del slide. Para el
4280
+ // pizarrón táctil del aula y para la clase por Zoom. Lo de la clase no toca lo guardado.
4281
+ { type: "pizarra", emoji: "\u{1F58D}\uFE0F", label: "Pizarra blanca", blurb: "Una moneda que en el \u25B6 Player abre una pizarra encima de todo el slide: escribe a mano con el dedo, el l\xE1piz o el rat\xF3n, teclea texto y pega im\xE1genes. Pluma, resaltador, borrador, colores y deshacer; la \u2715 roja la quita. Papel blanco, de rayas, cuadros, puntos, vidrio, transparente o pizarr\xF3n de tiza. Ideal para pizarras t\xE1ctiles y clases por Zoom: lo que se escribe en clase no toca tu planificaci\xF3n.", premium: false, ready: true },
4156
4282
  { type: "plotter", emoji: "\u{1F4C8}", label: "Graficador 2D \xB7 3D", blurb: "Orbe que abre un graficador de ecuaciones. En 2D: funciones, polares, param\xE9tricas, c\xEDrculos y elipses, inecuaciones, y los CORTES entre curvas marcados con sus coordenadas. En 3D: superficies z = f(x,y) que se giran con el dedo. Cada letra suelta (a, b, c) se vuelve un deslizador.", premium: true, ready: true }
4157
4283
  ];
4158
- var APP_NATIVE_BOOSTERS = ["instructions", "magicwand", "magichat", "quitafondo", "transformar", "tombola", "trafficlight", "plotter", "labelit", "cycle", "fractions", "fracplot", "qr"];
4284
+ var APP_NATIVE_BOOSTERS = ["instructions", "magicwand", "magichat", "quitafondo", "transformar", "tombola", "trafficlight", "plotter", "labelit", "cycle", "magicshow", "fractions", "fracplot", "qr", "pizarra"];
4159
4285
  var ARENAS_APP = [
4160
4286
  { type: "classification", emoji: "\u{1F6F8}", label: "Clasificaci\xF3n \xB7 Espacio", blurb: "Arrastra cada palabra a su categor\xEDa correcta. Tema espacial con OVNIs y aliens.", premium: false, ready: true },
4161
- { type: "classification-jungle", emoji: "\u{1F435}", label: "Clasificaci\xF3n \xB7 Selva", blurb: "Arrastra cada monito a su bungal\xF3 correcto. Tema selva con caba\xF1as de bamb\xFA.", premium: false, ready: true },
4287
+ { type: "classification-jungle", emoji: "\u{1F435}", label: "Clasificaci\xF3n \xB7 Selva", blurb: "Arrastra cada monito a su bungal\xF3 correcto. Tema selva con caba\xF1as de bamb\xFA.", premium: true, ready: true },
4162
4288
  { type: "gameshow", emoji: "\u{1F3AD}", label: "Game Show", blurb: "Concurso de 2 estudiantes: tel\xF3n, ruleta, preguntas con tiempo y podio.", premium: true, ready: true },
4163
4289
  { type: "race", emoji: "\u{1F3CE}\uFE0F", label: "Carrera de Karts", blurb: "Carrera estilo F\xF3rmula 1: hasta 4 jugadores eligen corredor, responden preguntas y ganan nitro.", premium: true, ready: true },
4164
4290
  { type: "pirate", emoji: "\u{1F3F4}\u200D\u2620\uFE0F", label: "Batalla Pirata", blurb: "Concurso de preguntas para TODA la clase desde sus celulares: gana oro por contestar r\xE1pido, y el que acierta abre un cofre que puede quitarle oro a un compa\xF1ero.", premium: true, ready: true },
@@ -4167,19 +4293,20 @@ var ARENAS_APP = [
4167
4293
  { type: "memory", emoji: "\u{1F0CF}", label: "Memory Game", blurb: "Encuentra las parejas ocultas. Cada carta acepta imagen, audio y texto: imagen\u2194imagen con audio, imagen\u2194palabra o palabra\u2194definici\xF3n. Individual o por equipos.", premium: false, ready: true },
4168
4294
  // Word Storm nació en we-boosters y se trajo app-native (ver APP_NATIVE_ARENAS): su ficha
4169
4295
  // ya no viene del puente, así que vive aquí como cualquier arena de la app.
4170
- { type: "booster-escape", emoji: "\u{1F9ED}", label: "Booster Escape", blurb: "Laberinto a contrarreloj con dos modos: Individual (corre por los pasillos, esquiva a los enemigos y llega a la respuesta correcta; 3 vidas) y Versus 1v1. Un toque y el explorador corre solo hasta topar. Edita las preguntas (con imagen/audio).", premium: false, ready: true },
4171
- { type: "las-vegas", emoji: "\u{1F3B0}", label: "The Fabulous Las Vegas", blurb: "Tragamonedas para construir frases: jala la palanca, los rodillos frenan uno a uno y sale una combinaci\xF3n \u2014SHE \xB7 WRITTEN \xB7 A LETTER\u2014 que la clase dice en voz alta. De 1 a 4 rodillos, y cada casilla puede ser una palabra o un dibujo.", premium: false, ready: true },
4296
+ { type: "booster-escape", emoji: "\u{1F9ED}", label: "Booster Escape", blurb: "Laberinto a contrarreloj con dos modos: Individual (corre por los pasillos, esquiva a los enemigos y llega a la respuesta correcta; 3 vidas) y Versus 1v1. Un toque y el explorador corre solo hasta topar. Edita las preguntas (con imagen/audio).", premium: true, ready: true },
4297
+ { type: "las-vegas", emoji: "\u{1F3B0}", label: "The Fabulous Las Vegas", blurb: "Tragamonedas para construir frases: jala la palanca, los rodillos frenan uno a uno y sale una combinaci\xF3n \u2014SHE \xB7 WRITTEN \xB7 A LETTER\u2014 que la clase dice en voz alta. De 1 a 4 rodillos, y cada casilla puede ser una palabra o un dibujo.", premium: true, ready: true },
4172
4298
  // Sentence Line (app-native): la hermana de Word Factory — mismo motor, pero la pieza es una
4173
4299
  // PALABRA y lo que se arma es una oración.
4174
- { type: "sentenceline", emoji: "\u{1F309}", label: "Sentence Bridge", blurb: "El puente de tablones: cada palabra es un tabl\xF3n y hay que ponerlas en orden para armar el puente. El explorador salta al tabl\xF3n que acabas de poner, y si la oraci\xF3n no es, los tablones equivocados se caen al r\xEDo. Cuando la frase queda bien, cruza al otro lado. Se parte sola por los espacios, o marcas trozos con barras \u2014\xABThe black cat / is sleeping / on the sofa\xBB\u2014 para ense\xF1ar la estructura.", premium: false, ready: true },
4300
+ { type: "sentenceline", emoji: "\u{1F309}", label: "Sentence Bridge", blurb: "El puente de tablones: cada palabra es un tabl\xF3n y hay que ponerlas en orden para armar el puente. El explorador salta al tabl\xF3n que acabas de poner, y si la oraci\xF3n no es, los tablones equivocados se caen al r\xEDo. Cuando la frase queda bien, cruza al otro lado. Se parte sola por los espacios, o marcas trozos con barras \u2014\xABThe black cat / is sleeping / on the sofa\xBB\u2014 para ense\xF1ar la estructura.", premium: true, ready: true },
4175
4301
  // Word Factory (app-native): deletrear ordenando las letras que llegan por la cinta.
4176
- { type: "wordfactory", emoji: "\u{1F3ED}", label: "Word Factory", blurb: "Una f\xE1brica de palabras: entra un dibujo y sus letras llegan revueltas por la cinta transportadora. El alumno las mete en la m\xE1quina en el orden correcto y, cuando la palabra sale bien, la m\xE1quina fabrica el objeto y lo sella. Sirve para cualquier vocabulario: sube tu dibujo y escribe su palabra.", premium: false, ready: true },
4302
+ { type: "wordfactory", emoji: "\u{1F3ED}", label: "Word Factory", blurb: "Una f\xE1brica de palabras: entra un dibujo y sus letras llegan revueltas por la cinta transportadora. El alumno las mete en la m\xE1quina en el orden correcto y, cuando la palabra sale bien, la m\xE1quina fabrica el objeto y lo sella. Sirve para cualquier vocabulario: sube tu dibujo y escribe su palabra.", premium: true, ready: true },
4177
4303
  // Mazo de Cartas (app-native): reemplaza la actividad de cartas de Wordwall. El maestro
4178
4304
  // toca el mazo y sale una carta al azar; la descarta o la devuelve al mazo.
4179
4305
  { type: "deck", emoji: "\u{1F3B4}", label: "Mazo de Cartas", blurb: "Un mazo boca abajo: toca y sale una carta al azar, volte\xE1ndose en el aire. Cada carta lleva una palabra, una pregunta o un dibujo. La descartas o la devuelves al mazo, y con un bot\xF3n se mezcla otra vez. Para conversaci\xF3n, vocabulario, verbos o tarjetas de rol.", premium: false, ready: true },
4180
- { type: "word-storm", emoji: "\u2328\uFE0F", label: "Word Storm", blurb: "Lluvia de palabras: escr\xEDbelas con el teclado para destruirlas antes de que toquen la l\xEDnea de peligro. La dificultad sube solita. Edita la lista de palabras.", premium: false, ready: true }
4306
+ { type: "word-storm", emoji: "\u2328\uFE0F", label: "Word Storm", blurb: "Lluvia de palabras: escr\xEDbelas con el teclado para destruirlas antes de que toquen la l\xEDnea de peligro. La dificultad sube solita. Edita la lista de palabras.", premium: true, ready: true }
4181
4307
  ];
4182
- var soloFicha = (e) => ({ type: e.type, emoji: e.emoji, label: e.label, blurb: e.blurb, premium: e.premium, ready: e.ready });
4308
+ var soloFicha = (e) => ({ type: e.type, emoji: e.emoji, label: e.label, blurb: e.blurb, premium: e.premium || PREMIUM_EXTERNAS.has(e.type), ready: e.ready });
4309
+ var PREMIUM_EXTERNAS = /* @__PURE__ */ new Set(["jenga", "we-spelling-jenga", "duck-shooting"]);
4183
4310
  var ALL_WIDGETS = [
4184
4311
  ...WIDGETS,
4185
4312
  ...FICHAS_BOOSTERS.filter((e) => !APP_NATIVE_BOOSTERS.includes(e.type)).map(soloFicha)
@@ -4204,8 +4331,13 @@ function construirWidget(type, manos) {
4204
4331
  guess: false,
4205
4332
  sound: true,
4206
4333
  stages: Array.from({ length: 4 }, () => ({ id: cycleUid(), text: "" }))
4207
- } } : type === "fractions" ? { ext: { mode: "add", title: "", a: "1/2", b: "1/3", denoms: FRAC_DENOMS, labels: true, sound: true } } : type === "qr" ? { ext: { ...QR_DEFAULTS, gen: 0 } } : type === "wheel" ? { names: namesDefault } : type === "timer" ? { seconds: 60 } : type === "score" ? { teams: ["Equipo A", "Equipo B"] } : type === "groups" ? { names: namesDefault, teamCount: 2 } : type === "mystery" ? { items: ["apple", "happy", "run", "book", "blue", "friend", "school", "water"] } : { dice: 1 };
4208
- const sz = ext ? ext.defaultSize : type === "trafficlight" ? { w: 16, h: 42 } : type === "labelit" ? { w: 52, h: 68 } : type === "cycle" ? { w: 66, h: 64 } : type === "fractions" ? { w: 74, h: 50 } : type === "qr" ? { w: 16, h: 33 } : { w: 7, h: 12.4 };
4334
+ } } : type === "magicshow" ? { ext: {
4335
+ names: true,
4336
+ shuffle: true,
4337
+ sound: true,
4338
+ items: MAGICSHOW_DEMO.map((text) => ({ id: magicShowUid(), text }))
4339
+ } } : type === "fractions" ? { ext: { mode: "add", title: "", a: "1/2", b: "1/3", denoms: FRAC_DENOMS, labels: true, sound: true } } : type === "qr" ? { ext: { ...QR_DEFAULTS, gen: 0 } } : type === "pizarra" ? { ext: { ...PIZARRA_DEFAULTS, doc: pzVacio() } } : type === "wheel" ? { names: namesDefault } : type === "timer" ? { seconds: 60 } : type === "score" ? { teams: ["Equipo A", "Equipo B"] } : type === "groups" ? { names: namesDefault, teamCount: 2 } : type === "mystery" ? { items: ["apple", "happy", "run", "book", "blue", "friend", "school", "water"] } : { dice: 1 };
4340
+ const sz = ext ? ext.defaultSize : type === "trafficlight" ? { w: 16, h: 42 } : type === "labelit" ? { w: 52, h: 68 } : type === "cycle" ? { w: 66, h: 64 } : type === "magicshow" ? { w: 62, h: 62 } : type === "fractions" ? { w: 74, h: 50 } : type === "qr" ? { w: 16, h: 33 } : { w: 7, h: 12.4 };
4209
4341
  const pos = sz.h > 55 ? { x: (100 - sz.w) / 2, y: (100 - sz.h) / 2 } : p;
4210
4342
  return { ok: true, el: { id: manos.uid("wdg"), kind: "widget", widget: type, x: pos.x, y: pos.y, w: sz.w, h: sz.h, wcfg } };
4211
4343
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "boosterarena-mcp",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "description": "El MCP de Booster: tu agente crea y edita presentaciones dentro de boosterarena.com",
5
5
  "keywords": [
6
6
  "mcp",
package/proyectos.mjs CHANGED
@@ -154,7 +154,12 @@ export async function resolverDestino(token, { equipo, carpeta }) {
154
154
  if (import.meta.url === pathToFileURL(process.argv[1]).href) {
155
155
  const YO = `node ${process.argv[1]}`;
156
156
  const [, , orden, ...resto] = process.argv;
157
- const token = await tokenDeAcceso();
157
+ const token = await tokenDeAcceso().catch((e) => {
158
+ console.error(`
159
+ ✕ ${e.message}
160
+ `);
161
+ process.exit(1);
162
+ });
158
163
  if (!token) {
159
164
  console.error('\n ✕ No hay sesión. Entra primero con `sesion.mjs entrar`.\n');
160
165
  process.exit(1);
package/servidor.mjs CHANGED
@@ -30,7 +30,7 @@ import { join } from 'node:path';
30
30
  import { existsSync } from 'node:fs';
31
31
  import { fileURLToPath, pathToFileURL } from 'node:url';
32
32
  import { createRequire } from 'node:module';
33
- import { tokenDeAcceso } from './sesion.mjs';
33
+ import { alRenovar, tokenDeAcceso } from './sesion.mjs';
34
34
  import { abrir, carpetas, crear, equipos, guardar, listar, resolverDestino } from './proyectos.mjs';
35
35
  import { subirArchivo, subirBytes } from './subidas.mjs';
36
36
  import { rematarEncargo } from './encargos.mjs';
@@ -104,6 +104,10 @@ const fabricas = () => ({
104
104
  nuevaArena: (t) => nucleo.construirArena(t, manos()),
105
105
  });
106
106
 
107
+ // Cada refresco de la sesión queda en el registro. Lo normal es ~1 por hora: si salen muchos
108
+ // seguidos, algo vuelve a renovar de más (ver `tokenDeAcceso()`).
109
+ alRenovar(({ refrescos, expira }) => aviso(`sesión renovada (#${refrescos}), vale hasta ${expira}`));
110
+
107
111
  async function token() {
108
112
  const t = await tokenDeAcceso();
109
113
  if (!t) throw new Error('No hay sesión en este equipo. Corre `node mcp/sesion.mjs entrar` y vuelve a arrancar el agente.');
@@ -146,6 +150,10 @@ function herramientasDelLienzo() {
146
150
  }));
147
151
  }
148
152
 
153
+ const MAX_TANDA = 300;
154
+ // Lo que cambia de proyecto (dejaría el resto de la lista cayendo en otro) y la propia tanda.
155
+ const NO_EN_TANDA = new Set(['tanda', 'abrir_proyecto', 'crear_proyecto']);
156
+
149
157
  const HERRAMIENTAS_PROYECTO = [
150
158
  {
151
159
  name: 'listar_proyectos',
@@ -156,13 +164,34 @@ const HERRAMIENTAS_PROYECTO = [
156
164
  {
157
165
  name: 'abrir_proyecto',
158
166
  description: 'Abre un proyecto y lo deja LISTO para trabajar. A partir de aquí, todo lo que pongas '
159
- + 'cae en él. Hay que llamarlo antes que cualquier herramienta del lienzo.',
167
+ + 'cae en él. Hay que llamarlo antes que cualquier herramienta del lienzo. Sin `slide`, se trabaja '
168
+ + 'en el slide 1 de la escena 1; para CORREGIR un slide ya hecho, pásale `escena` y `slide` (o '
169
+ + 'usa `ir_a_slide` después): entonces devuelve una línea por slide y el detalle SOLO de ese, que '
170
+ + 'cuesta mucho menos leer que el proyecto entero.',
160
171
  inputSchema: {
161
172
  type: 'object',
162
- properties: { id: { type: 'string', description: 'El id que devolvió `listar_proyectos`.' } },
173
+ properties: {
174
+ id: { type: 'string', description: 'El id que devolvió `listar_proyectos`.' },
175
+ escena: { type: 'string', description: 'En qué escena trabajar: su número empezando en 1, o su nombre. Sin esto, la 1.' },
176
+ slide: { type: 'string', description: 'En qué slide trabajar: su número empezando en 1, su nombre o su id. Sin esto, el 1.' },
177
+ },
163
178
  required: ['id'],
164
179
  },
165
180
  },
181
+ {
182
+ name: 'ir_a_slide',
183
+ description: 'Cambia el slide de TRABAJO del proyecto abierto a uno que ya existe, para corregirlo '
184
+ + 'sin rehacer nada. Todo lo que pongas después (y el `capa` de las herramientas de poner) es de '
185
+ + 'ese slide. Devuelve lo que tiene, con los ids de sus objetos y capas.',
186
+ inputSchema: {
187
+ type: 'object',
188
+ properties: {
189
+ escena: { type: 'string', description: 'Su número empezando en 1, o su nombre. Sin esto, la escena en la que estás.' },
190
+ slide: { type: 'string', description: 'Su número empezando en 1 dentro de esa escena, su nombre o su id.' },
191
+ },
192
+ required: ['slide'],
193
+ },
194
+ },
166
195
  {
167
196
  name: 'crear_proyecto',
168
197
  description: 'Crea una presentación nueva, vacía, y la deja abierta para trabajar. Devuelve su id. '
@@ -285,6 +314,34 @@ const HERRAMIENTAS_PROYECTO = [
285
314
  required: [],
286
315
  },
287
316
  },
317
+ {
318
+ name: 'tanda',
319
+ description: 'Hace VARIAS órdenes en una sola llamada, en orden, sobre el proyecto abierto. Es la forma '
320
+ + 'BARATA de armar un slide: en vez de 100 llamadas sueltas (y 100 vueltas de pensar), una lista. '
321
+ + 'Cada orden es { herramienta, args } con los mismos nombres y argumentos que la herramienta suelta; '
322
+ + 'los `ref` de una orden valen para las siguientes. Se para en la primera que falle (lo de antes ya '
323
+ + 'queda puesto) y te dice cuál y por qué. No sirve para abrir o crear proyectos. Guarda al final '
324
+ + 'con `guardar_proyecto` (puede ser la última orden de la lista).',
325
+ inputSchema: {
326
+ type: 'object',
327
+ properties: {
328
+ ordenes: {
329
+ type: 'array',
330
+ description: `De 1 a ${MAX_TANDA} órdenes.`,
331
+ items: {
332
+ type: 'object',
333
+ properties: {
334
+ herramienta: { type: 'string', description: 'Nombre de la herramienta. Ej: "poner_texto".' },
335
+ args: { type: 'object', description: 'Sus argumentos, como si la llamaras sola.' },
336
+ },
337
+ required: ['herramienta'],
338
+ },
339
+ },
340
+ seguir_si_falla: { type: 'boolean', description: 'true = si una falla, sigue con las demás. Sin esto, se para.' },
341
+ },
342
+ required: ['ordenes'],
343
+ },
344
+ },
288
345
  ];
289
346
 
290
347
  // ── Ejecutar una herramienta ─────────────────────────────────────────────────
@@ -336,6 +393,8 @@ function queHace(t, doc, slide) {
336
393
  : a.type === 'goSlide' ? 'va a otro slide'
337
394
  : a.type === 'closeLayer' ? 'cierra la capa'
338
395
  : a.type === 'playSound' ? `suena ${a.targetId}`
396
+ : a.type === 'pauseSound' ? `pausa ${a.targetId || 'el audio'}`
397
+ : a.type === 'stopSound' ? `detiene ${a.targetId || 'el audio'}`
339
398
  : a.type === 'speak' ? `lee «${corto(a.text, 24)}»`
340
399
  : a.type === 'openUrl' ? `abre ${a.url}`
341
400
  : a.type === 'hideEl' ? `oculta ${a.targetId || 'el objeto'}`
@@ -378,6 +437,78 @@ function resumenDelDoc(doc, { escena, slide } = {}) {
378
437
  return filas.join('\n') || 'No hay nada con esos números de escena y slide.';
379
438
  }
380
439
 
440
+ /**
441
+ * Varias órdenes en UNA llamada. El gasto de un agente no está en cada orden sino en cada vuelta
442
+ * de pensar: un slide eran ~150 llamadas y, en cada una, el modelo relee toda la conversación.
443
+ * Con una lista, el modelo piensa una vez y el servidor ejecuta. La respuesta es una línea por
444
+ * orden, sin la bitácora, para que tampoco la vuelta engorde.
445
+ */
446
+ async function tanda(args) {
447
+ const ordenes = Array.isArray(args.ordenes) ? args.ordenes : [];
448
+ if (!ordenes.length) throw new Error('Falta `ordenes`: una lista de { herramienta, args }.');
449
+ if (ordenes.length > MAX_TANDA) throw new Error(`Son ${ordenes.length} órdenes y el máximo son ${MAX_TANDA}. Pártelas en varias tandas.`);
450
+ const filas = [];
451
+ let fallos = 0;
452
+ for (const [i, o] of ordenes.entries()) {
453
+ const h = String(o?.herramienta ?? '').trim();
454
+ try {
455
+ if (!h) throw new Error('falta `herramienta`.');
456
+ if (NO_EN_TANDA.has(h)) throw new Error(`\`${h}\` no va dentro de una tanda: llámala sola.`);
457
+ const dijo = await llamar(h, o.args && typeof o.args === 'object' ? o.args : {});
458
+ filas.push(`${i + 1} ✓ ${h} — ${String(dijo).split('\n(bitácora:')[0].split('\n')[0]}`);
459
+ } catch (e) {
460
+ fallos++;
461
+ filas.push(`${i + 1} ✕ ${h || '?'} — ${e.message}`);
462
+ if (!args.seguir_si_falla) {
463
+ if (i + 1 < ordenes.length) filas.push(`(paré aquí: las ${ordenes.length - i - 1} siguientes NO se hicieron)`);
464
+ break;
465
+ }
466
+ }
467
+ }
468
+ const texto = filas.join('\n');
469
+ if (fallos) throw new Error(`${fallos} orden(es) fallaron. Lo que salió bien ya está puesto (sin guardar).\n${texto}`);
470
+ return `${ordenes.length} orden(es) hechas.\n${texto}`;
471
+ }
472
+
473
+ /** Una línea por slide: para orientarse sin leer el proyecto entero. */
474
+ function indiceDelDoc(doc) {
475
+ const filas = [];
476
+ doc.forEach((esc, i) => {
477
+ filas.push(`Escena ${i + 1}: «${esc.name ?? 'sin nombre'}»`);
478
+ esc.slides.forEach((s, j) => {
479
+ const n = s.base.length + s.layers.reduce((t, l) => t + l.els.length, 0);
480
+ filas.push(` slide ${j + 1}${s.name ? ` «${s.name}»` : ''} — ${n} objeto(s), ${s.layers.length} capa(s)`);
481
+ });
482
+ });
483
+ return filas.join('\n');
484
+ }
485
+
486
+ /**
487
+ * Mueve el punto de trabajo a un slide que YA existe. Antes `abrir_proyecto` dejaba siempre el
488
+ * slide 1 y no había forma de ir a otro: corregir un slide hecho obligaba a rehacer el proyecto
489
+ * (Planificatron, hallazgo 23, 2026-10-12). Números desde 1, como los enseña `resumenDelDoc`.
490
+ */
491
+ function irASlide(args) {
492
+ const b = yo.borrador;
493
+ const busca = (lista, pide, actual, qué) => {
494
+ if (pide === undefined || pide === null || String(pide).trim() === '') return actual;
495
+ const q = String(pide).trim();
496
+ const n = Number(q);
497
+ if (Number.isInteger(n) && n >= 1 && n <= lista.length) return n - 1;
498
+ const i = lista.findIndex((x) => x.id === q || String(x.name ?? '').trim().toLowerCase() === q.toLowerCase());
499
+ if (i < 0) {
500
+ throw new Error(`No hay ${qué === 'escena' ? 'ninguna escena' : 'ningún slide'} "${q}". `
501
+ + `${qué === 'escena' ? 'Las' : 'Los'} que hay: ${lista.map((x, k) => `${k + 1} «${x.name ?? ''}»`).join(', ')}.`);
502
+ }
503
+ return i;
504
+ };
505
+ const e = busca(b.doc, args.escena, Math.min(b.escena, b.doc.length - 1), 'escena');
506
+ const s = busca(b.doc[e].slides, args.slide, 0, 'slide');
507
+ b.escena = e;
508
+ b.slide = s;
509
+ return { escena: e + 1, slide: s + 1 };
510
+ }
511
+
381
512
  async function llamar(nombre, args = {}) {
382
513
  // ── Proyectos ──────────────────────────────────────────────────────────────
383
514
  if (nombre === 'listar_proyectos') {
@@ -410,6 +541,8 @@ async function llamar(nombre, args = {}) {
410
541
  yo.proyecto = p;
411
542
  yo.borrador = nucleo.nuevoBorrador(Array.isArray(p.document) ? p.document : [], 0, 0, '');
412
543
  yo.borrador.estricto = true; // sin nadie delante: lo que el Robot resuelve solo, aquí se niega
544
+ const aUnSlide = nombre === 'abrir_proyecto' && (args.slide !== undefined || args.escena !== undefined);
545
+ const dondeTrabajo = aUnSlide ? irASlide({ escena: args.escena, slide: args.slide ?? 1 }) : null;
413
546
  yo.borrador.premium = yo.plan === 'pro';
414
547
  // `poner_dibujo`, `poner_fondo` y `poner_lottie` buscan en caliente.
415
548
  await Promise.all([nucleo.cargarBiblioteca(), nucleo.cargarAnimaciones()]);
@@ -422,7 +555,23 @@ async function llamar(nombre, args = {}) {
422
555
  : 'tu espacio Personal';
423
556
  return `${nombre === 'crear_proyecto' ? 'Creado' : 'Abierto'} «${p.title}»\nid: ${p.id}\n`
424
557
  + `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`
425
- + `Plan de la cuenta: ${yo.plan}. ${yo.borrador.doc.length} escena(s).\n\n${resumenDelDoc(yo.borrador.doc)}`;
558
+ + `Plan de la cuenta: ${yo.plan}. ${yo.borrador.doc.length} escena(s).\n`
559
+ + `Trabajando en: escena ${yo.borrador.escena + 1}, slide ${yo.borrador.slide + 1}.\n\n`
560
+ // Abierto en un slide: el índice de todo + el detalle SOLO de ese. Un proyecto entero con sus
561
+ // ids son miles de tokens que, para corregir un slide, no hacen falta.
562
+ + (dondeTrabajo
563
+ ? `${indiceDelDoc(yo.borrador.doc)}\n\nEl slide de trabajo:\n${resumenDelDoc(yo.borrador.doc, dondeTrabajo)}`
564
+ : resumenDelDoc(yo.borrador.doc));
565
+ }
566
+ if (nombre === 'tanda') {
567
+ exigeProyecto();
568
+ return tanda(args);
569
+ }
570
+ if (nombre === 'ir_a_slide') {
571
+ exigeProyecto();
572
+ const { escena, slide } = irASlide(args);
573
+ return `Ahora trabajas en la escena ${escena}, slide ${slide}. Lo que pongas va aquí; guarda al terminar.\n\n`
574
+ + resumenDelDoc(yo.borrador.doc, { escena, slide });
426
575
  }
427
576
  // La voz de Booster: la MISMA función que usa el editor (`/api/voz`, que pone el candado Pro y
428
577
  // descuenta las letras en el servidor), luego se sube el MP3 y se planta como un audio normal.
@@ -522,7 +671,14 @@ ${r.aviso}` : '');
522
671
  // ── El lienzo: lo hace el MISMO ejecutor que el Robot del navegador ─────────
523
672
  exigeProyecto();
524
673
  yo.borrador.fallo = false;
525
- const dijo = nucleo.ejecutar(yo.borrador, nombre, args, fabricas());
674
+ // Lo que pidió la persona vale SOLO para esta orden: si se quedara puesto, un «quita esto» de
675
+ // hace diez órdenes abriría el cerrojo para cualquier `borrar` posterior.
676
+ yo.borrador.pedido = String(args.pedido_del_maestro ?? '');
677
+ let dijo;
678
+ try { dijo = nucleo.ejecutar(yo.borrador, nombre, args, fabricas()); } finally { yo.borrador.pedido = ''; }
679
+ if (yo.borrador.fallo && !args.pedido_del_maestro && (nombre === 'borrar' || args.accion === 'quitar')) {
680
+ throw new Error(`${dijo} Si la persona SÍ te lo pidió, vuelve a llamar pasando su mensaje tal cual en \`pedido_del_maestro\`.`);
681
+ }
526
682
  // ⚠️ UN RECHAZO ES UN ERROR, NO UN ÉXITO CON MALAS NOTICIAS. El ejecutor contesta con una frase
527
683
  // también cuando se niega («No existe ninguna arena…»), porque al Robot del navegador le basta
528
684
  // leerla. A una fábrica sin nadie delante, no: la contaba como hecha y el slide quedaba vacío
package/sesion.mjs CHANGED
@@ -73,12 +73,16 @@ function cliente() {
73
73
  }
74
74
 
75
75
  // ── La credencial en disco ───────────────────────────────────────────────────
76
- // Se guarda el `refresh_token`, no el de acceso: el de acceso caduca en una hora y se renueva
77
- // solo. Este archivo ES una llave, así que va a 600 (solo su dueño), igual que `~/.claude`.
76
+ // Se guarda el `refresh_token` y, junto a él, el token de acceso con su caducidad. Este archivo
77
+ // ES una llave, así que va a 600 (solo su dueño), igual que `~/.claude`.
78
+ // El de acceso va también al archivo para que dos procesos (el servidor y un `quien` suelto, o
79
+ // dos agentes) se lo PRESTEN en vez de renovar cada uno por su lado.
78
80
  function guardar(sesion) {
79
81
  if (!existsSync(CARPETA)) mkdirSync(CARPETA, { recursive: true, mode: 0o700 });
80
82
  const datos = {
81
83
  refresh_token: sesion.refresh_token,
84
+ access_token: sesion.access_token,
85
+ expira: sesion.expires_at ?? Math.floor(Date.now() / 1000) + (sesion.expires_in ?? 3600),
82
86
  correo: sesion.user?.email ?? null,
83
87
  guardado: new Date().toISOString(),
84
88
  };
@@ -91,15 +95,91 @@ export function leerCredencial() {
91
95
  try { return JSON.parse(readFileSync(CREDENCIAL, 'utf8')); } catch { return null; }
92
96
  }
93
97
 
94
- /** Devuelve un token de acceso FRESCO, o null si no hay sesión. Es lo que usará el MCP. */
98
+ // ── El token de acceso: se RENUEVA SOLO CUANDO VA A CADUCAR ───────────────────
99
+ // ⚠️ Antes se llamaba a `refreshSession` en CADA orden. Un slide con capas son ~150 órdenes, y
100
+ // Supabase limita los refrescos por IP: a mitad de tanda contestaba con error, este código se lo
101
+ // tragaba (`return null`) y el agente veía «No hay sesión» / «ya no vale». Minutos después volvía
102
+ // solo. Lo destapó el agente de una coach fabricando boosters (Planificatron, hallazgo 22,
103
+ // 2026-10-12). Ahora:
104
+ // · el token vive en memoria (y en el archivo) y se reutiliza mientras le queden > 5 minutos;
105
+ // · UN solo refresco a la vez: las órdenes que llegan mientras tanto esperan al mismo;
106
+ // · si el refresco falla, se dice el error REAL de Supabase, no «no hay sesión».
107
+ const MARGEN_S = 5 * 60;
108
+ let enMemoria = null; // { access_token, expira, refresh_token }
109
+ let enCurso = null; // la promesa del refresco que está en marcha, si hay uno
110
+ let refrescos = 0;
111
+ let alRenovarFn = null;
112
+
113
+ /** Para que el servidor apunte en su bitácora cada refresco (debería ser ~1 por hora). */
114
+ export function alRenovar(fn) { alRenovarFn = fn; }
115
+
116
+ const ahoraS = () => Date.now() / 1000;
117
+ const vale = (c) => !!c?.access_token && Number(c.expira) - ahoraS() > MARGEN_S;
118
+ const esperar = (ms) => new Promise((r) => setTimeout(r, ms));
119
+
120
+ function describir(error) {
121
+ if (!error) return 'Supabase no devolvió sesión';
122
+ const partes = [error.message || String(error)];
123
+ if (error.code) partes.push(`[${error.code}]`);
124
+ if (error.status) partes.push(`(HTTP ${error.status})`);
125
+ return partes.join(' ');
126
+ }
127
+
128
+ async function renovar(cred) {
129
+ let ultimo = null;
130
+ let refresh = cred.refresh_token;
131
+ for (let intento = 0; intento < 3; intento++) {
132
+ const { data, error } = await cliente().auth.refreshSession({ refresh_token: refresh });
133
+ if (!error && data?.session) {
134
+ guardar(data.session); // el refresh token ROTA: si no se reescribe, la próxima vez falla
135
+ enMemoria = {
136
+ access_token: data.session.access_token,
137
+ expira: data.session.expires_at,
138
+ refresh_token: data.session.refresh_token,
139
+ };
140
+ refrescos++;
141
+ alRenovarFn?.({ refrescos, expira: new Date(enMemoria.expira * 1000).toISOString() });
142
+ return enMemoria.access_token;
143
+ }
144
+ ultimo = error;
145
+ // ¿Lo renovó otro proceso mientras tanto? Entonces el archivo ya trae uno bueno.
146
+ const otra = leerCredencial();
147
+ if (otra?.refresh_token && otra.refresh_token !== refresh) {
148
+ if (vale(otra)) {
149
+ enMemoria = { access_token: otra.access_token, expira: otra.expira, refresh_token: otra.refresh_token };
150
+ return otra.access_token;
151
+ }
152
+ refresh = otra.refresh_token;
153
+ continue;
154
+ }
155
+ // Un 4xx que no es límite de peticiones es definitivo (sesión cerrada, token revocado):
156
+ // reintentar no lo arregla. Red caída, 429 y 5xx sí merecen otra vuelta.
157
+ const st = error?.status;
158
+ if (st && st >= 400 && st < 500 && st !== 429) break;
159
+ await esperar(1000 * 2 ** intento);
160
+ }
161
+ // Si el token que ya tenemos aún no ha caducado del todo, mejor usarlo que tumbar la orden.
162
+ if (cred.access_token && Number(cred.expira) - ahoraS() > 30) return cred.access_token;
163
+ throw new Error(`La sesión de ${cred.correo ?? 'este equipo'} no se pudo renovar: ${describir(ultimo)}. `
164
+ + 'Si persiste, vuelve a entrar con `npx boosterarena-mcp entrar`.');
165
+ }
166
+
167
+ /**
168
+ * Devuelve un token de acceso válido, o null si en este equipo no hay sesión.
169
+ * Si hay sesión pero Supabase no la renueva, LANZA con el error real.
170
+ */
95
171
  export async function tokenDeAcceso() {
96
172
  const cred = leerCredencial();
97
- if (!cred?.refresh_token) return null;
98
- const sb = cliente();
99
- const { data, error } = await sb.auth.refreshSession({ refresh_token: cred.refresh_token });
100
- if (error || !data.session) return null;
101
- guardar(data.session); // el refresh token ROTA: si no se reescribe, la próxima vez falla
102
- return data.session.access_token;
173
+ if (!cred?.refresh_token) { enMemoria = null; return null; }
174
+ // Otra entrada (u otro proceso que renovó) cambió el archivo: lo de memoria ya no es lo vigente.
175
+ if (enMemoria && enMemoria.refresh_token !== cred.refresh_token) enMemoria = null;
176
+ if (vale(enMemoria)) return enMemoria.access_token;
177
+ if (vale(cred)) {
178
+ enMemoria = { access_token: cred.access_token, expira: Number(cred.expira), refresh_token: cred.refresh_token };
179
+ return cred.access_token;
180
+ }
181
+ enCurso ??= renovar(cred).finally(() => { enCurso = null; });
182
+ return enCurso;
103
183
  }
104
184
 
105
185
  // ── Abrir el navegador ───────────────────────────────────────────────────────
@@ -246,7 +326,8 @@ function escuchar(puerto, estado) {
246
326
  export async function quien() {
247
327
  const cred = leerCredencial();
248
328
  if (!cred) { console.log(` No hay sesión en este equipo. Usa: ${YO} entrar`); return; }
249
- const token = await tokenDeAcceso();
329
+ let token;
330
+ try { token = await tokenDeAcceso(); } catch (e) { console.log(` ✕ ${e.message}`); return; }
250
331
  if (!token) { console.log(` La sesión de ${cred.correo ?? 'este equipo'} ya no vale. Vuelve a entrar.`); return; }
251
332
  const r = await fetch(`${SUPABASE_URL}/auth/v1/user`, {
252
333
  headers: { apikey: SUPABASE_KEY, Authorization: `Bearer ${token}` },