@productmaker/mcp 0.4.0 → 0.8.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 (3) hide show
  1. package/dist/http.js +292 -51
  2. package/dist/stdio.js +292 -51
  3. package/package.json +1 -1
package/dist/http.js CHANGED
@@ -496,6 +496,92 @@ __name(translateError, "translateError");
496
496
  import { z } from "zod";
497
497
  var Lang = z.string().min(2).max(10);
498
498
  var Country = z.string().min(2).max(3);
499
+ var VideoPresetEnum = z.enum([
500
+ "auto",
501
+ "ugc_dynamic",
502
+ "ugc_unboxing_chaos",
503
+ "ugc_curiosity_reveal",
504
+ "ugc_listicle_3",
505
+ "ugc_skeptic_confession",
506
+ "ugc_demo_review",
507
+ "problem_solution",
508
+ "pixar_problem",
509
+ "pixar_sassy",
510
+ "product_showcase",
511
+ "wearable_showcase"
512
+ ]);
513
+ var VIDEO_PRESET_RESOLUTION = {
514
+ auto: null,
515
+ ugc_dynamic: {
516
+ videoStyle: "ai_dynamic",
517
+ narrativeStyle: "review",
518
+ videoModel: "veo"
519
+ },
520
+ ugc_unboxing_chaos: {
521
+ videoStyle: "ai_dynamic",
522
+ narrativeStyle: "ugc_unboxing_chaos",
523
+ videoModel: "veo"
524
+ },
525
+ ugc_curiosity_reveal: {
526
+ videoStyle: "ai_dynamic",
527
+ narrativeStyle: "ugc_curiosity_reveal",
528
+ videoModel: "veo"
529
+ },
530
+ ugc_listicle_3: {
531
+ videoStyle: "ai_dynamic",
532
+ narrativeStyle: "ugc_listicle_3",
533
+ videoModel: "kling"
534
+ },
535
+ ugc_skeptic_confession: {
536
+ videoStyle: "ai_dynamic",
537
+ narrativeStyle: "ugc_skeptic_confession",
538
+ videoModel: "veo"
539
+ },
540
+ ugc_demo_review: {
541
+ videoStyle: "ai_dynamic",
542
+ narrativeStyle: "ugc_demo_review",
543
+ videoModel: "kling"
544
+ },
545
+ problem_solution: {
546
+ videoStyle: "ai_dynamic",
547
+ narrativeStyle: "problem_product_cta",
548
+ videoModel: "kling"
549
+ },
550
+ pixar_problem: {
551
+ videoStyle: "ai_dynamic",
552
+ narrativeStyle: "animated_problem_pixar",
553
+ videoModel: "kling"
554
+ },
555
+ pixar_sassy: {
556
+ videoStyle: "ai_dynamic",
557
+ narrativeStyle: "sassy_product_pixar",
558
+ videoModel: "seedance"
559
+ },
560
+ product_showcase: {
561
+ videoStyle: "product_only",
562
+ narrativeStyle: "product_showcase",
563
+ videoModel: "veo"
564
+ },
565
+ wearable_showcase: {
566
+ videoStyle: "ugc",
567
+ narrativeStyle: "wearable_showcase",
568
+ videoModel: "veo"
569
+ }
570
+ };
571
+ function resolveVideoPreset(preset) {
572
+ if (!preset) return null;
573
+ return VIDEO_PRESET_RESOLUTION[preset] ?? null;
574
+ }
575
+ __name(resolveVideoPreset, "resolveVideoPreset");
576
+ var VideoTierEnum = z.enum([
577
+ "basic",
578
+ "standard"
579
+ ]);
580
+ function resolveVideoTier(tier) {
581
+ if (!tier) return void 0;
582
+ return tier === "basic" ? "grok" : "standard";
583
+ }
584
+ __name(resolveVideoTier, "resolveVideoTier");
499
585
  var VideoStyleEnum = z.enum([
500
586
  "ugc",
501
587
  "cartoon_3d",
@@ -525,6 +611,30 @@ var VideoModelEnum = z.enum([
525
611
  "grok",
526
612
  "kling"
527
613
  ]);
614
+ var ProductReferenceUserChoiceEnum = z.enum([
615
+ "same_product",
616
+ "variants",
617
+ "different_products",
618
+ "combo_distinct"
619
+ ]);
620
+ var ProductReferenceCandidateGroupingEnum = z.enum([
621
+ "same_product_same_variant",
622
+ "same_product_different_variants",
623
+ "different_products",
624
+ "unclear"
625
+ ]);
626
+ function buildSelectedImageUrlsHash(urls) {
627
+ const input = [
628
+ ...urls
629
+ ].sort().join("|");
630
+ let hash = 2166136261;
631
+ for (let index = 0; index < input.length; index += 1) {
632
+ hash ^= input.charCodeAt(index);
633
+ hash = Math.imul(hash, 16777619) >>> 0;
634
+ }
635
+ return `fnv1a:${hash.toString(16).padStart(8, "0")}:${urls.length}`;
636
+ }
637
+ __name(buildSelectedImageUrlsHash, "buildSelectedImageUrlsHash");
528
638
  var ImageCreativeStyleEnum = z.enum([
529
639
  "studio",
530
640
  "floating",
@@ -819,17 +929,16 @@ var CreateProductTaskInput = z2.object({
819
929
  country: Country.describe('C\xF3digo ISO del pa\xEDs objetivo. Ejemplos: "CO", "MX", "US", "AR". Define moneda, m\xE9todos de pago locales y tono. REQUERIDO \u2014 si el usuario no lo especific\xF3, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat.'),
820
930
  price: z2.string().optional().describe('Precio que se mostrar\xE1 en la landing y video. Formato libre \u2014 ej. "$89.900", "USD 29.99", "MXN 599". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar. Si te dice "t\xFA elige", om\xEDtelo y el backend sugiere uno.'),
821
931
  offer: z2.string().optional().describe('Oferta o promoci\xF3n a destacar. Ej. "2x1", "Env\xEDo gratis hoy", "50% OFF". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar (es v\xE1lido que diga "sin oferta"). Si te dice "t\xFA elige", om\xEDtelo.'),
822
- videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual del video. "ugc" = creador hablando a c\xE1mara (default, mejor CTR LATAM). "cartoon_3d" = animaci\xF3n 3D. "product_only" = sin actor. "ai_dynamic" = cinematogr\xE1fico AI. Si se omite, default "ugc".'),
823
- narrativeStyle: NarrativeStyleEnum.optional().describe('Opcional. Estructura narrativa. "review" = UGC cl\xE1sico multi-hook (default, alta conversi\xF3n). "problem_product_cta" = problema\u2192soluci\xF3n. "product_showcase" = features. "metaphor_product_cta" = met\xE1fora visual. "wearable_showcase" = para ropa/accesorios. "ugc_unboxing_chaos" = unboxing agresivo. "ugc_curiosity_reveal" = hook con producto oculto. "ugc_listicle_3" = 3 razones (30s recomendado). "ugc_skeptic_confession" = esc\xE9ptico\u2192conversi\xF3n (30s recomendado). "ugc_demo_review" = demo en uso a lo largo de d\xEDas. Si se omite, default "review".'),
824
- videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). "bytedance"/"seedance" = TikTok-style. "kling"/"wan" = alternativos. "infinitalk" = lipsync largo. "grok" = grok-imagine. Si se omite, el sistema escoge seg\xFAn costo/calidad.'),
932
+ videoPreset: VideoPresetEnum.default("auto").describe('Opcional. Tipo/narrativa del video \u2014 \xFAnico campo de "estilo" que el usuario controla (en la web es el \xFAnico dropdown). Default "auto" (la IA escoge tras analizar el producto). Opciones: "auto", "ugc_dynamic" (UGC testimonial), "ugc_unboxing_chaos" (unboxing agresivo), "ugc_curiosity_reveal" (hook con producto oculto), "ugc_listicle_3" (3 razones, ideal 30s), "ugc_skeptic_confession" (esc\xE9ptico\u2192conversi\xF3n, ideal 30s), "ugc_demo_review" (demo en uso), "problem_solution" (problema\u2192producto\u2192CTA), "pixar_problem" (problema animado 3D), "pixar_sassy" (producto que habla), "product_showcase" (solo producto, sin actor), "wearable_showcase" (ropa/accesorios puestos). Si el usuario pide algo fuera de esta lista (ej. "estilo anime"), p\xEDdele una opci\xF3n v\xE1lida.'),
933
+ videoTier: VideoTierEnum.optional().describe('Opcional. Estilo del video (en la web la etiqueta es "\xBFCalidad de video?", 2 opciones \u2014 sin escalera de calidad, son ESTILOS distintos). "basic" = "Realista" (RECOMENDADO por la web): render de 15s con audio nativo, el m\xE1s natural \u2014 S\xD3LO v\xE1lido si la duraci\xF3n del video es 15s. "standard" = "Multiescena": m\xFAltiples tomas, hasta 30s. Si el usuario no menciona estilo, OMITE este campo y el backend usa su default. Para duraci\xF3n 30s s\xF3lo aplica "standard" (Realista est\xE1 vetada).'),
825
934
  multiAngleVideos: z2.boolean().optional().describe("Opcional. Si true, genera m\xFAltiples \xE1ngulos de venta en paralelo (3-5 videos). Si false/omitido, genera 1 video del \xE1ngulo principal. Default false."),
826
- angleNarratives: NarrativeStyleEnum.array().max(3).optional().describe('Opcional. Narrativa POR \xC1NGULO (posicional). angleNarratives[i] fuerza la narrativa del \xE1ngulo en la posici\xF3n i; usa los mismos valores que narrativeStyle. Si la lista es m\xE1s corta que la cantidad de \xE1ngulos, las posiciones restantes las elige la IA. Ej: ["animated_problem_pixar","review","ugc_skeptic_confession"] para 3 \xE1ngulos distintos. Requiere multiAngleVideos:true (o videoAngleCount>1) para que haya varios \xE1ngulos. Si se omite, todas las narrativas se auto-seleccionan.'),
935
+ angleNarratives: NarrativeStyleEnum.array().max(3).optional().describe('Opcional. Narrativa POR \xC1NGULO (posicional, override avanzado). Usa los mismos identificadores de narrativa que videoPreset (ej. "animated_problem_pixar", "review", "ugc_skeptic_confession"). angleNarratives[i] fuerza la narrativa del \xE1ngulo en la posici\xF3n i; si la lista es m\xE1s corta que la cantidad de \xE1ngulos, las posiciones restantes las elige la IA. Requiere multiAngleVideos:true para que haya varios \xE1ngulos. Si se omite, todas las narrativas se auto-seleccionan seg\xFAn el producto.'),
827
936
  outputs: z2.array(z2.enum([
828
937
  "landing",
829
938
  "video",
830
939
  "image"
831
940
  ])).min(2).max(3).optional().describe('Opcional. Qu\xE9 outputs generar. Default: todos ["landing","video","image"]. Para 2 outputs: ["landing","image"] (sin video), ["video","image"] (sin landing), ["landing","video"] (sin imagen). Para 1 solo output, usa generate_landing / generate_video_creative / generate_image_creatives.')
832
- });
941
+ }).strict();
833
942
  var GetTaskStatusInput = z2.object({
834
943
  taskId: z2.string().min(1),
835
944
  waitSeconds: z2.number().int().min(0).max(120).optional()
@@ -853,8 +962,25 @@ var EditTaskDraftInput = z2.object({
853
962
  scriptNotes: z2.string().max(2e3).optional()
854
963
  })
855
964
  });
965
+ var PendingActionPayload = z2.discriminatedUnion("kind", [
966
+ z2.object({
967
+ kind: z2.literal("product_reference_confirmation"),
968
+ userChoice: ProductReferenceUserChoiceEnum
969
+ })
970
+ ]);
971
+ var ResolvePendingActionInput = z2.object({
972
+ taskId: z2.string().min(1),
973
+ action: PendingActionPayload
974
+ }).strict();
975
+ var StatusForResolveSchema = z2.object({
976
+ pendingUserAction: z2.object({
977
+ kind: z2.string(),
978
+ candidateGrouping: z2.string().optional(),
979
+ selectedImageUrls: z2.array(z2.string()).optional()
980
+ }).optional()
981
+ }).passthrough();
856
982
  function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http" } = {}) {
857
- server.tool("create_product_task", 'Crea una tarea de producto desde una o varias fotos. Por defecto genera los 3 outputs (landing + video + image creatives). Devuelve un taskId.\n\nINPUTS REQUERIDOS: images (1-5 fotos del MISMO producto; la primera es la hero), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto del chat: preg\xFAntale expl\xEDcitamente, incluso si el usuario est\xE1 en LATAM. Si te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nSi el usuario tiene varias fotos del MISMO producto (frente, lateral, detalle, uso), incl\xFAyelas todas en images[] hasta 5. Si tiene fotos de productos DIFERENTES, son llamadas separadas (una tarea por producto).\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos):\n- videoStyle, narrativeStyle, videoModel (enums \u2014 ver descripci\xF3n de cada campo)\n- multiAngleVideos (booleano)\n- outputs (subconjunto de {landing,video,image})\n- productUrl\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado (ej. "estilo anime"), p\xEDdele una opci\xF3n v\xE1lida.', CreateProductTaskInput.shape, async (input) => {
983
+ server.tool("create_product_task", 'Crea una tarea de producto desde una o varias fotos. Por defecto genera los 3 outputs (landing + video + image creatives). Devuelve un taskId.\n\nINPUTS REQUERIDOS: images (1-5 fotos del MISMO producto; la primera es la hero), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto del chat: preg\xFAntale expl\xEDcitamente, incluso si el usuario est\xE1 en LATAM. Si te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nSi el usuario tiene varias fotos del MISMO producto (frente, lateral, detalle, uso), incl\xFAyelas todas en images[] hasta 5. Si tiene fotos de productos DIFERENTES, son llamadas separadas (una tarea por producto).\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos):\n- videoPreset (tipo/narrativa del video \u2014 \xFAnico campo de estilo)\n- videoTier (estilo: basic="Realista"/standard="Multiescena")\n- multiAngleVideos (booleano)\n- angleNarratives (override per-angle de la narrativa, avanzado)\n- outputs (subconjunto de {landing,video,image})\n- productUrl\n\nGUION: para revisar/editar el guion del video antes de renderizar, usa `generate_video_creative` con `pauseForVideoScriptReview: true` en vez de este tool. Despu\xE9s de la pausa: get_video_script para leer, approve_video_script para aprobar con ediciones.\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado (ej. "estilo anime", "modelo X"), p\xEDdele una opci\xF3n v\xE1lida del enum correspondiente.', CreateProductTaskInput.shape, async (input) => {
858
984
  try {
859
985
  const imgs = await resolveImages(input.images, {
860
986
  transport
@@ -863,16 +989,19 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
863
989
  ...img,
864
990
  filename: `image-${i}.png`
865
991
  }));
992
+ const resolved = resolveVideoPreset(input.videoPreset);
993
+ const wireTier = resolveVideoTier(input.videoTier);
866
994
  const fields = flatten({
867
995
  "options.languageCode": input.language,
868
996
  "options.country": input.country,
869
997
  "options.price": input.price,
870
998
  "options.offer": input.offer,
871
- "options.videoStyle": input.videoStyle,
872
- "options.narrativeStyle": input.narrativeStyle,
873
- "options.angleNarratives": input.angleNarratives,
874
- "options.videoModel": input.videoModel,
999
+ "options.videoPreset": input.videoPreset,
1000
+ "options.videoStyle": resolved?.videoStyle,
1001
+ "options.narrativeStyle": resolved?.narrativeStyle,
1002
+ "options.videoTier": wireTier,
875
1003
  "options.multiAngleVideos": input.multiAngleVideos,
1004
+ "options.angleNarratives": input.angleNarratives,
876
1005
  "options.selectedOutputs": input.outputs,
877
1006
  url: input.productUrl
878
1007
  });
@@ -888,7 +1017,7 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
888
1017
  throw translateError(e);
889
1018
  }
890
1019
  });
891
- server.tool("get_task_status", "Devuelve el estado y URLs de outputs de una tarea. Si waitSeconds>0, hace polling hasta que cambie la fase o llegue al timeout (max 120s). Cuando la tarea queda en `done`, la respuesta incluye un segundo bloque markdown con previews inline de las im\xE1genes, links descargables al video MP4 y un enlace a Product Maker para ver el resultado completo. Use this to check on a task progress; pass waitSeconds for a near-synchronous response.", GetTaskStatusInput.shape, async (input, extra) => {
1020
+ server.tool("get_task_status", 'Devuelve el estado y URLs de outputs de una tarea. Si waitSeconds>0, hace polling hasta que cambie la fase o llegue al timeout (max 120s). Cuando la tarea queda en `done`, la respuesta incluye un segundo bloque markdown con previews inline de las im\xE1genes, links descargables al video MP4 y un enlace a Product Maker para ver el resultado completo.\n\nIMPORTANTE \u2014 `pendingUserAction`: si est\xE1 presente, el pipeline est\xE1 PARADO esperando que el usuario decida algo. Mira el `kind` (hoy: `"product_reference_confirmation"` \u2014 el clasificador no est\xE1 seguro si las fotos son variantes del mismo producto o productos distintos), mu\xE9strale al usuario `targetProductLabel` + `reason` en lenguaje natural, recoge su respuesta, y llama `resolve_pending_action` con `action: { kind: <mismo kind>, ... }` (las `options` v\xE1lidas vienen en el mismo objeto). Sin esto la tarea NUNCA avanza por m\xE1s que esperes. Use this to check on a task progress; pass waitSeconds for a near-synchronous response.', GetTaskStatusInput.shape, async (input, extra) => {
892
1021
  try {
893
1022
  const fetcher = /* @__PURE__ */ __name(() => api.getJson(`/v1/status/${encodeURIComponent(input.taskId)}`), "fetcher");
894
1023
  const signal = extra?.signal;
@@ -945,6 +1074,45 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
945
1074
  throw translateError(e);
946
1075
  }
947
1076
  });
1077
+ server.tool("resolve_pending_action", 'Resuelve una acci\xF3n que el pipeline est\xE1 esperando del usuario. La llamas cuando `get_task_status` devuelve `pendingUserAction` (sin esto la tarea NUNCA avanza por m\xE1s que esperes). El `action.kind` que mandas DEBE coincidir con el `pendingUserAction.kind` que viste en el status \u2014 es el contrato de simetr\xEDa que mantiene este tool como \xFAnica superficie de resoluci\xF3n de gates.\n\nKINDS soportados hoy:\n\n\u2022 `product_reference_confirmation` \u2014 el clasificador no est\xE1 seguro si las fotos son variantes del mismo producto o productos distintos. Payload: `userChoice` \u2208 { "same_product" (mismo producto, fotos son s\xF3lo \xE1ngulos distintos), "variants" (mismo producto, distintos colores/tallas/sabores), "combo_distinct" (son 2 productos distintos vendidos juntos como combo \u2014 marca la tarea como combo), "different_products" (productos distintos y el usuario quiere generar s\xF3lo uno \u2014 el backend bloquea hasta que selecciones im\xE1genes de un solo producto; normalmente NO es la respuesta correcta para destrabar) }. PREG\xDANTALE al usuario antes de invocar \u2014 `pendingUserAction.targetProductLabel` y `reason` te dan contexto para formularle la pregunta en lenguaje natural. Usa SOLO valores presentes en `pendingUserAction.options`.\n\nFuturos kinds (no implementados a\xFAn) se agregar\xE1n como nuevas variantes del `action` discriminated union \u2014 el flujo "lee kind del status \u2192 manda mismo kind aqu\xED" sigue igual.', ResolvePendingActionInput.shape, async (input) => {
1078
+ try {
1079
+ const raw = await api.getJson(`/v1/status/${encodeURIComponent(input.taskId)}`);
1080
+ const status = StatusForResolveSchema.parse(raw);
1081
+ const pending = status.pendingUserAction;
1082
+ if (!pending) {
1083
+ throw new Error("No hay acci\xF3n pendiente para esta tarea \u2014 llama get_task_status primero para confirmar el estado.");
1084
+ }
1085
+ if (pending.kind !== input.action.kind) {
1086
+ throw new Error(`La tarea espera una acci\xF3n de tipo "${pending.kind}" pero mandaste "${input.action.kind}". Reusa el mismo kind que viste en pendingUserAction.`);
1087
+ }
1088
+ switch (input.action.kind) {
1089
+ case "product_reference_confirmation": {
1090
+ const candidateGrouping = pending.candidateGrouping;
1091
+ const selectedImageUrls = pending.selectedImageUrls ?? [];
1092
+ if (!candidateGrouping || selectedImageUrls.length === 0) {
1093
+ throw new Error("Estado inconsistente: la tarea est\xE1 pausada pero falta candidateGrouping o selectedImageUrls.");
1094
+ }
1095
+ const r = await api.postJson(`/v1/tasks/${encodeURIComponent(input.taskId)}/confirm-product-references`, {
1096
+ productReferenceConfirmation: {
1097
+ candidateGrouping,
1098
+ userChoice: input.action.userChoice,
1099
+ // Server overwrites with its own server-side computation;
1100
+ // we send the matching MCP-mirror value so the wire body
1101
+ // is internally coherent if the server skips overwrite.
1102
+ selectedImageUrlsHash: buildSelectedImageUrlsHash(selectedImageUrls)
1103
+ }
1104
+ });
1105
+ return asText(r);
1106
+ }
1107
+ default: {
1108
+ const _exhaustive = input.action.kind;
1109
+ throw new Error(`Unsupported pending action kind: ${String(_exhaustive)}`);
1110
+ }
1111
+ }
1112
+ } catch (e) {
1113
+ throw translateError(e);
1114
+ }
1115
+ });
948
1116
  }
949
1117
  __name(registerTaskTools, "registerTaskTools");
950
1118
 
@@ -971,15 +1139,15 @@ var VideoInput = z3.object({
971
1139
  "Auto"
972
1140
  ]).describe('Relaci\xF3n de aspecto. "9:16" = vertical (TikTok/Reels \u2014 default recomendado para ads). "16:9" = horizontal (YouTube). "Auto" = decide la IA.'),
973
1141
  durationSeconds: z3.number().int().min(5).max(60).optional().describe("Opcional. Duraci\xF3n del video en segundos (5-60). Default 15s. Para hooks cortos usa 8, para storytelling usa 30."),
974
- narrativeStyle: NarrativeStyleEnum.optional().describe('Opcional. Estructura narrativa. "review" (UGC cl\xE1sico multi-hook, default), "problem_product_cta", "product_showcase", "metaphor_product_cta", "wearable_showcase", "ugc_unboxing_chaos" (unboxing agresivo), "ugc_curiosity_reveal" (hook con producto oculto), "ugc_listicle_3" (3 razones numeradas, recomendado 30s), "ugc_skeptic_confession" (esc\xE9ptico\u2192conversi\xF3n, recomendado 30s), "ugc_demo_review" (demo en uso a lo largo de d\xEDas).'),
975
- videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual. "ugc" (creador hablando, default), "cartoon_3d", "product_only", "ai_dynamic".'),
976
- videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). Ver lista de valores v\xE1lidos.'),
1142
+ videoPreset: VideoPresetEnum.default("auto").describe('Opcional. Tipo/narrativa del video \u2014 \xFAnico campo de "estilo" que el usuario controla (en la web es el \xFAnico dropdown). Default "auto" (la IA escoge tras analizar el producto). Opciones: "auto", "ugc_dynamic" (UGC cl\xE1sico testimonial multi-hook), "ugc_unboxing_chaos" (unboxing agresivo), "ugc_curiosity_reveal" (hook con producto oculto), "ugc_listicle_3" (3 razones numeradas, recomendado 30s), "ugc_skeptic_confession" (esc\xE9ptico\u2192conversi\xF3n, recomendado 30s), "ugc_demo_review" (demo en uso a lo largo de d\xEDas), "problem_solution" (problema\u2192producto\u2192CTA), "pixar_problem" (problema animado 3D estilo Pixar), "pixar_sassy" (producto que habla con personalidad), "product_showcase" (sin actor, solo producto), "wearable_showcase" (ropa/accesorios puestos).'),
1143
+ videoTier: VideoTierEnum.optional().describe('Opcional. Estilo del video (en la web la etiqueta es "\xBFCalidad de video?", 2 opciones \u2014 sin escalera de calidad, son ESTILOS distintos). "basic" = "Realista" (RECOMENDADO por la web): render de 15s con audio nativo, el m\xE1s natural \u2014 S\xD3LO v\xE1lido cuando durationSeconds \u2264 15 (el handler lo valida). "standard" = "Multiescena": m\xFAltiples tomas, hasta 30s. Si la duraci\xF3n es 30s, s\xF3lo aplica "standard". Si el usuario no menciona estilo, OMITE este campo y el backend usa su default.'),
977
1144
  features: z3.array(z3.string()).optional().describe('Opcional. Lista de features/beneficios clave a mencionar. Ej. ["resistente al agua", "bater\xEDa 24h", "carga inal\xE1mbrica"].'),
978
1145
  price: CreativeRefs.price,
979
1146
  offer: CreativeRefs.offer,
980
1147
  cta: z3.string().optional().describe('Opcional. Call-to-action final del video. Ej. "Compra ahora", "Pide el tuyo hoy", "Link en bio". Si se omite, la IA escribe uno.'),
981
- scriptNotes: z3.string().optional().describe('Opcional. Notas/restricciones para el guion. Ej. "No mencionar la marca X", "Tono divertido, no cl\xEDnico".')
982
- });
1148
+ scriptNotes: z3.string().optional().describe('Opcional. Notas/restricciones para el guion (la IA las usa al redactar). Ej. "No mencionar la marca X", "Tono divertido, no cl\xEDnico", "Mencionar 2x1". NO es el guion literal \u2014 para editar el guion palabra-por-palabra, pasa `pauseForVideoScriptReview: true` y usa get_video_script / approve_video_script tras la pausa.'),
1149
+ pauseForVideoScriptReview: z3.boolean().optional().describe("Opcional. Si true, la tarea pausa despu\xE9s de generar el guion y antes de renderizar. \xDAsalo cuando el usuario quiera editar el guion antes de gastar cr\xE9ditos en el video. Tras la pausa: get_video_script para leerlo, approve_video_script para aprobar (con o sin ediciones). Default false.")
1150
+ }).strict();
983
1151
  var imageStyleList = ImageCreativeStyleEnum.options.join(" | ");
984
1152
  var archetypeList = ImageCreativeArchetypeEnum.options.join(" | ");
985
1153
  var visualTreatmentList = ImageCreativeVisualTreatmentEnum.options.join(" | ");
@@ -1019,12 +1187,26 @@ var LandingInput = z3.object({
1019
1187
  isCombo: CreativeRefs.isCombo
1020
1188
  });
1021
1189
  function registerCreativeTools(server, api, { transport = "http" } = {}) {
1022
- server.tool("generate_video_creative", 'Genera UN video creativo independiente. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), title, primaryAngle, language, country, aspectRatio.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos): durationSeconds, narrativeStyle, videoStyle, videoModel, features, cta, scriptNotes.\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado, p\xEDdele una opci\xF3n v\xE1lida.', VideoInput.shape, async (input) => {
1190
+ server.tool("generate_video_creative", 'Genera UN video creativo independiente. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), title, primaryAngle, language, country, aspectRatio.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos): durationSeconds, videoPreset (tipo/narrativa del video), videoTier (estilo: basic="Realista"/standard="Multiescena"), features, cta, scriptNotes.\n\nGUION: el usuario NO escribe el guion desde cero \u2014 la IA lo redacta. Si quiere REVISARLO/EDITARLO antes de renderizar (igual que en la web), pasa `pauseForVideoScriptReview: true`; cuando la tarea quede pausada, usa get_video_script para leer las escenas, ofr\xE9celas al usuario, y llama approve_video_script con las ediciones. Para dirigir el guion ANTES de generar (sin pausar), usa scriptNotes.', VideoInput.shape, async (input) => {
1023
1191
  try {
1192
+ if (input.videoTier === "basic" && (input.durationSeconds ?? 15) > 15) {
1193
+ throw new Error('videoTier="basic" s\xF3lo admite durationSeconds \u2264 15. Para 30s usa "standard".');
1194
+ }
1195
+ const resolved = resolveVideoPreset(input.videoPreset);
1196
+ const wireTier = resolveVideoTier(input.videoTier);
1197
+ const { videoTier: _publicTier, ...rest } = input;
1198
+ const wirePayload = {
1199
+ ...rest
1200
+ };
1201
+ if (resolved) {
1202
+ wirePayload.videoStyle = resolved.videoStyle;
1203
+ wirePayload.narrativeStyle = resolved.narrativeStyle;
1204
+ }
1205
+ if (wireTier !== void 0) wirePayload.videoTier = wireTier;
1024
1206
  return await submitCreative(api, {
1025
1207
  path: "/v1/creatives/videos",
1026
1208
  estimatedSeconds: 300,
1027
- input,
1209
+ input: wirePayload,
1028
1210
  transport
1029
1211
  });
1030
1212
  } catch (e) {
@@ -1058,10 +1240,68 @@ function registerCreativeTools(server, api, { transport = "http" } = {}) {
1058
1240
  }
1059
1241
  __name(registerCreativeTools, "registerCreativeTools");
1060
1242
 
1061
- // src/tools/connections.ts
1243
+ // src/tools/script-review.ts
1062
1244
  import { z as z4 } from "zod";
1245
+ var GetVideoScriptInput = z4.object({
1246
+ taskId: z4.string().min(1).describe("ID de la tarea (la misma que devolvi\xF3 create_product_task / generate_video_creative)."),
1247
+ angleKey: z4.string().min(1).optional().describe('Clave del \xE1ngulo cuyo guion se quiere leer (formato "angle_0", "angle_1", ...). REQUERIDO para tareas multi-angle: si se omite, el backend resuelve a "angle_0" LITERAL (no "el primer pendiente"), as\xED que en multi-angle donde angle_0 ya est\xE9 aprobado/saltado obtendr\xEDas status="approved" y nunca alcanzar\xEDas el \xE1ngulo realmente pendiente. Para single-angle puede omitirse.')
1248
+ }).strict();
1249
+ var ApproveSceneEdit = z4.object({
1250
+ index: z4.number().int().nonnegative().describe("\xCDndice de la escena (0-based, mismo que devuelve get_video_script)."),
1251
+ dialogue: z4.string().min(1).refine((v) => v.trim().length > 0, {
1252
+ message: "dialogue no puede ser s\xF3lo espacios"
1253
+ }).describe("Di\xE1logo final de la escena. NO puede estar vac\xEDo ni ser s\xF3lo espacios. Puede ser el original sin cambios o la edici\xF3n del usuario.")
1254
+ }).strict();
1255
+ var ApproveVideoScriptInput = z4.object({
1256
+ taskId: z4.string().min(1).describe("ID de la tarea."),
1257
+ angleKey: z4.string().min(1).optional().describe('Mismo angleKey usado en get_video_script. REQUERIDO en multi-angle (si se omite el backend resuelve a "angle_0" literal). En single-angle puede omitirse.'),
1258
+ scenes: z4.array(ApproveSceneEdit).min(1).describe("Escenas con los di\xE1logos finales. El backend mergea por `index` \u2014 las escenas NO incluidas conservan el di\xE1logo original. Debe haber al menos 1 entrada (scenes vac\xEDo = 400). Para cada entrada que mandes con un dialogue distinto del original, el backend valida minWords \u2264 words \u2264 maxWords y rechaza con 400 si te sales del rango.")
1259
+ }).strict();
1260
+ var RegenerateVideoScriptInput = z4.object({
1261
+ taskId: z4.string().min(1).describe("ID de la tarea."),
1262
+ angleKey: z4.string().min(1).describe('Clave del \xE1ngulo a regenerar. Single-angle: pasa "angle_0". Multi-angle: el mismo que devuelve get_video_script. NO se puede omitir \u2014 el backend lo exige siempre.'),
1263
+ narrativeStyle: NarrativeStyleEnum.describe('Narrativa para el nuevo guion (mismos valores que angleNarratives). REQUERIDO \u2014 el backend NO acepta "mantener la actual"; siempre tienes que elegir una. Si el usuario quiere re-rolear con la misma narrativa, pasa la que ya tiene el \xE1ngulo (la ves en get_video_script o en get_task_status).')
1264
+ }).strict();
1265
+ function registerScriptReviewTools(server, api) {
1266
+ server.tool("get_video_script", 'Lee el guion del video pendiente de revisi\xF3n. S\xF3lo funciona cuando la tarea fue creada con `pauseForVideoScriptReview: true` (via generate_video_creative). Devuelve { status, angleKey, perClipSeconds?, scenes: [{ index, dialogue, maxWords, minWords?, beatId? }] }.\n\nCU\xC1NDO LLAMAR: no existe un status top-level "awaiting_script_review" en get_task_status. Si la tarea fue creada con pauseForVideoScriptReview=true, polea get_task_status hasta que `status === "processing"` y a partir de ah\xED llama este tool peri\xF3dicamente \u2014 la respuesta de get_video_script trae su propio campo `status` que es la se\xF1al real:\n- "ready" \u2192 el guion est\xE1 listo; mu\xE9strale al usuario cada `scenes[].dialogue` por `index` con su tope `maxWords`, recoge ediciones, llama approve_video_script.\n- "not_ready" \u2192 el guion a\xFAn se est\xE1 generando; espera 5\u201310s y reintenta este tool.\n- "approved" \u2192 ya fue aprobado; en multi-angle puede ser que ese \xE1ngulo ya est\xE9 hecho \u2014 vuelve a llamar con otro angleKey.\n- "skipped" \u2192 el usuario opt\xF3 por no revisar en una pausa previa.\n- "error" \u2192 el backend no pudo generar el guion; usa `regenerate_video_script` para re-intentar (es la ruta de recuperaci\xF3n v\xE1lida \u2014 el guion no se aprueba en estado "error").\n\nMULTI-ANGLE: si la tarea tiene varios \xE1ngulos, omitir angleKey siempre devuelve "angle_0" literal \u2014 itera expl\xEDcitamente por "angle_0", "angle_1", ... hasta encontrar uno con status="ready".', GetVideoScriptInput.shape, async (input) => {
1267
+ try {
1268
+ const qs = input.angleKey ? `?angleKey=${encodeURIComponent(input.angleKey)}` : "";
1269
+ const r = await api.getJson(`/v1/tasks/${encodeURIComponent(input.taskId)}/video-script${qs}`);
1270
+ return asText(r);
1271
+ } catch (e) {
1272
+ throw translateError(e);
1273
+ }
1274
+ });
1275
+ server.tool("regenerate_video_script", 'Re-rolea (regenera) el guion del video pendiente de revisi\xF3n. \xDAsalo cuando:\n- get_video_script devuelve status="error" (el LLM fall\xF3 al redactar).\n- El usuario rechaza el guion entero y prefiere reintentar (no editar l\xEDnea por l\xEDnea).\n- Quieres probar otra narrativa (pasa `narrativeStyle`).\n\nFlujo: llama este tool \u2192 polea get_video_script hasta que vuelva a status="ready" \u2192 mu\xE9strale el nuevo guion al usuario \u2192 approve_video_script.\n\nIMPORTANTE: regenerar CUESTA cr\xE9ditos de LLM. No lo invoques sin confirmaci\xF3n del usuario salvo que el status sea "error" (sin retry el usuario no tiene salida).\n\nMULTI-ANGLE: pasa el mismo `angleKey` que usaste con get_video_script.', RegenerateVideoScriptInput.shape, async (input) => {
1276
+ try {
1277
+ const r = await api.postJson(`/v1/tasks/${encodeURIComponent(input.taskId)}/video-script/regenerate`, {
1278
+ angleKey: input.angleKey,
1279
+ narrativeStyle: input.narrativeStyle
1280
+ });
1281
+ return asText(r);
1282
+ } catch (e) {
1283
+ throw translateError(e);
1284
+ }
1285
+ });
1286
+ server.tool("approve_video_script", 'Aprueba el guion del video (con o sin ediciones). Tras llamarlo, el render arranca y consume cr\xE9ditos.\n\nREGLAS DURAS:\n1. INCLUYE EN scenes[] S\xD3LO las escenas que cambian. El backend mergea por `index` \u2014 las escenas no incluidas conservan su di\xE1logo original. scenes vac\xEDo es rechazado con 400, as\xED que si el usuario no quiere editar nada, manda AL MENOS UNA escena con su `dialogue` original (ser\xE1 un no-op sem\xE1ntico pero satisface el min(1)).\n2. Para cada entrada incluida cuyo `dialogue` difiera del original, el backend valida `minWords \u2264 words(dialogue) \u2264 maxWords` (los rangos vienen en get_video_script). Si te sales del rango, rechaza con 400 \u2014 NO recortes en silencio; p\xEDdele al usuario que ajuste.\n3. Escenas con LLM-original ya fuera de rango: el backend NO las valida si no las incluyes (la regla #2 s\xF3lo aplica a escenas editadas). Si quieres que pasen tal cual, simplemente no las incluyas en scenes[].\n4. NUNCA inventes ni reescribas el guion por tu cuenta. Aplica los cambios del usuario al pie de la letra dentro de los l\xEDmites de palabras.\n5. MULTI-ANGLE: pasa el mismo `angleKey` que usaste en get_video_script. Si lo omites, el backend resuelve a "angle_0" literal \u2014 puedes aprobar un \xE1ngulo equivocado.\n\nDevuelve { status }. status="approved" confirma la aprobaci\xF3n; otro valor significa que la pausa sigue activa.', ApproveVideoScriptInput.shape, async (input) => {
1287
+ try {
1288
+ const body = {
1289
+ scenes: input.scenes
1290
+ };
1291
+ if (input.angleKey) body.angleKey = input.angleKey;
1292
+ const r = await api.postJson(`/v1/tasks/${encodeURIComponent(input.taskId)}/video-script/approve`, body);
1293
+ return asText(r);
1294
+ } catch (e) {
1295
+ throw translateError(e);
1296
+ }
1297
+ });
1298
+ }
1299
+ __name(registerScriptReviewTools, "registerScriptReviewTools");
1300
+
1301
+ // src/tools/connections.ts
1302
+ import { z as z5 } from "zod";
1063
1303
  function registerConnectionTools(server, api) {
1064
- server.tool("list_shopify_shops", "Lista las tiendas Shopify conectadas por este usuario. Si la lista est\xE1 vac\xEDa, dile al usuario que conecte una tienda en productmaker.app/integrations antes de intentar publicar.", z4.object({}).shape, async () => {
1304
+ server.tool("list_shopify_shops", "Lista las tiendas Shopify conectadas por este usuario. Si la lista est\xE1 vac\xEDa, dile al usuario que conecte una tienda en productmaker.app/integrations antes de intentar publicar.", z5.object({}).shape, async () => {
1065
1305
  try {
1066
1306
  const r = await api.getJson("/v1/shopify/shops");
1067
1307
  return asText(r);
@@ -1069,7 +1309,7 @@ function registerConnectionTools(server, api) {
1069
1309
  throw translateError(e);
1070
1310
  }
1071
1311
  });
1072
- server.tool("list_meta_ad_accounts", "Lista las cuentas publicitarias de Meta conectadas. Si est\xE1 vac\xEDa, ind\xEDcale al usuario conectar Meta en productmaker.app/integrations.", z4.object({}).shape, async () => {
1312
+ server.tool("list_meta_ad_accounts", "Lista las cuentas publicitarias de Meta conectadas. Si est\xE1 vac\xEDa, ind\xEDcale al usuario conectar Meta en productmaker.app/integrations.", z5.object({}).shape, async () => {
1073
1313
  try {
1074
1314
  const r = await api.getJson("/v1/facebook/ad-accounts");
1075
1315
  return asText(r);
@@ -1077,7 +1317,7 @@ function registerConnectionTools(server, api) {
1077
1317
  throw translateError(e);
1078
1318
  }
1079
1319
  });
1080
- server.tool("list_tiktok_advertisers", "Lista las cuentas de TikTok Ads conectadas. Si est\xE1 vac\xEDa, ind\xEDcale al usuario conectar TikTok en productmaker.app/integrations.", z4.object({}).shape, async () => {
1320
+ server.tool("list_tiktok_advertisers", "Lista las cuentas de TikTok Ads conectadas. Si est\xE1 vac\xEDa, ind\xEDcale al usuario conectar TikTok en productmaker.app/integrations.", z5.object({}).shape, async () => {
1081
1321
  try {
1082
1322
  const r = await api.getJson("/v1/tiktok/advertisers");
1083
1323
  return asText(r);
@@ -1089,40 +1329,40 @@ function registerConnectionTools(server, api) {
1089
1329
  __name(registerConnectionTools, "registerConnectionTools");
1090
1330
 
1091
1331
  // src/tools/publish.ts
1092
- import { z as z5 } from "zod";
1093
- var ShopifyInput = z5.object({
1094
- taskId: z5.string().min(1),
1095
- shopDomain: z5.string().min(1),
1096
- numLandingImages: z5.number().int().min(1).max(7).optional(),
1097
- variantIndex: z5.number().int().min(0).optional(),
1332
+ import { z as z6 } from "zod";
1333
+ var ShopifyInput = z6.object({
1334
+ taskId: z6.string().min(1),
1335
+ shopDomain: z6.string().min(1),
1336
+ numLandingImages: z6.number().int().min(1).max(7).optional(),
1337
+ variantIndex: z6.number().int().min(0).optional(),
1098
1338
  /** ASK THE USER before setting: "¿Tu producto tiene variantes (talla, color, etc.)?".
1099
1339
  * When true, the landing renders variant-picker pills; when false, it ships
1100
1340
  * a single-option product page. Defaults to false. */
1101
- showVariantPicker: z5.boolean().optional()
1341
+ showVariantPicker: z6.boolean().optional()
1102
1342
  });
1103
- var McpCampaignConfig = z5.object({
1104
- dailyBudgetUsd: z5.number().positive().optional(),
1343
+ var McpCampaignConfig = z6.object({
1344
+ dailyBudgetUsd: z6.number().positive().optional(),
1105
1345
  /** ISO-3166-1 alpha-2 country codes. Meta accepts max 1 (single-country
1106
1346
  * ad set). TikTok takes only the first entry. */
1107
- targetCountries: z5.array(z5.string().length(2)).max(1).optional(),
1108
- campaignName: z5.string().trim().min(1).max(100).optional()
1347
+ targetCountries: z6.array(z6.string().length(2)).max(1).optional(),
1348
+ campaignName: z6.string().trim().min(1).max(100).optional()
1109
1349
  });
1110
- var MetaInput = z5.object({
1111
- taskId: z5.string().min(1),
1112
- adAccountId: z5.string().min(1),
1113
- pixelId: z5.string().min(1).optional(),
1114
- landingUrl: z5.string().url().optional(),
1350
+ var MetaInput = z6.object({
1351
+ taskId: z6.string().min(1),
1352
+ adAccountId: z6.string().min(1),
1353
+ pixelId: z6.string().min(1).optional(),
1354
+ landingUrl: z6.string().url().optional(),
1115
1355
  /** Select which angles to publish by 0-based index. Omit to publish all
1116
1356
  * successful angles. Example: a task with 3 angles → `[0,2]` publishes #0 + #2. */
1117
- angleIndexes: z5.array(z5.number().int().nonnegative()).optional(),
1357
+ angleIndexes: z6.array(z6.number().int().nonnegative()).optional(),
1118
1358
  campaignConfig: McpCampaignConfig.optional()
1119
1359
  });
1120
- var TikTokInput = z5.object({
1121
- taskId: z5.string().min(1),
1122
- advertiserId: z5.string().min(1),
1123
- pixelId: z5.string().min(1).optional(),
1124
- landingUrl: z5.string().url().optional(),
1125
- angleIndexes: z5.array(z5.number().int().nonnegative()).optional(),
1360
+ var TikTokInput = z6.object({
1361
+ taskId: z6.string().min(1),
1362
+ advertiserId: z6.string().min(1),
1363
+ pixelId: z6.string().min(1).optional(),
1364
+ landingUrl: z6.string().url().optional(),
1365
+ angleIndexes: z6.array(z6.number().int().nonnegative()).optional(),
1126
1366
  campaignConfig: McpCampaignConfig.optional()
1127
1367
  });
1128
1368
  function registerPublishTools(server, api) {
@@ -1224,13 +1464,13 @@ function toTikTokCampaignConfig(cfg) {
1224
1464
  __name(toTikTokCampaignConfig, "toTikTokCampaignConfig");
1225
1465
 
1226
1466
  // src/tools/campaigns.ts
1227
- import { z as z6 } from "zod";
1228
- var MetaStatusInput = z6.object({
1229
- campaignId: z6.string().min(1).describe("Meta campaign ID returned by publish_to_meta.")
1467
+ import { z as z7 } from "zod";
1468
+ var MetaStatusInput = z7.object({
1469
+ campaignId: z7.string().min(1).describe("Meta campaign ID returned by publish_to_meta.")
1230
1470
  });
1231
- var TikTokStatusInput = z6.object({
1232
- campaignId: z6.string().min(1).describe("TikTok campaign ID returned by publish_to_tiktok."),
1233
- advertiserId: z6.string().min(1).describe("The same advertiserId used to publish the campaign.")
1471
+ var TikTokStatusInput = z7.object({
1472
+ campaignId: z7.string().min(1).describe("TikTok campaign ID returned by publish_to_tiktok."),
1473
+ advertiserId: z7.string().min(1).describe("The same advertiserId used to publish the campaign.")
1234
1474
  });
1235
1475
  function registerCampaignTools(server, api) {
1236
1476
  server.tool("get_meta_campaign_status", "Consulta el estado actual + m\xE9tricas lifetime (spend, impressions, clicks, reach) de una campa\xF1a Meta publicada. Llama esto DESPU\xC9S de `publish_to_meta` para confirmar que la campa\xF1a se cre\xF3 correctamente y para reportar performance al usuario. Es read-only \u2014 no modifica nada en Meta Ads Manager.", MetaStatusInput.shape, async (input) => {
@@ -1348,6 +1588,7 @@ function createServer(opts) {
1348
1588
  registerCreativeTools(server, api, {
1349
1589
  transport
1350
1590
  });
1591
+ registerScriptReviewTools(server, api);
1351
1592
  registerConnectionTools(server, api);
1352
1593
  registerPublishTools(server, api);
1353
1594
  registerCampaignTools(server, api);
package/dist/stdio.js CHANGED
@@ -493,6 +493,92 @@ __name(translateError, "translateError");
493
493
  import { z } from "zod";
494
494
  var Lang = z.string().min(2).max(10);
495
495
  var Country = z.string().min(2).max(3);
496
+ var VideoPresetEnum = z.enum([
497
+ "auto",
498
+ "ugc_dynamic",
499
+ "ugc_unboxing_chaos",
500
+ "ugc_curiosity_reveal",
501
+ "ugc_listicle_3",
502
+ "ugc_skeptic_confession",
503
+ "ugc_demo_review",
504
+ "problem_solution",
505
+ "pixar_problem",
506
+ "pixar_sassy",
507
+ "product_showcase",
508
+ "wearable_showcase"
509
+ ]);
510
+ var VIDEO_PRESET_RESOLUTION = {
511
+ auto: null,
512
+ ugc_dynamic: {
513
+ videoStyle: "ai_dynamic",
514
+ narrativeStyle: "review",
515
+ videoModel: "veo"
516
+ },
517
+ ugc_unboxing_chaos: {
518
+ videoStyle: "ai_dynamic",
519
+ narrativeStyle: "ugc_unboxing_chaos",
520
+ videoModel: "veo"
521
+ },
522
+ ugc_curiosity_reveal: {
523
+ videoStyle: "ai_dynamic",
524
+ narrativeStyle: "ugc_curiosity_reveal",
525
+ videoModel: "veo"
526
+ },
527
+ ugc_listicle_3: {
528
+ videoStyle: "ai_dynamic",
529
+ narrativeStyle: "ugc_listicle_3",
530
+ videoModel: "kling"
531
+ },
532
+ ugc_skeptic_confession: {
533
+ videoStyle: "ai_dynamic",
534
+ narrativeStyle: "ugc_skeptic_confession",
535
+ videoModel: "veo"
536
+ },
537
+ ugc_demo_review: {
538
+ videoStyle: "ai_dynamic",
539
+ narrativeStyle: "ugc_demo_review",
540
+ videoModel: "kling"
541
+ },
542
+ problem_solution: {
543
+ videoStyle: "ai_dynamic",
544
+ narrativeStyle: "problem_product_cta",
545
+ videoModel: "kling"
546
+ },
547
+ pixar_problem: {
548
+ videoStyle: "ai_dynamic",
549
+ narrativeStyle: "animated_problem_pixar",
550
+ videoModel: "kling"
551
+ },
552
+ pixar_sassy: {
553
+ videoStyle: "ai_dynamic",
554
+ narrativeStyle: "sassy_product_pixar",
555
+ videoModel: "seedance"
556
+ },
557
+ product_showcase: {
558
+ videoStyle: "product_only",
559
+ narrativeStyle: "product_showcase",
560
+ videoModel: "veo"
561
+ },
562
+ wearable_showcase: {
563
+ videoStyle: "ugc",
564
+ narrativeStyle: "wearable_showcase",
565
+ videoModel: "veo"
566
+ }
567
+ };
568
+ function resolveVideoPreset(preset) {
569
+ if (!preset) return null;
570
+ return VIDEO_PRESET_RESOLUTION[preset] ?? null;
571
+ }
572
+ __name(resolveVideoPreset, "resolveVideoPreset");
573
+ var VideoTierEnum = z.enum([
574
+ "basic",
575
+ "standard"
576
+ ]);
577
+ function resolveVideoTier(tier) {
578
+ if (!tier) return void 0;
579
+ return tier === "basic" ? "grok" : "standard";
580
+ }
581
+ __name(resolveVideoTier, "resolveVideoTier");
496
582
  var VideoStyleEnum = z.enum([
497
583
  "ugc",
498
584
  "cartoon_3d",
@@ -522,6 +608,30 @@ var VideoModelEnum = z.enum([
522
608
  "grok",
523
609
  "kling"
524
610
  ]);
611
+ var ProductReferenceUserChoiceEnum = z.enum([
612
+ "same_product",
613
+ "variants",
614
+ "different_products",
615
+ "combo_distinct"
616
+ ]);
617
+ var ProductReferenceCandidateGroupingEnum = z.enum([
618
+ "same_product_same_variant",
619
+ "same_product_different_variants",
620
+ "different_products",
621
+ "unclear"
622
+ ]);
623
+ function buildSelectedImageUrlsHash(urls) {
624
+ const input = [
625
+ ...urls
626
+ ].sort().join("|");
627
+ let hash = 2166136261;
628
+ for (let index = 0; index < input.length; index += 1) {
629
+ hash ^= input.charCodeAt(index);
630
+ hash = Math.imul(hash, 16777619) >>> 0;
631
+ }
632
+ return `fnv1a:${hash.toString(16).padStart(8, "0")}:${urls.length}`;
633
+ }
634
+ __name(buildSelectedImageUrlsHash, "buildSelectedImageUrlsHash");
525
635
  var ImageCreativeStyleEnum = z.enum([
526
636
  "studio",
527
637
  "floating",
@@ -775,17 +885,16 @@ var CreateProductTaskInput = z2.object({
775
885
  country: Country.describe('C\xF3digo ISO del pa\xEDs objetivo. Ejemplos: "CO", "MX", "US", "AR". Define moneda, m\xE9todos de pago locales y tono. REQUERIDO \u2014 si el usuario no lo especific\xF3, PREG\xDANTALE antes de invocar. NUNCA infieras desde el contexto del chat.'),
776
886
  price: z2.string().optional().describe('Precio que se mostrar\xE1 en la landing y video. Formato libre \u2014 ej. "$89.900", "USD 29.99", "MXN 599". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar. Si te dice "t\xFA elige", om\xEDtelo y el backend sugiere uno.'),
777
887
  offer: z2.string().optional().describe('Oferta o promoci\xF3n a destacar. Ej. "2x1", "Env\xEDo gratis hoy", "50% OFF". Opcional pero MUY IMPACTANTE: si el usuario no lo mencion\xF3, PREG\xDANTALE antes de invocar (es v\xE1lido que diga "sin oferta"). Si te dice "t\xFA elige", om\xEDtelo.'),
778
- videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual del video. "ugc" = creador hablando a c\xE1mara (default, mejor CTR LATAM). "cartoon_3d" = animaci\xF3n 3D. "product_only" = sin actor. "ai_dynamic" = cinematogr\xE1fico AI. Si se omite, default "ugc".'),
779
- narrativeStyle: NarrativeStyleEnum.optional().describe('Opcional. Estructura narrativa. "review" = UGC cl\xE1sico multi-hook (default, alta conversi\xF3n). "problem_product_cta" = problema\u2192soluci\xF3n. "product_showcase" = features. "metaphor_product_cta" = met\xE1fora visual. "wearable_showcase" = para ropa/accesorios. "ugc_unboxing_chaos" = unboxing agresivo. "ugc_curiosity_reveal" = hook con producto oculto. "ugc_listicle_3" = 3 razones (30s recomendado). "ugc_skeptic_confession" = esc\xE9ptico\u2192conversi\xF3n (30s recomendado). "ugc_demo_review" = demo en uso a lo largo de d\xEDas. Si se omite, default "review".'),
780
- videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). "bytedance"/"seedance" = TikTok-style. "kling"/"wan" = alternativos. "infinitalk" = lipsync largo. "grok" = grok-imagine. Si se omite, el sistema escoge seg\xFAn costo/calidad.'),
888
+ videoPreset: VideoPresetEnum.default("auto").describe('Opcional. Tipo/narrativa del video \u2014 \xFAnico campo de "estilo" que el usuario controla (en la web es el \xFAnico dropdown). Default "auto" (la IA escoge tras analizar el producto). Opciones: "auto", "ugc_dynamic" (UGC testimonial), "ugc_unboxing_chaos" (unboxing agresivo), "ugc_curiosity_reveal" (hook con producto oculto), "ugc_listicle_3" (3 razones, ideal 30s), "ugc_skeptic_confession" (esc\xE9ptico\u2192conversi\xF3n, ideal 30s), "ugc_demo_review" (demo en uso), "problem_solution" (problema\u2192producto\u2192CTA), "pixar_problem" (problema animado 3D), "pixar_sassy" (producto que habla), "product_showcase" (solo producto, sin actor), "wearable_showcase" (ropa/accesorios puestos). Si el usuario pide algo fuera de esta lista (ej. "estilo anime"), p\xEDdele una opci\xF3n v\xE1lida.'),
889
+ videoTier: VideoTierEnum.optional().describe('Opcional. Estilo del video (en la web la etiqueta es "\xBFCalidad de video?", 2 opciones \u2014 sin escalera de calidad, son ESTILOS distintos). "basic" = "Realista" (RECOMENDADO por la web): render de 15s con audio nativo, el m\xE1s natural \u2014 S\xD3LO v\xE1lido si la duraci\xF3n del video es 15s. "standard" = "Multiescena": m\xFAltiples tomas, hasta 30s. Si el usuario no menciona estilo, OMITE este campo y el backend usa su default. Para duraci\xF3n 30s s\xF3lo aplica "standard" (Realista est\xE1 vetada).'),
781
890
  multiAngleVideos: z2.boolean().optional().describe("Opcional. Si true, genera m\xFAltiples \xE1ngulos de venta en paralelo (3-5 videos). Si false/omitido, genera 1 video del \xE1ngulo principal. Default false."),
782
- angleNarratives: NarrativeStyleEnum.array().max(3).optional().describe('Opcional. Narrativa POR \xC1NGULO (posicional). angleNarratives[i] fuerza la narrativa del \xE1ngulo en la posici\xF3n i; usa los mismos valores que narrativeStyle. Si la lista es m\xE1s corta que la cantidad de \xE1ngulos, las posiciones restantes las elige la IA. Ej: ["animated_problem_pixar","review","ugc_skeptic_confession"] para 3 \xE1ngulos distintos. Requiere multiAngleVideos:true (o videoAngleCount>1) para que haya varios \xE1ngulos. Si se omite, todas las narrativas se auto-seleccionan.'),
891
+ angleNarratives: NarrativeStyleEnum.array().max(3).optional().describe('Opcional. Narrativa POR \xC1NGULO (posicional, override avanzado). Usa los mismos identificadores de narrativa que videoPreset (ej. "animated_problem_pixar", "review", "ugc_skeptic_confession"). angleNarratives[i] fuerza la narrativa del \xE1ngulo en la posici\xF3n i; si la lista es m\xE1s corta que la cantidad de \xE1ngulos, las posiciones restantes las elige la IA. Requiere multiAngleVideos:true para que haya varios \xE1ngulos. Si se omite, todas las narrativas se auto-seleccionan seg\xFAn el producto.'),
783
892
  outputs: z2.array(z2.enum([
784
893
  "landing",
785
894
  "video",
786
895
  "image"
787
896
  ])).min(2).max(3).optional().describe('Opcional. Qu\xE9 outputs generar. Default: todos ["landing","video","image"]. Para 2 outputs: ["landing","image"] (sin video), ["video","image"] (sin landing), ["landing","video"] (sin imagen). Para 1 solo output, usa generate_landing / generate_video_creative / generate_image_creatives.')
788
- });
897
+ }).strict();
789
898
  var GetTaskStatusInput = z2.object({
790
899
  taskId: z2.string().min(1),
791
900
  waitSeconds: z2.number().int().min(0).max(120).optional()
@@ -809,8 +918,25 @@ var EditTaskDraftInput = z2.object({
809
918
  scriptNotes: z2.string().max(2e3).optional()
810
919
  })
811
920
  });
921
+ var PendingActionPayload = z2.discriminatedUnion("kind", [
922
+ z2.object({
923
+ kind: z2.literal("product_reference_confirmation"),
924
+ userChoice: ProductReferenceUserChoiceEnum
925
+ })
926
+ ]);
927
+ var ResolvePendingActionInput = z2.object({
928
+ taskId: z2.string().min(1),
929
+ action: PendingActionPayload
930
+ }).strict();
931
+ var StatusForResolveSchema = z2.object({
932
+ pendingUserAction: z2.object({
933
+ kind: z2.string(),
934
+ candidateGrouping: z2.string().optional(),
935
+ selectedImageUrls: z2.array(z2.string()).optional()
936
+ }).optional()
937
+ }).passthrough();
812
938
  function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http" } = {}) {
813
- server.tool("create_product_task", 'Crea una tarea de producto desde una o varias fotos. Por defecto genera los 3 outputs (landing + video + image creatives). Devuelve un taskId.\n\nINPUTS REQUERIDOS: images (1-5 fotos del MISMO producto; la primera es la hero), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto del chat: preg\xFAntale expl\xEDcitamente, incluso si el usuario est\xE1 en LATAM. Si te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nSi el usuario tiene varias fotos del MISMO producto (frente, lateral, detalle, uso), incl\xFAyelas todas en images[] hasta 5. Si tiene fotos de productos DIFERENTES, son llamadas separadas (una tarea por producto).\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos):\n- videoStyle, narrativeStyle, videoModel (enums \u2014 ver descripci\xF3n de cada campo)\n- multiAngleVideos (booleano)\n- outputs (subconjunto de {landing,video,image})\n- productUrl\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado (ej. "estilo anime"), p\xEDdele una opci\xF3n v\xE1lida.', CreateProductTaskInput.shape, async (input) => {
939
+ server.tool("create_product_task", 'Crea una tarea de producto desde una o varias fotos. Por defecto genera los 3 outputs (landing + video + image creatives). Devuelve un taskId.\n\nINPUTS REQUERIDOS: images (1-5 fotos del MISMO producto; la primera es la hero), language, country.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto del chat: preg\xFAntale expl\xEDcitamente, incluso si el usuario est\xE1 en LATAM. Si te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nSi el usuario tiene varias fotos del MISMO producto (frente, lateral, detalle, uso), incl\xFAyelas todas en images[] hasta 5. Si tiene fotos de productos DIFERENTES, son llamadas separadas (una tarea por producto).\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos):\n- videoPreset (tipo/narrativa del video \u2014 \xFAnico campo de estilo)\n- videoTier (estilo: basic="Realista"/standard="Multiescena")\n- multiAngleVideos (booleano)\n- angleNarratives (override per-angle de la narrativa, avanzado)\n- outputs (subconjunto de {landing,video,image})\n- productUrl\n\nGUION: para revisar/editar el guion del video antes de renderizar, usa `generate_video_creative` con `pauseForVideoScriptReview: true` en vez de este tool. Despu\xE9s de la pausa: get_video_script para leer, approve_video_script para aprobar con ediciones.\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado (ej. "estilo anime", "modelo X"), p\xEDdele una opci\xF3n v\xE1lida del enum correspondiente.', CreateProductTaskInput.shape, async (input) => {
814
940
  try {
815
941
  const imgs = await resolveImages(input.images, {
816
942
  transport
@@ -819,16 +945,19 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
819
945
  ...img,
820
946
  filename: `image-${i}.png`
821
947
  }));
948
+ const resolved = resolveVideoPreset(input.videoPreset);
949
+ const wireTier = resolveVideoTier(input.videoTier);
822
950
  const fields = flatten({
823
951
  "options.languageCode": input.language,
824
952
  "options.country": input.country,
825
953
  "options.price": input.price,
826
954
  "options.offer": input.offer,
827
- "options.videoStyle": input.videoStyle,
828
- "options.narrativeStyle": input.narrativeStyle,
829
- "options.angleNarratives": input.angleNarratives,
830
- "options.videoModel": input.videoModel,
955
+ "options.videoPreset": input.videoPreset,
956
+ "options.videoStyle": resolved?.videoStyle,
957
+ "options.narrativeStyle": resolved?.narrativeStyle,
958
+ "options.videoTier": wireTier,
831
959
  "options.multiAngleVideos": input.multiAngleVideos,
960
+ "options.angleNarratives": input.angleNarratives,
832
961
  "options.selectedOutputs": input.outputs,
833
962
  url: input.productUrl
834
963
  });
@@ -844,7 +973,7 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
844
973
  throw translateError(e);
845
974
  }
846
975
  });
847
- server.tool("get_task_status", "Devuelve el estado y URLs de outputs de una tarea. Si waitSeconds>0, hace polling hasta que cambie la fase o llegue al timeout (max 120s). Cuando la tarea queda en `done`, la respuesta incluye un segundo bloque markdown con previews inline de las im\xE1genes, links descargables al video MP4 y un enlace a Product Maker para ver el resultado completo. Use this to check on a task progress; pass waitSeconds for a near-synchronous response.", GetTaskStatusInput.shape, async (input, extra) => {
976
+ server.tool("get_task_status", 'Devuelve el estado y URLs de outputs de una tarea. Si waitSeconds>0, hace polling hasta que cambie la fase o llegue al timeout (max 120s). Cuando la tarea queda en `done`, la respuesta incluye un segundo bloque markdown con previews inline de las im\xE1genes, links descargables al video MP4 y un enlace a Product Maker para ver el resultado completo.\n\nIMPORTANTE \u2014 `pendingUserAction`: si est\xE1 presente, el pipeline est\xE1 PARADO esperando que el usuario decida algo. Mira el `kind` (hoy: `"product_reference_confirmation"` \u2014 el clasificador no est\xE1 seguro si las fotos son variantes del mismo producto o productos distintos), mu\xE9strale al usuario `targetProductLabel` + `reason` en lenguaje natural, recoge su respuesta, y llama `resolve_pending_action` con `action: { kind: <mismo kind>, ... }` (las `options` v\xE1lidas vienen en el mismo objeto). Sin esto la tarea NUNCA avanza por m\xE1s que esperes. Use this to check on a task progress; pass waitSeconds for a near-synchronous response.', GetTaskStatusInput.shape, async (input, extra) => {
848
977
  try {
849
978
  const fetcher = /* @__PURE__ */ __name(() => api.getJson(`/v1/status/${encodeURIComponent(input.taskId)}`), "fetcher");
850
979
  const signal = extra?.signal;
@@ -901,6 +1030,45 @@ function registerTaskTools(server, api, { proxyBaseUrl = null, transport = "http
901
1030
  throw translateError(e);
902
1031
  }
903
1032
  });
1033
+ server.tool("resolve_pending_action", 'Resuelve una acci\xF3n que el pipeline est\xE1 esperando del usuario. La llamas cuando `get_task_status` devuelve `pendingUserAction` (sin esto la tarea NUNCA avanza por m\xE1s que esperes). El `action.kind` que mandas DEBE coincidir con el `pendingUserAction.kind` que viste en el status \u2014 es el contrato de simetr\xEDa que mantiene este tool como \xFAnica superficie de resoluci\xF3n de gates.\n\nKINDS soportados hoy:\n\n\u2022 `product_reference_confirmation` \u2014 el clasificador no est\xE1 seguro si las fotos son variantes del mismo producto o productos distintos. Payload: `userChoice` \u2208 { "same_product" (mismo producto, fotos son s\xF3lo \xE1ngulos distintos), "variants" (mismo producto, distintos colores/tallas/sabores), "combo_distinct" (son 2 productos distintos vendidos juntos como combo \u2014 marca la tarea como combo), "different_products" (productos distintos y el usuario quiere generar s\xF3lo uno \u2014 el backend bloquea hasta que selecciones im\xE1genes de un solo producto; normalmente NO es la respuesta correcta para destrabar) }. PREG\xDANTALE al usuario antes de invocar \u2014 `pendingUserAction.targetProductLabel` y `reason` te dan contexto para formularle la pregunta en lenguaje natural. Usa SOLO valores presentes en `pendingUserAction.options`.\n\nFuturos kinds (no implementados a\xFAn) se agregar\xE1n como nuevas variantes del `action` discriminated union \u2014 el flujo "lee kind del status \u2192 manda mismo kind aqu\xED" sigue igual.', ResolvePendingActionInput.shape, async (input) => {
1034
+ try {
1035
+ const raw = await api.getJson(`/v1/status/${encodeURIComponent(input.taskId)}`);
1036
+ const status = StatusForResolveSchema.parse(raw);
1037
+ const pending = status.pendingUserAction;
1038
+ if (!pending) {
1039
+ throw new Error("No hay acci\xF3n pendiente para esta tarea \u2014 llama get_task_status primero para confirmar el estado.");
1040
+ }
1041
+ if (pending.kind !== input.action.kind) {
1042
+ throw new Error(`La tarea espera una acci\xF3n de tipo "${pending.kind}" pero mandaste "${input.action.kind}". Reusa el mismo kind que viste en pendingUserAction.`);
1043
+ }
1044
+ switch (input.action.kind) {
1045
+ case "product_reference_confirmation": {
1046
+ const candidateGrouping = pending.candidateGrouping;
1047
+ const selectedImageUrls = pending.selectedImageUrls ?? [];
1048
+ if (!candidateGrouping || selectedImageUrls.length === 0) {
1049
+ throw new Error("Estado inconsistente: la tarea est\xE1 pausada pero falta candidateGrouping o selectedImageUrls.");
1050
+ }
1051
+ const r = await api.postJson(`/v1/tasks/${encodeURIComponent(input.taskId)}/confirm-product-references`, {
1052
+ productReferenceConfirmation: {
1053
+ candidateGrouping,
1054
+ userChoice: input.action.userChoice,
1055
+ // Server overwrites with its own server-side computation;
1056
+ // we send the matching MCP-mirror value so the wire body
1057
+ // is internally coherent if the server skips overwrite.
1058
+ selectedImageUrlsHash: buildSelectedImageUrlsHash(selectedImageUrls)
1059
+ }
1060
+ });
1061
+ return asText(r);
1062
+ }
1063
+ default: {
1064
+ const _exhaustive = input.action.kind;
1065
+ throw new Error(`Unsupported pending action kind: ${String(_exhaustive)}`);
1066
+ }
1067
+ }
1068
+ } catch (e) {
1069
+ throw translateError(e);
1070
+ }
1071
+ });
904
1072
  }
905
1073
  __name(registerTaskTools, "registerTaskTools");
906
1074
 
@@ -927,15 +1095,15 @@ var VideoInput = z3.object({
927
1095
  "Auto"
928
1096
  ]).describe('Relaci\xF3n de aspecto. "9:16" = vertical (TikTok/Reels \u2014 default recomendado para ads). "16:9" = horizontal (YouTube). "Auto" = decide la IA.'),
929
1097
  durationSeconds: z3.number().int().min(5).max(60).optional().describe("Opcional. Duraci\xF3n del video en segundos (5-60). Default 15s. Para hooks cortos usa 8, para storytelling usa 30."),
930
- narrativeStyle: NarrativeStyleEnum.optional().describe('Opcional. Estructura narrativa. "review" (UGC cl\xE1sico multi-hook, default), "problem_product_cta", "product_showcase", "metaphor_product_cta", "wearable_showcase", "ugc_unboxing_chaos" (unboxing agresivo), "ugc_curiosity_reveal" (hook con producto oculto), "ugc_listicle_3" (3 razones numeradas, recomendado 30s), "ugc_skeptic_confession" (esc\xE9ptico\u2192conversi\xF3n, recomendado 30s), "ugc_demo_review" (demo en uso a lo largo de d\xEDas).'),
931
- videoStyle: VideoStyleEnum.optional().describe('Opcional. Estilo visual. "ugc" (creador hablando, default), "cartoon_3d", "product_only", "ai_dynamic".'),
932
- videoModel: VideoModelEnum.optional().describe('Opcional. Modelo de video. "veo" (default, calidad Google). Ver lista de valores v\xE1lidos.'),
1098
+ videoPreset: VideoPresetEnum.default("auto").describe('Opcional. Tipo/narrativa del video \u2014 \xFAnico campo de "estilo" que el usuario controla (en la web es el \xFAnico dropdown). Default "auto" (la IA escoge tras analizar el producto). Opciones: "auto", "ugc_dynamic" (UGC cl\xE1sico testimonial multi-hook), "ugc_unboxing_chaos" (unboxing agresivo), "ugc_curiosity_reveal" (hook con producto oculto), "ugc_listicle_3" (3 razones numeradas, recomendado 30s), "ugc_skeptic_confession" (esc\xE9ptico\u2192conversi\xF3n, recomendado 30s), "ugc_demo_review" (demo en uso a lo largo de d\xEDas), "problem_solution" (problema\u2192producto\u2192CTA), "pixar_problem" (problema animado 3D estilo Pixar), "pixar_sassy" (producto que habla con personalidad), "product_showcase" (sin actor, solo producto), "wearable_showcase" (ropa/accesorios puestos).'),
1099
+ videoTier: VideoTierEnum.optional().describe('Opcional. Estilo del video (en la web la etiqueta es "\xBFCalidad de video?", 2 opciones \u2014 sin escalera de calidad, son ESTILOS distintos). "basic" = "Realista" (RECOMENDADO por la web): render de 15s con audio nativo, el m\xE1s natural \u2014 S\xD3LO v\xE1lido cuando durationSeconds \u2264 15 (el handler lo valida). "standard" = "Multiescena": m\xFAltiples tomas, hasta 30s. Si la duraci\xF3n es 30s, s\xF3lo aplica "standard". Si el usuario no menciona estilo, OMITE este campo y el backend usa su default.'),
933
1100
  features: z3.array(z3.string()).optional().describe('Opcional. Lista de features/beneficios clave a mencionar. Ej. ["resistente al agua", "bater\xEDa 24h", "carga inal\xE1mbrica"].'),
934
1101
  price: CreativeRefs.price,
935
1102
  offer: CreativeRefs.offer,
936
1103
  cta: z3.string().optional().describe('Opcional. Call-to-action final del video. Ej. "Compra ahora", "Pide el tuyo hoy", "Link en bio". Si se omite, la IA escribe uno.'),
937
- scriptNotes: z3.string().optional().describe('Opcional. Notas/restricciones para el guion. Ej. "No mencionar la marca X", "Tono divertido, no cl\xEDnico".')
938
- });
1104
+ scriptNotes: z3.string().optional().describe('Opcional. Notas/restricciones para el guion (la IA las usa al redactar). Ej. "No mencionar la marca X", "Tono divertido, no cl\xEDnico", "Mencionar 2x1". NO es el guion literal \u2014 para editar el guion palabra-por-palabra, pasa `pauseForVideoScriptReview: true` y usa get_video_script / approve_video_script tras la pausa.'),
1105
+ pauseForVideoScriptReview: z3.boolean().optional().describe("Opcional. Si true, la tarea pausa despu\xE9s de generar el guion y antes de renderizar. \xDAsalo cuando el usuario quiera editar el guion antes de gastar cr\xE9ditos en el video. Tras la pausa: get_video_script para leerlo, approve_video_script para aprobar (con o sin ediciones). Default false.")
1106
+ }).strict();
939
1107
  var imageStyleList = ImageCreativeStyleEnum.options.join(" | ");
940
1108
  var archetypeList = ImageCreativeArchetypeEnum.options.join(" | ");
941
1109
  var visualTreatmentList = ImageCreativeVisualTreatmentEnum.options.join(" | ");
@@ -975,12 +1143,26 @@ var LandingInput = z3.object({
975
1143
  isCombo: CreativeRefs.isCombo
976
1144
  });
977
1145
  function registerCreativeTools(server, api, { transport = "http" } = {}) {
978
- server.tool("generate_video_creative", 'Genera UN video creativo independiente. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), title, primaryAngle, language, country, aspectRatio.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos): durationSeconds, narrativeStyle, videoStyle, videoModel, features, cta, scriptNotes.\n\nUsa SOLO valores declarados en los enums. Si el usuario pide algo no soportado, p\xEDdele una opci\xF3n v\xE1lida.', VideoInput.shape, async (input) => {
1146
+ server.tool("generate_video_creative", 'Genera UN video creativo independiente. Devuelve un taskId.\n\nREQUERIDOS: images (array de 1-5 fotos), title, primaryAngle, language, country, aspectRatio.\n\nANTES de invocar, PREG\xDANTALE al usuario por price y offer si no los mencion\xF3 \u2014 son los inputs de mayor impacto en conversi\xF3n y nunca debes asumirlos. NO defaultes country/language desde el contexto: preg\xFAntale expl\xEDcitamente. Si el usuario te dice "t\xFA elige" para price/offer, om\xEDtelos.\n\nOTROS OPCIONALES (puedes invocar sin ellos y dejar que la IA elija defaults sensatos): durationSeconds, videoPreset (tipo/narrativa del video), videoTier (estilo: basic="Realista"/standard="Multiescena"), features, cta, scriptNotes.\n\nGUION: el usuario NO escribe el guion desde cero \u2014 la IA lo redacta. Si quiere REVISARLO/EDITARLO antes de renderizar (igual que en la web), pasa `pauseForVideoScriptReview: true`; cuando la tarea quede pausada, usa get_video_script para leer las escenas, ofr\xE9celas al usuario, y llama approve_video_script con las ediciones. Para dirigir el guion ANTES de generar (sin pausar), usa scriptNotes.', VideoInput.shape, async (input) => {
979
1147
  try {
1148
+ if (input.videoTier === "basic" && (input.durationSeconds ?? 15) > 15) {
1149
+ throw new Error('videoTier="basic" s\xF3lo admite durationSeconds \u2264 15. Para 30s usa "standard".');
1150
+ }
1151
+ const resolved = resolveVideoPreset(input.videoPreset);
1152
+ const wireTier = resolveVideoTier(input.videoTier);
1153
+ const { videoTier: _publicTier, ...rest } = input;
1154
+ const wirePayload = {
1155
+ ...rest
1156
+ };
1157
+ if (resolved) {
1158
+ wirePayload.videoStyle = resolved.videoStyle;
1159
+ wirePayload.narrativeStyle = resolved.narrativeStyle;
1160
+ }
1161
+ if (wireTier !== void 0) wirePayload.videoTier = wireTier;
980
1162
  return await submitCreative(api, {
981
1163
  path: "/v1/creatives/videos",
982
1164
  estimatedSeconds: 300,
983
- input,
1165
+ input: wirePayload,
984
1166
  transport
985
1167
  });
986
1168
  } catch (e) {
@@ -1014,10 +1196,68 @@ function registerCreativeTools(server, api, { transport = "http" } = {}) {
1014
1196
  }
1015
1197
  __name(registerCreativeTools, "registerCreativeTools");
1016
1198
 
1017
- // src/tools/connections.ts
1199
+ // src/tools/script-review.ts
1018
1200
  import { z as z4 } from "zod";
1201
+ var GetVideoScriptInput = z4.object({
1202
+ taskId: z4.string().min(1).describe("ID de la tarea (la misma que devolvi\xF3 create_product_task / generate_video_creative)."),
1203
+ angleKey: z4.string().min(1).optional().describe('Clave del \xE1ngulo cuyo guion se quiere leer (formato "angle_0", "angle_1", ...). REQUERIDO para tareas multi-angle: si se omite, el backend resuelve a "angle_0" LITERAL (no "el primer pendiente"), as\xED que en multi-angle donde angle_0 ya est\xE9 aprobado/saltado obtendr\xEDas status="approved" y nunca alcanzar\xEDas el \xE1ngulo realmente pendiente. Para single-angle puede omitirse.')
1204
+ }).strict();
1205
+ var ApproveSceneEdit = z4.object({
1206
+ index: z4.number().int().nonnegative().describe("\xCDndice de la escena (0-based, mismo que devuelve get_video_script)."),
1207
+ dialogue: z4.string().min(1).refine((v) => v.trim().length > 0, {
1208
+ message: "dialogue no puede ser s\xF3lo espacios"
1209
+ }).describe("Di\xE1logo final de la escena. NO puede estar vac\xEDo ni ser s\xF3lo espacios. Puede ser el original sin cambios o la edici\xF3n del usuario.")
1210
+ }).strict();
1211
+ var ApproveVideoScriptInput = z4.object({
1212
+ taskId: z4.string().min(1).describe("ID de la tarea."),
1213
+ angleKey: z4.string().min(1).optional().describe('Mismo angleKey usado en get_video_script. REQUERIDO en multi-angle (si se omite el backend resuelve a "angle_0" literal). En single-angle puede omitirse.'),
1214
+ scenes: z4.array(ApproveSceneEdit).min(1).describe("Escenas con los di\xE1logos finales. El backend mergea por `index` \u2014 las escenas NO incluidas conservan el di\xE1logo original. Debe haber al menos 1 entrada (scenes vac\xEDo = 400). Para cada entrada que mandes con un dialogue distinto del original, el backend valida minWords \u2264 words \u2264 maxWords y rechaza con 400 si te sales del rango.")
1215
+ }).strict();
1216
+ var RegenerateVideoScriptInput = z4.object({
1217
+ taskId: z4.string().min(1).describe("ID de la tarea."),
1218
+ angleKey: z4.string().min(1).describe('Clave del \xE1ngulo a regenerar. Single-angle: pasa "angle_0". Multi-angle: el mismo que devuelve get_video_script. NO se puede omitir \u2014 el backend lo exige siempre.'),
1219
+ narrativeStyle: NarrativeStyleEnum.describe('Narrativa para el nuevo guion (mismos valores que angleNarratives). REQUERIDO \u2014 el backend NO acepta "mantener la actual"; siempre tienes que elegir una. Si el usuario quiere re-rolear con la misma narrativa, pasa la que ya tiene el \xE1ngulo (la ves en get_video_script o en get_task_status).')
1220
+ }).strict();
1221
+ function registerScriptReviewTools(server, api) {
1222
+ server.tool("get_video_script", 'Lee el guion del video pendiente de revisi\xF3n. S\xF3lo funciona cuando la tarea fue creada con `pauseForVideoScriptReview: true` (via generate_video_creative). Devuelve { status, angleKey, perClipSeconds?, scenes: [{ index, dialogue, maxWords, minWords?, beatId? }] }.\n\nCU\xC1NDO LLAMAR: no existe un status top-level "awaiting_script_review" en get_task_status. Si la tarea fue creada con pauseForVideoScriptReview=true, polea get_task_status hasta que `status === "processing"` y a partir de ah\xED llama este tool peri\xF3dicamente \u2014 la respuesta de get_video_script trae su propio campo `status` que es la se\xF1al real:\n- "ready" \u2192 el guion est\xE1 listo; mu\xE9strale al usuario cada `scenes[].dialogue` por `index` con su tope `maxWords`, recoge ediciones, llama approve_video_script.\n- "not_ready" \u2192 el guion a\xFAn se est\xE1 generando; espera 5\u201310s y reintenta este tool.\n- "approved" \u2192 ya fue aprobado; en multi-angle puede ser que ese \xE1ngulo ya est\xE9 hecho \u2014 vuelve a llamar con otro angleKey.\n- "skipped" \u2192 el usuario opt\xF3 por no revisar en una pausa previa.\n- "error" \u2192 el backend no pudo generar el guion; usa `regenerate_video_script` para re-intentar (es la ruta de recuperaci\xF3n v\xE1lida \u2014 el guion no se aprueba en estado "error").\n\nMULTI-ANGLE: si la tarea tiene varios \xE1ngulos, omitir angleKey siempre devuelve "angle_0" literal \u2014 itera expl\xEDcitamente por "angle_0", "angle_1", ... hasta encontrar uno con status="ready".', GetVideoScriptInput.shape, async (input) => {
1223
+ try {
1224
+ const qs = input.angleKey ? `?angleKey=${encodeURIComponent(input.angleKey)}` : "";
1225
+ const r = await api.getJson(`/v1/tasks/${encodeURIComponent(input.taskId)}/video-script${qs}`);
1226
+ return asText(r);
1227
+ } catch (e) {
1228
+ throw translateError(e);
1229
+ }
1230
+ });
1231
+ server.tool("regenerate_video_script", 'Re-rolea (regenera) el guion del video pendiente de revisi\xF3n. \xDAsalo cuando:\n- get_video_script devuelve status="error" (el LLM fall\xF3 al redactar).\n- El usuario rechaza el guion entero y prefiere reintentar (no editar l\xEDnea por l\xEDnea).\n- Quieres probar otra narrativa (pasa `narrativeStyle`).\n\nFlujo: llama este tool \u2192 polea get_video_script hasta que vuelva a status="ready" \u2192 mu\xE9strale el nuevo guion al usuario \u2192 approve_video_script.\n\nIMPORTANTE: regenerar CUESTA cr\xE9ditos de LLM. No lo invoques sin confirmaci\xF3n del usuario salvo que el status sea "error" (sin retry el usuario no tiene salida).\n\nMULTI-ANGLE: pasa el mismo `angleKey` que usaste con get_video_script.', RegenerateVideoScriptInput.shape, async (input) => {
1232
+ try {
1233
+ const r = await api.postJson(`/v1/tasks/${encodeURIComponent(input.taskId)}/video-script/regenerate`, {
1234
+ angleKey: input.angleKey,
1235
+ narrativeStyle: input.narrativeStyle
1236
+ });
1237
+ return asText(r);
1238
+ } catch (e) {
1239
+ throw translateError(e);
1240
+ }
1241
+ });
1242
+ server.tool("approve_video_script", 'Aprueba el guion del video (con o sin ediciones). Tras llamarlo, el render arranca y consume cr\xE9ditos.\n\nREGLAS DURAS:\n1. INCLUYE EN scenes[] S\xD3LO las escenas que cambian. El backend mergea por `index` \u2014 las escenas no incluidas conservan su di\xE1logo original. scenes vac\xEDo es rechazado con 400, as\xED que si el usuario no quiere editar nada, manda AL MENOS UNA escena con su `dialogue` original (ser\xE1 un no-op sem\xE1ntico pero satisface el min(1)).\n2. Para cada entrada incluida cuyo `dialogue` difiera del original, el backend valida `minWords \u2264 words(dialogue) \u2264 maxWords` (los rangos vienen en get_video_script). Si te sales del rango, rechaza con 400 \u2014 NO recortes en silencio; p\xEDdele al usuario que ajuste.\n3. Escenas con LLM-original ya fuera de rango: el backend NO las valida si no las incluyes (la regla #2 s\xF3lo aplica a escenas editadas). Si quieres que pasen tal cual, simplemente no las incluyas en scenes[].\n4. NUNCA inventes ni reescribas el guion por tu cuenta. Aplica los cambios del usuario al pie de la letra dentro de los l\xEDmites de palabras.\n5. MULTI-ANGLE: pasa el mismo `angleKey` que usaste en get_video_script. Si lo omites, el backend resuelve a "angle_0" literal \u2014 puedes aprobar un \xE1ngulo equivocado.\n\nDevuelve { status }. status="approved" confirma la aprobaci\xF3n; otro valor significa que la pausa sigue activa.', ApproveVideoScriptInput.shape, async (input) => {
1243
+ try {
1244
+ const body = {
1245
+ scenes: input.scenes
1246
+ };
1247
+ if (input.angleKey) body.angleKey = input.angleKey;
1248
+ const r = await api.postJson(`/v1/tasks/${encodeURIComponent(input.taskId)}/video-script/approve`, body);
1249
+ return asText(r);
1250
+ } catch (e) {
1251
+ throw translateError(e);
1252
+ }
1253
+ });
1254
+ }
1255
+ __name(registerScriptReviewTools, "registerScriptReviewTools");
1256
+
1257
+ // src/tools/connections.ts
1258
+ import { z as z5 } from "zod";
1019
1259
  function registerConnectionTools(server, api) {
1020
- server.tool("list_shopify_shops", "Lista las tiendas Shopify conectadas por este usuario. Si la lista est\xE1 vac\xEDa, dile al usuario que conecte una tienda en productmaker.app/integrations antes de intentar publicar.", z4.object({}).shape, async () => {
1260
+ server.tool("list_shopify_shops", "Lista las tiendas Shopify conectadas por este usuario. Si la lista est\xE1 vac\xEDa, dile al usuario que conecte una tienda en productmaker.app/integrations antes de intentar publicar.", z5.object({}).shape, async () => {
1021
1261
  try {
1022
1262
  const r = await api.getJson("/v1/shopify/shops");
1023
1263
  return asText(r);
@@ -1025,7 +1265,7 @@ function registerConnectionTools(server, api) {
1025
1265
  throw translateError(e);
1026
1266
  }
1027
1267
  });
1028
- server.tool("list_meta_ad_accounts", "Lista las cuentas publicitarias de Meta conectadas. Si est\xE1 vac\xEDa, ind\xEDcale al usuario conectar Meta en productmaker.app/integrations.", z4.object({}).shape, async () => {
1268
+ server.tool("list_meta_ad_accounts", "Lista las cuentas publicitarias de Meta conectadas. Si est\xE1 vac\xEDa, ind\xEDcale al usuario conectar Meta en productmaker.app/integrations.", z5.object({}).shape, async () => {
1029
1269
  try {
1030
1270
  const r = await api.getJson("/v1/facebook/ad-accounts");
1031
1271
  return asText(r);
@@ -1033,7 +1273,7 @@ function registerConnectionTools(server, api) {
1033
1273
  throw translateError(e);
1034
1274
  }
1035
1275
  });
1036
- server.tool("list_tiktok_advertisers", "Lista las cuentas de TikTok Ads conectadas. Si est\xE1 vac\xEDa, ind\xEDcale al usuario conectar TikTok en productmaker.app/integrations.", z4.object({}).shape, async () => {
1276
+ server.tool("list_tiktok_advertisers", "Lista las cuentas de TikTok Ads conectadas. Si est\xE1 vac\xEDa, ind\xEDcale al usuario conectar TikTok en productmaker.app/integrations.", z5.object({}).shape, async () => {
1037
1277
  try {
1038
1278
  const r = await api.getJson("/v1/tiktok/advertisers");
1039
1279
  return asText(r);
@@ -1045,40 +1285,40 @@ function registerConnectionTools(server, api) {
1045
1285
  __name(registerConnectionTools, "registerConnectionTools");
1046
1286
 
1047
1287
  // src/tools/publish.ts
1048
- import { z as z5 } from "zod";
1049
- var ShopifyInput = z5.object({
1050
- taskId: z5.string().min(1),
1051
- shopDomain: z5.string().min(1),
1052
- numLandingImages: z5.number().int().min(1).max(7).optional(),
1053
- variantIndex: z5.number().int().min(0).optional(),
1288
+ import { z as z6 } from "zod";
1289
+ var ShopifyInput = z6.object({
1290
+ taskId: z6.string().min(1),
1291
+ shopDomain: z6.string().min(1),
1292
+ numLandingImages: z6.number().int().min(1).max(7).optional(),
1293
+ variantIndex: z6.number().int().min(0).optional(),
1054
1294
  /** ASK THE USER before setting: "¿Tu producto tiene variantes (talla, color, etc.)?".
1055
1295
  * When true, the landing renders variant-picker pills; when false, it ships
1056
1296
  * a single-option product page. Defaults to false. */
1057
- showVariantPicker: z5.boolean().optional()
1297
+ showVariantPicker: z6.boolean().optional()
1058
1298
  });
1059
- var McpCampaignConfig = z5.object({
1060
- dailyBudgetUsd: z5.number().positive().optional(),
1299
+ var McpCampaignConfig = z6.object({
1300
+ dailyBudgetUsd: z6.number().positive().optional(),
1061
1301
  /** ISO-3166-1 alpha-2 country codes. Meta accepts max 1 (single-country
1062
1302
  * ad set). TikTok takes only the first entry. */
1063
- targetCountries: z5.array(z5.string().length(2)).max(1).optional(),
1064
- campaignName: z5.string().trim().min(1).max(100).optional()
1303
+ targetCountries: z6.array(z6.string().length(2)).max(1).optional(),
1304
+ campaignName: z6.string().trim().min(1).max(100).optional()
1065
1305
  });
1066
- var MetaInput = z5.object({
1067
- taskId: z5.string().min(1),
1068
- adAccountId: z5.string().min(1),
1069
- pixelId: z5.string().min(1).optional(),
1070
- landingUrl: z5.string().url().optional(),
1306
+ var MetaInput = z6.object({
1307
+ taskId: z6.string().min(1),
1308
+ adAccountId: z6.string().min(1),
1309
+ pixelId: z6.string().min(1).optional(),
1310
+ landingUrl: z6.string().url().optional(),
1071
1311
  /** Select which angles to publish by 0-based index. Omit to publish all
1072
1312
  * successful angles. Example: a task with 3 angles → `[0,2]` publishes #0 + #2. */
1073
- angleIndexes: z5.array(z5.number().int().nonnegative()).optional(),
1313
+ angleIndexes: z6.array(z6.number().int().nonnegative()).optional(),
1074
1314
  campaignConfig: McpCampaignConfig.optional()
1075
1315
  });
1076
- var TikTokInput = z5.object({
1077
- taskId: z5.string().min(1),
1078
- advertiserId: z5.string().min(1),
1079
- pixelId: z5.string().min(1).optional(),
1080
- landingUrl: z5.string().url().optional(),
1081
- angleIndexes: z5.array(z5.number().int().nonnegative()).optional(),
1316
+ var TikTokInput = z6.object({
1317
+ taskId: z6.string().min(1),
1318
+ advertiserId: z6.string().min(1),
1319
+ pixelId: z6.string().min(1).optional(),
1320
+ landingUrl: z6.string().url().optional(),
1321
+ angleIndexes: z6.array(z6.number().int().nonnegative()).optional(),
1082
1322
  campaignConfig: McpCampaignConfig.optional()
1083
1323
  });
1084
1324
  function registerPublishTools(server, api) {
@@ -1180,13 +1420,13 @@ function toTikTokCampaignConfig(cfg) {
1180
1420
  __name(toTikTokCampaignConfig, "toTikTokCampaignConfig");
1181
1421
 
1182
1422
  // src/tools/campaigns.ts
1183
- import { z as z6 } from "zod";
1184
- var MetaStatusInput = z6.object({
1185
- campaignId: z6.string().min(1).describe("Meta campaign ID returned by publish_to_meta.")
1423
+ import { z as z7 } from "zod";
1424
+ var MetaStatusInput = z7.object({
1425
+ campaignId: z7.string().min(1).describe("Meta campaign ID returned by publish_to_meta.")
1186
1426
  });
1187
- var TikTokStatusInput = z6.object({
1188
- campaignId: z6.string().min(1).describe("TikTok campaign ID returned by publish_to_tiktok."),
1189
- advertiserId: z6.string().min(1).describe("The same advertiserId used to publish the campaign.")
1427
+ var TikTokStatusInput = z7.object({
1428
+ campaignId: z7.string().min(1).describe("TikTok campaign ID returned by publish_to_tiktok."),
1429
+ advertiserId: z7.string().min(1).describe("The same advertiserId used to publish the campaign.")
1190
1430
  });
1191
1431
  function registerCampaignTools(server, api) {
1192
1432
  server.tool("get_meta_campaign_status", "Consulta el estado actual + m\xE9tricas lifetime (spend, impressions, clicks, reach) de una campa\xF1a Meta publicada. Llama esto DESPU\xC9S de `publish_to_meta` para confirmar que la campa\xF1a se cre\xF3 correctamente y para reportar performance al usuario. Es read-only \u2014 no modifica nada en Meta Ads Manager.", MetaStatusInput.shape, async (input) => {
@@ -1304,6 +1544,7 @@ function createServer(opts) {
1304
1544
  registerCreativeTools(server, api, {
1305
1545
  transport
1306
1546
  });
1547
+ registerScriptReviewTools(server, api);
1307
1548
  registerConnectionTools(server, api);
1308
1549
  registerPublishTools(server, api);
1309
1550
  registerCampaignTools(server, api);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@productmaker/mcp",
3
- "version": "0.4.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "private": false,
6
6
  "description": "Product Maker MCP server — connect Claude Desktop, Claude Code, ChatGPT, or Gemini to Product Maker AI to generate dropshipping landing pages, UGC videos, and ad creatives, and publish to Shopify and Meta Ads from your AI assistant.",