ponch-mcp-server 1.0.76 → 1.0.78

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/dist/index.js CHANGED
@@ -47,14 +47,14 @@ function resolveAuth() {
47
47
  const resolvedPath = saPath.startsWith("/") ? saPath : (0, import_path.join)(process.cwd(), saPath);
48
48
  if ((0, import_fs.existsSync)(resolvedPath)) {
49
49
  return {
50
- mode: "cowork",
50
+ canSwitchTenant: true,
51
+ // service account = admin del SaaS
51
52
  tenantId: null,
52
53
  brandId: null,
53
54
  brands: [],
54
55
  userId: null,
55
56
  userName: null,
56
57
  rol: "super_admin",
57
- // service account = admin total
58
58
  serviceAccountPath: resolvedPath,
59
59
  credentialsPath: null
60
60
  };
@@ -65,7 +65,7 @@ function resolveAuth() {
65
65
  try {
66
66
  const creds = JSON.parse((0, import_fs.readFileSync)(CREDENTIALS_PATH, "utf-8"));
67
67
  return {
68
- mode: creds.rol === "super_admin" ? "cowork" : "tenant",
68
+ canSwitchTenant: creds.rol === "super_admin",
69
69
  tenantId: creds.tenantId ?? null,
70
70
  brandId: creds.brands?.[0]?.id ?? null,
71
71
  brands: creds.brands ?? [],
@@ -80,7 +80,7 @@ function resolveAuth() {
80
80
  }
81
81
  }
82
82
  return {
83
- mode: "tenant",
83
+ canSwitchTenant: false,
84
84
  tenantId: null,
85
85
  brandId: null,
86
86
  brands: [],
@@ -108,6 +108,10 @@ function readCredentials() {
108
108
  var Session = class {
109
109
  context;
110
110
  _authContext;
111
+ _clientIdentity = {
112
+ mcpClient: null,
113
+ mcpClientVersion: null
114
+ };
111
115
  constructor(auth) {
112
116
  this._authContext = auth;
113
117
  this.context = {
@@ -115,8 +119,26 @@ var Session = class {
115
119
  brandId: auth.brandId
116
120
  };
117
121
  }
118
- get mode() {
119
- return this._authContext.mode;
122
+ /**
123
+ * Captura identidad del cliente MCP. Llamado al recibir handshake
124
+ * `initialize` del SDK. Idempotente — si se llama 2 veces, segunda no
125
+ * sobrescribe (el cliente no cambia mid-session).
126
+ */
127
+ setMcpClientIdentity(name, version) {
128
+ if (this._clientIdentity.mcpClient === null) {
129
+ this._clientIdentity.mcpClient = name;
130
+ this._clientIdentity.mcpClientVersion = version;
131
+ }
132
+ }
133
+ get mcpClientIdentity() {
134
+ return this._clientIdentity;
135
+ }
136
+ /**
137
+ * true → super_admin del SaaS (puede cambiar tenant). false → tenant
138
+ * member normal. Independiente del SDK que esté corriendo el MCP.
139
+ */
140
+ get canSwitchTenant() {
141
+ return this._authContext.canSwitchTenant;
120
142
  }
121
143
  get tenantId() {
122
144
  return this.context.tenantId;
@@ -137,19 +159,27 @@ var Session = class {
137
159
  get userId() {
138
160
  return this._authContext.userId;
139
161
  }
140
- /** Rol del usuario (admin/encargado/empleado/super_admin). 211.1 HITO 6 */
162
+ /** Rol del usuario (super_admin | ROL00X del tenant). */
141
163
  get rol() {
142
164
  return this._authContext.rol;
143
165
  }
144
166
  setContext(tenantId, brandId) {
145
- if (this._authContext.mode !== "cowork") {
146
- throw new Error("set_context solo disponible en Modo Cowork");
167
+ if (!this._authContext.canSwitchTenant) {
168
+ throw new Error("set_context solo disponible para usuarios con permiso multi-tenant (super_admin).");
147
169
  }
148
170
  this.context.tenantId = tenantId;
149
171
  this.context.brandId = brandId;
150
172
  }
151
173
  /**
152
- * Actualiza el contexto despues de connect_account (Modo B)
174
+ * Revoca canSwitchTenant en runtime. Usado cuando el bootstrap detecta
175
+ * token expirado — el usuario debe re-autenticar via connect_account
176
+ * antes de poder cambiar de tenant.
177
+ */
178
+ revokeCrossTenant() {
179
+ this._authContext.canSwitchTenant = false;
180
+ }
181
+ /**
182
+ * Actualiza el contexto después de connect_account.
153
183
  */
154
184
  setTenantContext(tenantId, brandId, brands, userId, userName) {
155
185
  this.context.tenantId = tenantId;
@@ -160,16 +190,10 @@ var Session = class {
160
190
  this._authContext.userId = userId;
161
191
  this._authContext.userName = userName;
162
192
  }
163
- /**
164
- * Fuerza el modo (usado cuando credentials existen pero token expiro)
165
- */
166
- forceMode(newMode) {
167
- this._authContext.mode = newMode;
168
- }
169
193
  requireTenant() {
170
194
  if (!this.context.tenantId) {
171
195
  throw new Error(
172
- this._authContext.mode === "cowork" ? "Usa set_context para seleccionar un tenant primero" : "No conectado. Usa connect_account para conectar tu cuenta de Ponch"
196
+ this._authContext.canSwitchTenant ? "Usa set_context para seleccionar un tenant primero." : "No conectado. Usa connect_account para conectar tu cuenta de Ponch."
173
197
  );
174
198
  }
175
199
  return this.context.tenantId;
@@ -188,11 +212,11 @@ var import_app = require("firebase/app");
188
212
  var import_lite = require("firebase/firestore/lite");
189
213
  var import_auth = require("firebase/auth");
190
214
  var import_fs2 = require("fs");
191
- var mode = "none";
215
+ var sdkMode = "none";
192
216
  var adminDb;
193
217
  var clientDb;
194
218
  function initFirebaseAdmin(serviceAccountPath) {
195
- if (mode !== "none") return;
219
+ if (sdkMode !== "none") return;
196
220
  const serviceAccount = JSON.parse(
197
221
  (0, import_fs2.readFileSync)(serviceAccountPath, "utf-8")
198
222
  );
@@ -200,10 +224,10 @@ function initFirebaseAdmin(serviceAccountPath) {
200
224
  credential: import_firebase_admin.default.credential.cert(serviceAccount)
201
225
  });
202
226
  adminDb = import_firebase_admin.default.firestore();
203
- mode = "admin";
227
+ sdkMode = "admin";
204
228
  }
205
229
  async function initFirebaseClient() {
206
- if (mode !== "none") return true;
230
+ if (sdkMode !== "none") return true;
207
231
  const creds = readCredentials();
208
232
  if (!creds) {
209
233
  console.error("[firestore] No hay credentials.json");
@@ -221,7 +245,7 @@ async function initFirebaseClient() {
221
245
  if (newCustomToken) {
222
246
  await (0, import_auth.signInWithCustomToken)(auth, newCustomToken);
223
247
  clientDb = (0, import_lite.getFirestore)(app);
224
- mode = "client";
248
+ sdkMode = "client";
225
249
  saveCredentials({ ...creds, customToken: newCustomToken });
226
250
  console.error("[firestore] Firebase Client re-autenticado con refreshToken");
227
251
  return true;
@@ -241,7 +265,7 @@ async function initFirebaseClient() {
241
265
  console.error("[firestore] refreshToken guardado para futuras sesiones");
242
266
  }
243
267
  clientDb = (0, import_lite.getFirestore)(app);
244
- mode = "client";
268
+ sdkMode = "client";
245
269
  console.error("[firestore] Firebase Client autenticado con customToken");
246
270
  return true;
247
271
  } catch (err) {
@@ -306,27 +330,27 @@ async function reauthClient(customToken) {
306
330
  console.error("[firestore] refreshToken guardado");
307
331
  }
308
332
  clientDb = (0, import_lite.getFirestore)(app);
309
- mode = "client";
333
+ sdkMode = "client";
310
334
  return true;
311
335
  } catch (err) {
312
336
  console.error("[firestore] Error re-autenticando:", err);
313
337
  return false;
314
338
  }
315
339
  }
316
- function getMode() {
317
- return mode;
340
+ function getSdkMode() {
341
+ return sdkMode;
318
342
  }
319
343
  function getAdminDb() {
320
- if (mode !== "admin") {
321
- throw new Error("getAdminDb: modo admin requerido (actual: " + mode + ")");
344
+ if (sdkMode !== "admin") {
345
+ throw new Error("getAdminDb: SDK admin requerido (actual: " + sdkMode + ")");
322
346
  }
323
347
  return adminDb;
324
348
  }
325
349
  async function queryByTenant(session, collectionName, filters = [], options = {}) {
326
350
  const tenantId = session.requireTenant();
327
- if (mode === "admin") {
351
+ if (sdkMode === "admin") {
328
352
  return queryByTenantAdmin(tenantId, collectionName, filters, options);
329
- } else if (mode === "client") {
353
+ } else if (sdkMode === "client") {
330
354
  return queryByTenantClient(tenantId, collectionName, filters, options);
331
355
  }
332
356
  throw new Error("Firebase no inicializado");
@@ -362,11 +386,11 @@ async function queryByTenantClient(tenantId, collectionName, filters, options) {
362
386
  return snapshot.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
363
387
  }
364
388
  async function readDoc(collectionName, docId) {
365
- if (mode === "admin") {
389
+ if (sdkMode === "admin") {
366
390
  const doc = await adminDb.collection(collectionName).doc(docId).get();
367
391
  if (!doc.exists) return null;
368
392
  return { id: doc.id, ...doc.data() };
369
- } else if (mode === "client") {
393
+ } else if (sdkMode === "client") {
370
394
  const ref = (0, import_lite.doc)(clientDb, collectionName, docId);
371
395
  const doc = await (0, import_lite.getDoc)(ref);
372
396
  if (!doc.exists()) return null;
@@ -374,10 +398,21 @@ async function readDoc(collectionName, docId) {
374
398
  }
375
399
  throw new Error("Firebase no inicializado");
376
400
  }
401
+ async function listCollection(path) {
402
+ if (sdkMode === "admin") {
403
+ const snap = await adminDb.collection(path).get();
404
+ return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
405
+ } else if (sdkMode === "client") {
406
+ const ref = (0, import_lite.collection)(clientDb, path);
407
+ const snap = await (0, import_lite.getDocs)(ref);
408
+ return snap.docs.map((doc) => ({ id: doc.id, ...doc.data() }));
409
+ }
410
+ throw new Error("Firebase no inicializado");
411
+ }
377
412
  async function writeDoc(collectionName, docId, data) {
378
- if (mode === "admin") {
413
+ if (sdkMode === "admin") {
379
414
  await adminDb.collection(collectionName).doc(docId).set(data, { merge: true });
380
- } else if (mode === "client") {
415
+ } else if (sdkMode === "client") {
381
416
  const ref = (0, import_lite.doc)(clientDb, collectionName, docId);
382
417
  await (0, import_lite.setDoc)(ref, data, { merge: true });
383
418
  } else {
@@ -385,9 +420,9 @@ async function writeDoc(collectionName, docId, data) {
385
420
  }
386
421
  }
387
422
  async function updateDoc(collectionName, docId, data) {
388
- if (mode === "admin") {
423
+ if (sdkMode === "admin") {
389
424
  await adminDb.collection(collectionName).doc(docId).update(data);
390
- } else if (mode === "client") {
425
+ } else if (sdkMode === "client") {
391
426
  const ref = (0, import_lite.doc)(clientDb, collectionName, docId);
392
427
  await (0, import_lite.updateDoc)(ref, data);
393
428
  } else {
@@ -395,7 +430,7 @@ async function updateDoc(collectionName, docId, data) {
395
430
  }
396
431
  }
397
432
  function serverTimestamp() {
398
- if (mode === "admin") {
433
+ if (sdkMode === "admin") {
399
434
  return import_firebase_admin.default.firestore.FieldValue.serverTimestamp();
400
435
  } else {
401
436
  return (0, import_lite.serverTimestamp)();
@@ -407,10 +442,10 @@ var import_zod = require("zod");
407
442
  var import_functions = require("firebase/functions");
408
443
  var import_app2 = require("firebase/app");
409
444
  function registerContextTools(server, session) {
410
- if (session.mode === "cowork") {
445
+ if (session.canSwitchTenant) {
411
446
  server.tool(
412
447
  "set_context",
413
- "Establece el tenant y brand para esta sesion. Solo disponible en Modo Cowork (admin).",
448
+ "Establece el tenant y brand para esta sesion. Solo disponible para super_admin.",
414
449
  {
415
450
  tenantId: import_zod.z.string().describe('ID del tenant (ej: "atteyo")'),
416
451
  brandId: import_zod.z.string().describe('ID de la brand (ej: "ponch")')
@@ -418,8 +453,8 @@ function registerContextTools(server, session) {
418
453
  async ({ tenantId, brandId }) => {
419
454
  const brand = await readDoc(`tenants/${tenantId}/marketing_config`, brandId);
420
455
  if (!brand) {
421
- const brandsSnap = await getAdminDb().collection("tenants").doc(tenantId).collection("marketing_config").get();
422
- if (brandsSnap.empty) {
456
+ const brands = await listCollection(`tenants/${tenantId}/marketing_config`);
457
+ if (brands.length === 0) {
423
458
  return {
424
459
  content: [{ type: "text", text: JSON.stringify({ ok: false, error: `Tenant "${tenantId}" no tiene marketing_config` }) }]
425
460
  };
@@ -430,7 +465,7 @@ function registerContextTools(server, session) {
430
465
  text: JSON.stringify({
431
466
  ok: false,
432
467
  error: `Brand "${brandId}" no existe en tenant "${tenantId}"`,
433
- brandsDisponibles: brandsSnap.docs.map((d) => d.id)
468
+ brandsDisponibles: brands.map((b) => b.id)
434
469
  })
435
470
  }]
436
471
  };
@@ -2120,7 +2155,7 @@ var TenantStatusFieldsSchema = import_zod14.z.object({
2120
2155
  cancellation: CancellationInfoSchema
2121
2156
  }).passthrough();
2122
2157
  var AuditActorSchema = import_zod15.z.object({
2123
- type: import_zod15.z.enum(["user", "martin", "system", "cf_scheduled", "webhook"]),
2158
+ type: import_zod15.z.enum(["user", "martin", "mcp_client", "system", "cf_scheduled", "webhook"]),
2124
2159
  uid: import_zod15.z.string().nullable(),
2125
2160
  nombre: import_zod15.z.string(),
2126
2161
  metadata: import_zod15.z.record(import_zod15.z.string(), import_zod15.z.any()).optional()
@@ -2194,11 +2229,10 @@ var AddonActivoSchema = import_zod16.z.object({
2194
2229
  estado: import_zod16.z.enum(["active", "cancelled"])
2195
2230
  }).strict();
2196
2231
  var TipoMemoriaEnum = import_zod17.z.enum([
2197
- "filtro",
2198
2232
  "preferencia",
2233
+ "regla",
2199
2234
  "patron",
2200
- "aprendido",
2201
- "delegacion"
2235
+ "aversion"
2202
2236
  ]);
2203
2237
  var StatusMemoriaEnum = import_zod17.z.enum([
2204
2238
  "explicito",
@@ -2726,33 +2760,48 @@ function registerCoreTools(server, session) {
2726
2760
  }
2727
2761
 
2728
2762
  // src/tools/marketing.ts
2729
- var import_zod38 = require("zod");
2763
+ var import_zod49 = require("zod");
2730
2764
 
2731
2765
  // ../packages/marketing-business-logic/dist/index.js
2732
2766
  var import_firebase_admin2 = require("firebase-admin");
2733
- var import_firebase_admin3 = require("firebase-admin");
2734
2767
  var import_zod27 = require("zod");
2735
2768
  var import_zod28 = require("zod");
2736
2769
  var import_zod29 = require("zod");
2770
+ var import_fs3 = require("fs");
2771
+ var import_path2 = require("path");
2772
+ var import_url = require("url");
2773
+ var import_firebase_admin3 = require("firebase-admin");
2774
+ var import_zod30 = require("zod");
2737
2775
  var import_firebase_admin4 = require("firebase-admin");
2776
+ var import_zod31 = require("zod");
2738
2777
  var import_firebase_admin5 = require("firebase-admin");
2778
+ var import_zod32 = require("zod");
2739
2779
  var import_firebase_admin6 = require("firebase-admin");
2740
- var import_zod30 = require("zod");
2780
+ var import_zod33 = require("zod");
2741
2781
  var import_firebase_admin7 = require("firebase-admin");
2742
- var import_zod31 = require("zod");
2743
- var import_zod32 = require("zod");
2782
+ var import_zod34 = require("zod");
2783
+ var import_zod35 = require("zod");
2744
2784
  var import_firebase_admin8 = require("firebase-admin");
2745
- var import_zod33 = require("zod");
2785
+ var import_zod36 = require("zod");
2786
+ var import_zod37 = require("zod");
2787
+ var import_zod38 = require("zod");
2746
2788
  var import_firebase_admin9 = require("firebase-admin");
2789
+ var import_zod39 = require("zod");
2747
2790
  var import_firebase_admin10 = require("firebase-admin");
2791
+ var import_zod40 = require("zod");
2792
+ var import_zod41 = require("zod");
2793
+ var import_zod42 = require("zod");
2794
+ var import_zod43 = require("zod");
2795
+ var import_zod44 = require("zod");
2748
2796
  var import_firestore3 = require("firebase-admin/firestore");
2749
2797
  var import_firestore4 = require("firebase-admin/firestore");
2750
2798
  var import_firestore5 = require("firebase-admin/firestore");
2751
2799
  var import_firestore6 = require("firebase-admin/firestore");
2752
- var import_zod34 = require("zod");
2800
+ var import_zod45 = require("zod");
2753
2801
  var import_firestore7 = require("firebase-admin/firestore");
2754
- var import_zod35 = require("zod");
2802
+ var import_zod46 = require("zod");
2755
2803
  var import_firestore8 = require("firebase-admin/firestore");
2804
+ var import_meta = {};
2756
2805
  var RULE_NEGATIVES = {
2757
2806
  allowFaces: "no people, no faces, no hands",
2758
2807
  allowProductTransform: "no distorted products, no warped objects",
@@ -2895,6 +2944,7 @@ async function brandBriefWriter(input) {
2895
2944
  return {
2896
2945
  ok: false,
2897
2946
  error: "Brand Brief no cumple el schema esperado.",
2947
+ code: "BRAND_BRIEF_VALIDATION_FAILED",
2898
2948
  detalle: validation.error,
2899
2949
  path: validation.path,
2900
2950
  recibido: validation.received,
@@ -2905,7 +2955,7 @@ async function brandBriefWriter(input) {
2905
2955
  const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
2906
2956
  const brandSnap = await brandRef.get();
2907
2957
  if (!brandSnap.exists) {
2908
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
2958
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
2909
2959
  }
2910
2960
  const brief = validation.parsed;
2911
2961
  const briefWithMeta = {
@@ -2929,90 +2979,76 @@ async function brandBriefWriter(input) {
2929
2979
  mensaje: `Brand Brief guardado para "${brandId}". El tenant puede validar los campos en Config > Mi Negocio.`
2930
2980
  };
2931
2981
  }
2932
- async function save(input) {
2933
- const { db, tenantId, brandId, plan } = input;
2934
- const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
2935
- const brandSnap = await brandRef.get();
2936
- if (!brandSnap.exists) {
2937
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
2982
+ var _localeCache = {};
2983
+ function _resolveLocaleFile(locale) {
2984
+ let baseDir;
2985
+ try {
2986
+ baseDir = typeof __dirname !== "undefined" ? __dirname : (0, import_path2.dirname)((0, import_url.fileURLToPath)(import_meta.url));
2987
+ } catch {
2988
+ baseDir = process.cwd();
2938
2989
  }
2939
- const { blogStrategy, ...planSinBlogStrategy } = plan;
2940
- const planWithMeta = {
2941
- ...planSinBlogStrategy,
2942
- fechaCreacion: (/* @__PURE__ */ new Date()).toISOString(),
2943
- creadoPorId: "mcp-cowork"
2944
- };
2945
- await brandRef.set({
2946
- plan: planWithMeta,
2947
- ...blogStrategy ? { blogStrategy } : {},
2948
- id: brandId,
2949
- brandId,
2950
- tenantId,
2951
- schemaVersion: 1,
2952
- updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
2953
- }, { merge: true });
2954
- return {
2955
- ok: true,
2956
- mensaje: `Plan de marketing guardado para brand "${brandId}"` + (blogStrategy ? " (incluye blogStrategy)" : "")
2957
- };
2990
+ const candidates = [
2991
+ (0, import_path2.join)(baseDir, "..", "..", "..", "..", "src", "i18n", "locales", `${locale}.json`),
2992
+ (0, import_path2.join)(baseDir, "..", "..", "..", "src", "i18n", "locales", `${locale}.json`),
2993
+ (0, import_path2.join)(baseDir, "i18n", "locales", `${locale}.json`),
2994
+ (0, import_path2.join)(baseDir, "..", "i18n", "locales", `${locale}.json`)
2995
+ ];
2996
+ for (const candidate of candidates) {
2997
+ try {
2998
+ (0, import_fs3.readFileSync)(candidate, "utf-8");
2999
+ return candidate;
3000
+ } catch {
3001
+ continue;
3002
+ }
3003
+ }
3004
+ throw new Error(
3005
+ `getMessage: locale file '${locale}.json' not found. Tried:
3006
+ ` + candidates.map((c) => ` - ${c}`).join("\n")
3007
+ );
2958
3008
  }
2959
- async function updateField(input) {
2960
- const { db, tenantId, brandId, field, value } = input;
2961
- const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
2962
- const brandSnap = await brandRef.get();
2963
- if (!brandSnap.exists) {
2964
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
3009
+ function _loadLocale(locale) {
3010
+ if (_localeCache[locale]) return _localeCache[locale];
3011
+ const path2 = _resolveLocaleFile(locale);
3012
+ const raw = (0, import_fs3.readFileSync)(path2, "utf-8");
3013
+ const data = JSON.parse(raw);
3014
+ _localeCache[locale] = data;
3015
+ return data;
3016
+ }
3017
+ function _resolvePath(obj, path2) {
3018
+ const parts = path2.split(".");
3019
+ let current = obj;
3020
+ for (const part of parts) {
3021
+ if (current === null || typeof current !== "object") return null;
3022
+ current = current[part];
2965
3023
  }
2966
- const brandData = brandSnap.data();
2967
- let parsedValue = value;
2968
- if (typeof value === "string") {
2969
- const trimmed = value.trim();
2970
- if (trimmed.startsWith("[") && trimmed.endsWith("]") || trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith('"') && trimmed.endsWith('"')) {
2971
- try {
2972
- parsedValue = JSON.parse(trimmed);
2973
- } catch {
2974
- }
3024
+ return typeof current === "string" ? current : null;
3025
+ }
3026
+ function _interpolate(template, vars, context) {
3027
+ return template.replace(/\{(\w+)\}/g, (_match, key) => {
3028
+ if (!vars || !(key in vars)) {
3029
+ throw new Error(
3030
+ `getMessage: variable '${key}' in template '${context.path}' (${context.locale}) not provided in vars. Pass { ${key}: '...' } when calling getMessage.`
3031
+ );
2975
3032
  }
3033
+ return vars[key];
3034
+ });
3035
+ }
3036
+ var SUPPORTED_LOCALES = ["es", "en"];
3037
+ function getMessage(path2, locale, vars) {
3038
+ if (!SUPPORTED_LOCALES.includes(locale)) {
3039
+ throw new Error(
3040
+ `getMessage: locale '${locale}' not supported. Active locales: ${SUPPORTED_LOCALES.join(", ")}.`
3041
+ );
2976
3042
  }
2977
- const validation = validatePlanField(field, parsedValue);
2978
- if (!validation.ok) {
2979
- return {
2980
- ok: false,
2981
- error: `Campo "${field}" no cumple el schema esperado.`,
2982
- detalle: validation.error,
2983
- path: validation.path,
2984
- recibido: validation.received,
2985
- ejemplo_correcto: PLAN_FIELD_EXAMPLES[field],
2986
- hint: "Manda el valor como objeto/array nativo, NO como string JSON. Usa el ejemplo arriba como referencia exacta del shape esperado."
2987
- };
2988
- }
2989
- const updatePayload = {
2990
- id: brandId,
2991
- brandId,
2992
- tenantId,
2993
- schemaVersion: 1,
2994
- updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
2995
- };
2996
- if (field === "blogStrategy") {
2997
- updatePayload.blogStrategy = validation.parsed;
2998
- } else {
2999
- const currentPlan = brandData.plan ?? {};
3000
- updatePayload.plan = {
3001
- ...currentPlan,
3002
- [field]: validation.parsed,
3003
- updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3004
- };
3043
+ const data = _loadLocale(locale);
3044
+ const template = _resolvePath(data, path2);
3045
+ if (template === null) {
3046
+ throw new Error(
3047
+ `getMessage: path '${path2}' not found in locale '${locale}'. Add it to src/i18n/locales/${locale}.json.`
3048
+ );
3005
3049
  }
3006
- await brandRef.set(updatePayload, { merge: true });
3007
- return {
3008
- ok: true,
3009
- mensaje: `Campo "${field}" actualizado en el plan de "${brandId}"`
3010
- };
3050
+ return _interpolate(template, vars, { path: path2, locale });
3011
3051
  }
3012
- var planWriter = {
3013
- save,
3014
- updateField
3015
- };
3016
3052
  var DisabledReasonCodeSchema = import_zod29.z.enum([
3017
3053
  "paywall",
3018
3054
  // Acción requiere upgrade de plan
@@ -3038,44 +3074,8 @@ var DisabledOutputSchema = import_zod29.z.object({
3038
3074
  */
3039
3075
  detail: import_zod29.z.record(import_zod29.z.string(), import_zod29.z.string()).optional()
3040
3076
  });
3041
- var TEMPLATES = {
3042
- es: {
3043
- paywall: "Esta acci\xF3n requiere actualizar tu plan.",
3044
- oauth_missing: "Necesitas conectar {service} primero.",
3045
- data_missing: "Falta informaci\xF3n: {detail}",
3046
- plan_not_includes: "Tu plan no incluye {feature}",
3047
- feature_disabled: "Esta funci\xF3n est\xE1 deshabilitada para tu cuenta.",
3048
- other: "{detail}"
3049
- },
3050
- en: {
3051
- paywall: "This action requires upgrading your plan.",
3052
- oauth_missing: "You need to connect {service} first.",
3053
- data_missing: "Missing information: {detail}",
3054
- plan_not_includes: "Your plan doesn't include {feature}",
3055
- feature_disabled: "This feature is disabled for your account.",
3056
- other: "{detail}"
3057
- }
3058
- };
3059
3077
  function getDisabledMessage(code, locale, vars) {
3060
- if (!(locale in TEMPLATES)) {
3061
- throw new Error(
3062
- `Locale '${locale}' no soportado en disabledMessages. Locales activos: ${Object.keys(TEMPLATES).join(", ")}.`
3063
- );
3064
- }
3065
- const template = TEMPLATES[locale][code];
3066
- if (!template) {
3067
- throw new Error(
3068
- `Disabled code '${code}' no tiene template definido para locale '${locale}'.`
3069
- );
3070
- }
3071
- return template.replace(/\{(\w+)\}/g, (_match, key) => {
3072
- if (!vars || !(key in vars)) {
3073
- throw new Error(
3074
- `getDisabledMessage: variable '${key}' del template '${code}' (${locale}) no provista en vars. Helper debe pasar { ${key}: '...' } en detail.`
3075
- );
3076
- }
3077
- return vars[key];
3078
- });
3078
+ return getMessage(`marketing.disabled.${code}`, locale, vars);
3079
3079
  }
3080
3080
  var SideEffectEnum = import_zod28.z.enum([
3081
3081
  "reads_firestore",
@@ -3107,6 +3107,10 @@ var ExtractTargetPathSchema = import_zod28.z.custom((val) => typeof val === "fun
3107
3107
  var ExtractChangesSchema = import_zod28.z.custom((val) => typeof val === "function", {
3108
3108
  message: "extractChanges debe ser funci\xF3n (input, output) => { before, after }"
3109
3109
  });
3110
+ var InputPredicateSchema = import_zod28.z.custom(
3111
+ (val) => typeof val === "function",
3112
+ { message: "predicado debe ser funci\xF3n (input) => boolean" }
3113
+ );
3110
3114
  var MartinContractSchema = import_zod28.z.object({
3111
3115
  // Schema versioning (Dim 4 backbone)
3112
3116
  schemaVersion: import_zod28.z.literal(1).default(1),
@@ -3122,6 +3126,16 @@ var MartinContractSchema = import_zod28.z.object({
3122
3126
  destructive: import_zod28.z.boolean(),
3123
3127
  affectsPublication: import_zod28.z.boolean(),
3124
3128
  affectsExternal: import_zod28.z.boolean(),
3129
+ /**
3130
+ * Override runtime del flag `destructive`. Si el predicado retorna
3131
+ * true para un input específico, el wrapper trata la invocación
3132
+ * como destructiva aun si `destructive: false`. Útil cuando una
3133
+ * misma tool tiene 2 modos según args (ej. update con
3134
+ * accionContenidoExistente='descartar').
3135
+ */
3136
+ isDestructiveForInput: InputPredicateSchema.optional(),
3137
+ /** Mismo patrón para affectsPublication (override runtime). */
3138
+ isPublicationForInput: InputPredicateSchema.optional(),
3125
3139
  // Presentación al usuario (i18n)
3126
3140
  martinSummaryTemplate: SummaryTemplateSchema,
3127
3141
  martinConfirmationTemplate: ConfirmationTemplateSchema.optional(),
@@ -3240,10 +3254,11 @@ var MartinContractSchema = import_zod28.z.object({
3240
3254
  }
3241
3255
  });
3242
3256
  var ParamsSchema = import_zod27.z.object({
3243
- tenantId: import_zod27.z.string().min(1),
3244
- brandId: import_zod27.z.string().min(1),
3245
- /** Plan completo generado por Claude. Puede incluir blogStrategy. */
3246
- plan: import_zod27.z.record(import_zod27.z.string(), import_zod27.z.unknown())
3257
+ tenantId: import_zod27.z.string().min(1).describe("Tenant identifier (the business account)."),
3258
+ brandId: import_zod27.z.string().min(1).describe("Brand identifier within the tenant."),
3259
+ brandBrief: import_zod27.z.record(import_zod27.z.string(), import_zod27.z.unknown()).describe(
3260
+ "Full Brand Brief object generated by the LLM. The helper runs full Zod validation against the canonical brandBrief schema before persisting."
3261
+ )
3247
3262
  });
3248
3263
  var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
3249
3264
  import_zod27.z.object({
@@ -3253,6 +3268,7 @@ var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
3253
3268
  import_zod27.z.object({
3254
3269
  ok: import_zod27.z.literal(false),
3255
3270
  error: import_zod27.z.string(),
3271
+ code: import_zod27.z.enum(["BRAND_BRIEF_VALIDATION_FAILED", "BRAND_NOT_FOUND"]).optional(),
3256
3272
  detalle: import_zod27.z.unknown().optional(),
3257
3273
  path: import_zod27.z.string().optional(),
3258
3274
  recibido: import_zod27.z.unknown().optional(),
@@ -3261,31 +3277,34 @@ var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
3261
3277
  })
3262
3278
  ]);
3263
3279
  var rawContract = {
3264
- name: "save_marketing_plan",
3265
- description: "Guarda el plan de marketing de una brand. Si el plan incluye blogStrategy, la extrae al nivel brand (no dentro de plan).",
3280
+ name: "save_brand_brief",
3281
+ description: "Persist the Brand Brief generated by Claude into the brand config doc. Applies full Zod validation against the canonical Brand Brief schema; returns code=BRAND_BRIEF_VALIDATION_FAILED with detalle/path/recibido/ejemplo_correcto/hint so the LLM can self-correct on retry.",
3266
3282
  paramsSchema: ParamsSchema,
3267
3283
  outputSchema: OutputSchema,
3268
- // No es destructivo (sobrescribe plan completo, pero plan se regenera fácil),
3269
- // no afecta publicación, no llama externo.
3284
+ // Sobrescribe brandBrief existente vía merge reversible (regenerar el
3285
+ // brief es barato), no afecta publicación, no llama externo.
3270
3286
  requiresConfirmation: false,
3271
3287
  destructive: false,
3272
3288
  affectsPublication: false,
3273
3289
  affectsExternal: false,
3274
3290
  martinSummaryTemplate: (input, output, locale) => {
3275
3291
  if (!output.ok) {
3276
- if (locale === "en") return `I couldn't save the plan: ${output.error}`;
3277
- return `No pude guardar el plan: ${output.error}`;
3292
+ if (output.code) {
3293
+ return getMessage(`marketing.errors.${output.code}`, locale);
3294
+ }
3295
+ return getMessage("marketing.safeError.generic", locale);
3278
3296
  }
3279
- if (locale === "en") return `I saved the marketing plan for ${input.brandId}.`;
3280
- return `Guard\xE9 el plan de marketing de ${input.brandId}.`;
3297
+ if (locale === "en") {
3298
+ return `I saved the brand brief for "${input.brandId}". You can review it in Config > My Business.`;
3299
+ }
3300
+ return `Guard\xE9 el brand brief de "${input.brandId}". Lo puedes revisar en Config > Mi Negocio.`;
3281
3301
  },
3282
- // Auditoría — OBLIGATORIO porque writes_firestore + updates_brand_config.
3283
- auditAction: "marketing.plan.guardar",
3302
+ auditAction: "marketing.brand_brief.guardar",
3284
3303
  extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_config/${input.brandId}`,
3285
3304
  extractChanges: (input, output) => ({
3286
3305
  before: null,
3287
- // El helper no lee el plan previo en save (solo merge).
3288
- after: output.ok ? { plan: input.plan } : null
3306
+ // El helper no lee el brief previo (set merge).
3307
+ after: output.ok ? { brandBrief: input.brandBrief } : null
3289
3308
  }),
3290
3309
  quotasConsumed: [],
3291
3310
  permissionScope: "module",
@@ -3293,97 +3312,360 @@ var rawContract = {
3293
3312
  permissionAction: "editar",
3294
3313
  sideEffects: ["writes_firestore", "updates_brand_config"]
3295
3314
  };
3296
- var planWriterSaveContract = MartinContractSchema.parse(
3315
+ var brandBriefWriterContract = MartinContractSchema.parse(
3297
3316
  rawContract
3298
3317
  );
3299
- async function collectionSuggestionsWriter(input) {
3300
- const { db, tenantId, brandId, suggestions } = input;
3301
- const validation = validateCollectionSuggestions(suggestions);
3302
- if (!validation.ok) {
3303
- return {
3304
- ok: false,
3305
- error: "Collection suggestions no cumplen el schema esperado.",
3306
- detalle: validation.error,
3307
- path: validation.path,
3308
- recibido: validation.received,
3309
- ejemplo_correcto: validation.example,
3310
- hint: "Revisa max chars: metaTitle <=60, metaDescription <=158, imageAlt <=125. Corrige el campo en path."
3311
- };
3312
- }
3313
- const validSuggestions = validation.parsed;
3318
+ async function save(input) {
3319
+ const { db, tenantId, brandId, plan } = input;
3314
3320
  const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
3315
3321
  const brandSnap = await brandRef.get();
3316
3322
  if (!brandSnap.exists) {
3317
- return { ok: false, error: `Brand ${brandId} no encontrada` };
3318
- }
3319
- const brandData = brandSnap.data();
3320
- const existing = brandData.collectionSuggestions ?? {};
3321
- const updated = { ...existing };
3322
- for (const s of validSuggestions) {
3323
- const id = String(s.collectionId);
3324
- updated[id] = {
3325
- suggestedTitle: s.suggestedTitle ?? null,
3326
- suggestedDescription: s.suggestedDescription ?? null,
3327
- suggestedMetaTitle: s.suggestedMetaTitle ?? null,
3328
- suggestedMetaDescription: s.suggestedMetaDescription ?? null,
3329
- suggestedHandle: s.suggestedHandle ?? null,
3330
- suggestedImageAlt: s.suggestedImageAlt ?? null,
3331
- keyword: s.keyword ?? null,
3332
- notas: s.notas ?? null,
3333
- estado: "pendiente",
3334
- generadoAt: import_firebase_admin4.firestore.FieldValue.serverTimestamp(),
3335
- generadoPor: "mcp-cowork"
3336
- };
3323
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
3337
3324
  }
3325
+ const { blogStrategy, ...planSinBlogStrategy } = plan;
3326
+ const planWithMeta = {
3327
+ ...planSinBlogStrategy,
3328
+ fechaCreacion: (/* @__PURE__ */ new Date()).toISOString(),
3329
+ creadoPorId: "mcp-cowork"
3330
+ };
3338
3331
  await brandRef.set({
3339
- collectionSuggestions: updated,
3332
+ plan: planWithMeta,
3333
+ ...blogStrategy ? { blogStrategy } : {},
3340
3334
  id: brandId,
3341
3335
  brandId,
3342
3336
  tenantId,
3343
3337
  schemaVersion: 1,
3344
- updatedAt: import_firebase_admin4.firestore.FieldValue.serverTimestamp()
3338
+ updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
3345
3339
  }, { merge: true });
3346
3340
  return {
3347
3341
  ok: true,
3348
- saved: validSuggestions.length,
3349
- message: `${validSuggestions.length} sugerencias guardadas. El tenant las ver\xE1 en Marketing > Mi Plan > Shopify.`
3342
+ mensaje: `Plan de marketing guardado para brand "${brandId}"` + (blogStrategy ? " (incluye blogStrategy)" : "")
3350
3343
  };
3351
3344
  }
3352
- var CAMPOS_PERMITIDOS = {
3353
- gbp: {
3354
- required: ["summary", "topicType"],
3355
- optional: ["callToAction", "perfilId"]
3356
- },
3357
- shopify_blog: {
3358
- // Sesion 188B: sincronizado con DatosBlogSchema v2 (27 campos)
3359
- // body es optional aqui para permitir save en 2 pasos:
3360
- // 1. save_generated_content con metadata (19 campos sin body)
3361
- // 2. update_generated_content con { body: "HTML completo..." }
3362
- // La CF publishToShopifyBlog valida que body exista antes de publicar.
3363
- required: [
3364
- "title",
3365
- "handle",
3366
- "metaTitle",
3367
- "metaDescription",
3368
- "summary",
3369
- "imageAltText",
3370
- "tags",
3371
- "quickAnswerBlock",
3372
- "faqItems",
3373
- "faqTitle",
3374
- "blogId",
3375
- "blogHandle",
3376
- "publishDate",
3377
- "languageCode",
3378
- "plataformaDestino"
3379
- ],
3380
- optional: [
3381
- "body",
3382
- "authorUserId",
3383
- "authorName",
3384
- "templateSuffix",
3385
- "isPublished",
3386
- "structuredData",
3345
+ async function updateField(input) {
3346
+ const { db, tenantId, brandId, field, value } = input;
3347
+ const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
3348
+ const brandSnap = await brandRef.get();
3349
+ if (!brandSnap.exists) {
3350
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
3351
+ }
3352
+ const brandData = brandSnap.data();
3353
+ let parsedValue = value;
3354
+ if (typeof value === "string") {
3355
+ const trimmed = value.trim();
3356
+ if (trimmed.startsWith("[") && trimmed.endsWith("]") || trimmed.startsWith("{") && trimmed.endsWith("}") || trimmed.startsWith('"') && trimmed.endsWith('"')) {
3357
+ try {
3358
+ parsedValue = JSON.parse(trimmed);
3359
+ } catch {
3360
+ }
3361
+ }
3362
+ }
3363
+ const validation = validatePlanField(field, parsedValue);
3364
+ if (!validation.ok) {
3365
+ return {
3366
+ ok: false,
3367
+ error: `Campo "${field}" no cumple el schema esperado.`,
3368
+ code: "FIELD_VALIDATION_FAILED",
3369
+ detalle: validation.error,
3370
+ path: validation.path,
3371
+ recibido: validation.received,
3372
+ ejemplo_correcto: PLAN_FIELD_EXAMPLES[field],
3373
+ hint: "Manda el valor como objeto/array nativo, NO como string JSON. Usa el ejemplo arriba como referencia exacta del shape esperado."
3374
+ };
3375
+ }
3376
+ const updatePayload = {
3377
+ id: brandId,
3378
+ brandId,
3379
+ tenantId,
3380
+ schemaVersion: 1,
3381
+ updatedAt: import_firebase_admin3.firestore.FieldValue.serverTimestamp()
3382
+ };
3383
+ if (field === "blogStrategy") {
3384
+ updatePayload.blogStrategy = validation.parsed;
3385
+ } else {
3386
+ const currentPlan = brandData.plan ?? {};
3387
+ updatePayload.plan = {
3388
+ ...currentPlan,
3389
+ [field]: validation.parsed,
3390
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3391
+ };
3392
+ }
3393
+ await brandRef.set(updatePayload, { merge: true });
3394
+ return {
3395
+ ok: true,
3396
+ mensaje: `Campo "${field}" actualizado en el plan de "${brandId}"`
3397
+ };
3398
+ }
3399
+ var planWriter = {
3400
+ save,
3401
+ updateField
3402
+ };
3403
+ var ParamsSchema2 = import_zod30.z.object({
3404
+ tenantId: import_zod30.z.string().min(1).describe("Tenant identifier (the business account)."),
3405
+ brandId: import_zod30.z.string().min(1).describe("Brand identifier within the tenant."),
3406
+ plan: import_zod30.z.record(import_zod30.z.string(), import_zod30.z.unknown()).describe(
3407
+ "Full marketing plan object generated by the LLM. May include a blogStrategy field \u2014 if present, it is extracted and stored at brand level (not nested inside plan)."
3408
+ )
3409
+ });
3410
+ var OutputSchema2 = import_zod30.z.discriminatedUnion("ok", [
3411
+ import_zod30.z.object({
3412
+ ok: import_zod30.z.literal(true),
3413
+ mensaje: import_zod30.z.string()
3414
+ }),
3415
+ import_zod30.z.object({
3416
+ ok: import_zod30.z.literal(false),
3417
+ error: import_zod30.z.string(),
3418
+ code: import_zod30.z.enum(["BRAND_NOT_FOUND", "FIELD_VALIDATION_FAILED"]).optional(),
3419
+ detalle: import_zod30.z.unknown().optional(),
3420
+ path: import_zod30.z.string().optional(),
3421
+ recibido: import_zod30.z.unknown().optional(),
3422
+ ejemplo_correcto: import_zod30.z.unknown().optional(),
3423
+ hint: import_zod30.z.string().optional()
3424
+ })
3425
+ ]);
3426
+ var rawContract2 = {
3427
+ name: "save_marketing_plan",
3428
+ description: "Save a marketing plan for a brand. If the plan object includes a blogStrategy field, it is extracted and stored at brand level (not nested inside plan).",
3429
+ paramsSchema: ParamsSchema2,
3430
+ outputSchema: OutputSchema2,
3431
+ // No es destructivo (sobrescribe plan completo, pero plan se regenera fácil),
3432
+ // no afecta publicación, no llama externo.
3433
+ requiresConfirmation: false,
3434
+ destructive: false,
3435
+ affectsPublication: false,
3436
+ affectsExternal: false,
3437
+ martinSummaryTemplate: (input, output, locale) => {
3438
+ if (!output.ok) {
3439
+ if (output.code) {
3440
+ return getMessage(`marketing.errors.${output.code}`, locale);
3441
+ }
3442
+ return getMessage("marketing.safeError.generic", locale);
3443
+ }
3444
+ if (locale === "en") return `I saved the marketing plan for ${input.brandId}.`;
3445
+ return `Guard\xE9 el plan de marketing de ${input.brandId}.`;
3446
+ },
3447
+ // Auditoría — OBLIGATORIO porque writes_firestore + updates_brand_config.
3448
+ auditAction: "marketing.plan.guardar",
3449
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_config/${input.brandId}`,
3450
+ extractChanges: (input, output) => ({
3451
+ before: null,
3452
+ // El helper no lee el plan previo en save (solo merge).
3453
+ after: output.ok ? { plan: input.plan } : null
3454
+ }),
3455
+ quotasConsumed: [],
3456
+ permissionScope: "module",
3457
+ permissionKey: "marketing",
3458
+ permissionAction: "editar",
3459
+ sideEffects: ["writes_firestore", "updates_brand_config"]
3460
+ };
3461
+ var planWriterSaveContract = MartinContractSchema.parse(
3462
+ rawContract2
3463
+ );
3464
+ var UpdateFieldParamsSchema = import_zod30.z.object({
3465
+ tenantId: import_zod30.z.string().min(1).describe("Tenant identifier (the business account)."),
3466
+ brandId: import_zod30.z.string().min(1).describe("Brand identifier within the tenant."),
3467
+ field: import_zod30.z.string().min(1).describe(
3468
+ "Plan field name to update (e.g. 'colecciones', 'temporadas', 'quickWins', 'blogStrategy'). blogStrategy is stored at brand level, NOT inside plan."
3469
+ ),
3470
+ value: import_zod30.z.unknown().describe(
3471
+ "New value for the field. Send objects/arrays as native types (not JSON strings). If a JSON-shaped string arrives, the helper auto-parses it as fallback."
3472
+ )
3473
+ });
3474
+ var rawUpdateFieldContract = {
3475
+ name: "update_marketing_plan_field",
3476
+ description: "Update ONE field of an existing marketing plan via partial merge \u2014 does NOT replace the whole plan. Auto-parses JSON-shaped strings into objects/arrays. Validates via validatePlanField against the canonical plan schema; on FIELD_VALIDATION_FAILED returns detalle/path/recibido/ejemplo_correcto/hint for the LLM to self-correct on retry. Note: blogStrategy is stored at the brand level, not inside plan.",
3477
+ paramsSchema: UpdateFieldParamsSchema,
3478
+ outputSchema: OutputSchema2,
3479
+ // Merge parcial sobre el plan existente — reversible (regenerar/reescribir
3480
+ // un campo es trivial). No publica nada externo.
3481
+ requiresConfirmation: false,
3482
+ destructive: false,
3483
+ affectsPublication: false,
3484
+ affectsExternal: false,
3485
+ martinSummaryTemplate: (input, output, locale) => {
3486
+ if (!output.ok) {
3487
+ if (output.code) {
3488
+ return getMessage(`marketing.errors.${output.code}`, locale);
3489
+ }
3490
+ return getMessage("marketing.safeError.generic", locale);
3491
+ }
3492
+ if (locale === "en") {
3493
+ return `I updated the "${input.field}" field in the plan for "${input.brandId}".`;
3494
+ }
3495
+ return `Actualic\xE9 el campo "${input.field}" en el plan de "${input.brandId}".`;
3496
+ },
3497
+ auditAction: "marketing.plan.actualizar_campo",
3498
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_config/${input.brandId}`,
3499
+ extractChanges: (input, output) => ({
3500
+ before: null,
3501
+ // El helper no captura el valor previo del campo.
3502
+ after: output.ok ? { field: input.field, value: input.value } : null
3503
+ }),
3504
+ quotasConsumed: [],
3505
+ permissionScope: "module",
3506
+ permissionKey: "marketing",
3507
+ permissionAction: "editar",
3508
+ sideEffects: ["writes_firestore", "updates_brand_config"]
3509
+ };
3510
+ var planWriterUpdateFieldContract = MartinContractSchema.parse(
3511
+ rawUpdateFieldContract
3512
+ );
3513
+ async function collectionSuggestionsWriter(input) {
3514
+ const { db, tenantId, brandId, suggestions } = input;
3515
+ const validation = validateCollectionSuggestions(suggestions);
3516
+ if (!validation.ok) {
3517
+ return {
3518
+ ok: false,
3519
+ error: "Collection suggestions no cumplen el schema esperado.",
3520
+ code: "COLLECTION_SUGGESTIONS_VALIDATION_FAILED",
3521
+ detalle: validation.error,
3522
+ path: validation.path,
3523
+ recibido: validation.received,
3524
+ ejemplo_correcto: validation.example,
3525
+ hint: "Revisa max chars: metaTitle <=60, metaDescription <=158, imageAlt <=125. Corrige el campo en path."
3526
+ };
3527
+ }
3528
+ const validSuggestions = validation.parsed;
3529
+ const brandRef = db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId);
3530
+ const brandSnap = await brandRef.get();
3531
+ if (!brandSnap.exists) {
3532
+ return { ok: false, error: `Brand ${brandId} no encontrada`, code: "BRAND_NOT_FOUND" };
3533
+ }
3534
+ const brandData = brandSnap.data();
3535
+ const existing = brandData.collectionSuggestions ?? {};
3536
+ const updated = { ...existing };
3537
+ for (const s of validSuggestions) {
3538
+ const id = String(s.collectionId);
3539
+ updated[id] = {
3540
+ suggestedTitle: s.suggestedTitle ?? null,
3541
+ suggestedDescription: s.suggestedDescription ?? null,
3542
+ suggestedMetaTitle: s.suggestedMetaTitle ?? null,
3543
+ suggestedMetaDescription: s.suggestedMetaDescription ?? null,
3544
+ suggestedHandle: s.suggestedHandle ?? null,
3545
+ suggestedImageAlt: s.suggestedImageAlt ?? null,
3546
+ keyword: s.keyword ?? null,
3547
+ notas: s.notas ?? null,
3548
+ estado: "pendiente",
3549
+ generadoAt: import_firebase_admin4.firestore.FieldValue.serverTimestamp(),
3550
+ generadoPor: "mcp-cowork"
3551
+ };
3552
+ }
3553
+ await brandRef.set({
3554
+ collectionSuggestions: updated,
3555
+ id: brandId,
3556
+ brandId,
3557
+ tenantId,
3558
+ schemaVersion: 1,
3559
+ updatedAt: import_firebase_admin4.firestore.FieldValue.serverTimestamp()
3560
+ }, { merge: true });
3561
+ return {
3562
+ ok: true,
3563
+ saved: validSuggestions.length,
3564
+ message: `${validSuggestions.length} sugerencias guardadas. El tenant las ver\xE1 en Marketing > Mi Plan > Shopify.`
3565
+ };
3566
+ }
3567
+ var ParamsSchema3 = import_zod31.z.object({
3568
+ tenantId: import_zod31.z.string().min(1).describe("Tenant identifier (the business account)."),
3569
+ brandId: import_zod31.z.string().min(1).describe("Brand identifier within the tenant."),
3570
+ suggestions: import_zod31.z.array(import_zod31.z.record(import_zod31.z.string(), import_zod31.z.unknown())).describe(
3571
+ "Array of SEO suggestions per collection. Each item must include collectionId and may include suggestedTitle, suggestedDescription, suggestedMetaTitle, suggestedMetaDescription, suggestedHandle, suggestedImageAlt, keyword, notas. Char limits: metaTitle <=60, metaDescription <=158, imageAlt <=125."
3572
+ )
3573
+ });
3574
+ var OutputSchema3 = import_zod31.z.discriminatedUnion("ok", [
3575
+ import_zod31.z.object({
3576
+ ok: import_zod31.z.literal(true),
3577
+ saved: import_zod31.z.number().int().nonnegative(),
3578
+ message: import_zod31.z.string()
3579
+ }),
3580
+ import_zod31.z.object({
3581
+ ok: import_zod31.z.literal(false),
3582
+ error: import_zod31.z.string(),
3583
+ code: import_zod31.z.enum(["COLLECTION_SUGGESTIONS_VALIDATION_FAILED", "BRAND_NOT_FOUND"]).optional(),
3584
+ detalle: import_zod31.z.unknown().optional(),
3585
+ path: import_zod31.z.string().optional(),
3586
+ recibido: import_zod31.z.unknown().optional(),
3587
+ ejemplo_correcto: import_zod31.z.unknown().optional(),
3588
+ hint: import_zod31.z.string().optional()
3589
+ })
3590
+ ]);
3591
+ var rawContract3 = {
3592
+ name: "save_collection_suggestions",
3593
+ description: "Save SEO suggestions for Shopify collections. Merges per-collection (preserves prior suggestions for other collectionIds). Validates max chars (metaTitle 60, metaDescription 158, imageAlt 125); on COLLECTION_SUGGESTIONS_VALIDATION_FAILED returns detalle/path/recibido/ejemplo_correcto/hint so the LLM can self-correct on retry. Tenant approves them in Marketing > My Plan > Shopify.",
3594
+ paramsSchema: ParamsSchema3,
3595
+ outputSchema: OutputSchema3,
3596
+ // Merge parcial sobre sugerencias previas — reversible (regenerar sugerencia
3597
+ // es trivial). El tenant las aprueba antes de aplicarlas a Shopify, así
3598
+ // que esta operación no publica ni afecta nada externo.
3599
+ requiresConfirmation: false,
3600
+ destructive: false,
3601
+ affectsPublication: false,
3602
+ affectsExternal: false,
3603
+ martinSummaryTemplate: (_input, output, locale) => {
3604
+ if (!output.ok) {
3605
+ if (output.code) {
3606
+ return getMessage(`marketing.errors.${output.code}`, locale);
3607
+ }
3608
+ return getMessage("marketing.safeError.generic", locale);
3609
+ }
3610
+ if (locale === "en") {
3611
+ return `I saved ${output.saved} collection suggestion${output.saved === 1 ? "" : "s"}. The tenant can review them in Marketing > My Plan > Shopify.`;
3612
+ }
3613
+ return `Guard\xE9 ${output.saved} sugerencia${output.saved === 1 ? "" : "s"} de colecci\xF3n. El tenant las puede revisar en Marketing > Mi Plan > Shopify.`;
3614
+ },
3615
+ auditAction: "marketing.collection_suggestions.guardar",
3616
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_config/${input.brandId}`,
3617
+ extractChanges: (input, output) => ({
3618
+ before: null,
3619
+ // Helper hace merge sin leer estado previo.
3620
+ after: output.ok ? {
3621
+ saved: output.saved,
3622
+ collectionIds: input.suggestions.map((s) => s.collectionId).filter((id) => id !== void 0)
3623
+ } : null
3624
+ }),
3625
+ quotasConsumed: [],
3626
+ permissionScope: "module",
3627
+ permissionKey: "marketing",
3628
+ permissionAction: "editar",
3629
+ sideEffects: ["writes_firestore", "updates_brand_config"]
3630
+ };
3631
+ var collectionSuggestionsWriterContract = MartinContractSchema.parse(
3632
+ rawContract3
3633
+ );
3634
+ var CAMPOS_PERMITIDOS = {
3635
+ gbp: {
3636
+ required: ["summary", "topicType"],
3637
+ optional: ["callToAction", "perfilId"]
3638
+ },
3639
+ shopify_blog: {
3640
+ // Sesion 188B: sincronizado con DatosBlogSchema v2 (27 campos)
3641
+ // body es optional aqui para permitir save en 2 pasos:
3642
+ // 1. save_generated_content con metadata (19 campos sin body)
3643
+ // 2. update_generated_content con { body: "HTML completo..." }
3644
+ // La CF publishToShopifyBlog valida que body exista antes de publicar.
3645
+ required: [
3646
+ "title",
3647
+ "handle",
3648
+ "metaTitle",
3649
+ "metaDescription",
3650
+ "summary",
3651
+ "imageAltText",
3652
+ "tags",
3653
+ "quickAnswerBlock",
3654
+ "faqItems",
3655
+ "faqTitle",
3656
+ "blogId",
3657
+ "blogHandle",
3658
+ "publishDate",
3659
+ "languageCode",
3660
+ "plataformaDestino"
3661
+ ],
3662
+ optional: [
3663
+ "body",
3664
+ "authorUserId",
3665
+ "authorName",
3666
+ "templateSuffix",
3667
+ "isPublished",
3668
+ "structuredData",
3387
3669
  "articuloNumero",
3388
3670
  "keywordsSecundarios",
3389
3671
  "productosLinkeados",
@@ -3452,11 +3734,15 @@ async function contenidoUpdater(input) {
3452
3734
  const docRef = db.doc(`tenants/${tenantId}/marketing_contenido/${contenidoId}`);
3453
3735
  const snap = await docRef.get();
3454
3736
  if (!snap.exists) {
3455
- return { ok: false, error: `Contenido ${contenidoId} no existe` };
3737
+ return { ok: false, error: `Contenido ${contenidoId} no existe`, code: "CONTENT_NOT_FOUND" };
3456
3738
  }
3457
3739
  const existing = snap.data();
3458
3740
  if (existing.tenantId !== tenantId) {
3459
- return { ok: false, error: "Contenido no pertenece a este tenant" };
3741
+ return {
3742
+ ok: false,
3743
+ error: "Contenido no pertenece a este tenant",
3744
+ code: "CONTENT_TENANT_MISMATCH"
3745
+ };
3460
3746
  }
3461
3747
  const plataforma = existing.plataforma;
3462
3748
  if (newDatos && Object.keys(newDatos).length > 0) {
@@ -3466,7 +3752,7 @@ async function contenidoUpdater(input) {
3466
3752
  };
3467
3753
  const schemaError = validateDatosSchema(plataforma, mergedDatos);
3468
3754
  if (schemaError) {
3469
- return { ok: false, error: schemaError };
3755
+ return { ok: false, error: schemaError, code: "CONTENT_DATA_VALIDATION_FAILED" };
3470
3756
  }
3471
3757
  const zodSchemas = {
3472
3758
  shopify_blog: DatosBlogSchema,
@@ -3508,11 +3794,98 @@ async function contenidoUpdater(input) {
3508
3794
  camposActualizados: Object.keys(update).filter((k) => k !== "updatedAt")
3509
3795
  };
3510
3796
  }
3797
+ var ParamsSchema4 = import_zod32.z.object({
3798
+ tenantId: import_zod32.z.string().min(1).describe("Tenant identifier (the business account)."),
3799
+ contenidoId: import_zod32.z.string().min(1).describe("Document ID in marketing_contenido to update."),
3800
+ datos: import_zod32.z.record(import_zod32.z.string(), import_zod32.z.unknown()).optional().describe(
3801
+ "Partial datos object to merge with existing datos via dot notation. Validated against the platform schema (Blog/GBP/IG/Review). OMIT if not updating datos."
3802
+ ),
3803
+ fotoId: import_zod32.z.string().nullable().optional().describe(
3804
+ "Linked photo ID. Pass null to clear the photo, OMIT to leave unchanged."
3805
+ ),
3806
+ keyword: import_zod32.z.string().nullable().optional().describe(
3807
+ "Primary keyword. Pass null to clear, OMIT to leave unchanged."
3808
+ ),
3809
+ languageCode: import_zod32.z.string().optional().describe(
3810
+ "Content language code (e.g. 'es', 'en'). OMIT to leave unchanged."
3811
+ ),
3812
+ estado: import_zod32.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe(
3813
+ "New content state. OMIT to leave unchanged. Use approve_content or reject_content tools for state transitions that require validation."
3814
+ ),
3815
+ calendarioItemRef: import_zod32.z.string().nullable().optional().describe(
3816
+ 'Link content to a calendar slot (format "semana:N:slot:M"). Pass null to unlink, OMIT to leave unchanged.'
3817
+ )
3818
+ });
3819
+ var OutputSchema4 = import_zod32.z.discriminatedUnion("ok", [
3820
+ import_zod32.z.object({
3821
+ ok: import_zod32.z.literal(true),
3822
+ contenidoId: import_zod32.z.string(),
3823
+ camposActualizados: import_zod32.z.array(import_zod32.z.string())
3824
+ }),
3825
+ import_zod32.z.object({
3826
+ ok: import_zod32.z.literal(false),
3827
+ error: import_zod32.z.string(),
3828
+ code: import_zod32.z.enum(["CONTENT_NOT_FOUND", "CONTENT_TENANT_MISMATCH", "CONTENT_DATA_VALIDATION_FAILED"]).optional()
3829
+ })
3830
+ ]);
3831
+ var rawContract4 = {
3832
+ name: "update_generated_content",
3833
+ description: "Update fields of an existing generated content (marketing_contenido) via partial merge. Cannot change tenantId/brandId/id (immutable). If `datos` is passed, it merges with the existing datos (does not replace whole object). Validates `datos` against the platform's schema (Blog/GBP/IG/Review) \u2014 returns CONTENT_DATA_VALIDATION_FAILED with details on mismatch.",
3834
+ paramsSchema: ParamsSchema4,
3835
+ outputSchema: OutputSchema4,
3836
+ // Editar borrador es reversible (volver a llamar con valores previos).
3837
+ // No publica nada externo — el contenido sigue siendo borrador hasta que
3838
+ // el tenant lo apruebe y la CF de publish lo empuje.
3839
+ requiresConfirmation: false,
3840
+ destructive: false,
3841
+ affectsPublication: false,
3842
+ affectsExternal: false,
3843
+ martinSummaryTemplate: (input, output, locale) => {
3844
+ if (!output.ok) {
3845
+ if (output.code) {
3846
+ return getMessage(`marketing.errors.${output.code}`, locale);
3847
+ }
3848
+ return getMessage("marketing.safeError.generic", locale);
3849
+ }
3850
+ const count = output.camposActualizados.length;
3851
+ if (locale === "en") {
3852
+ return `I updated the content (${count} field${count === 1 ? "" : "s"} changed).`;
3853
+ }
3854
+ return `Actualic\xE9 el contenido (${count} campo${count === 1 ? "" : "s"} modificado${count === 1 ? "" : "s"}).`;
3855
+ },
3856
+ auditAction: "marketing.contenido.actualizar",
3857
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_contenido/${input.contenidoId}`,
3858
+ extractChanges: (input, output) => ({
3859
+ before: null,
3860
+ // Helper no captura snapshot previo (merge parcial).
3861
+ after: output.ok ? {
3862
+ camposActualizados: output.camposActualizados,
3863
+ datosKeys: input.datos ? Object.keys(input.datos) : [],
3864
+ fotoId: input.fotoId ?? void 0,
3865
+ keyword: input.keyword ?? void 0,
3866
+ languageCode: input.languageCode,
3867
+ estado: input.estado,
3868
+ calendarioItemRef: input.calendarioItemRef ?? void 0
3869
+ } : null
3870
+ }),
3871
+ quotasConsumed: [],
3872
+ permissionScope: "module",
3873
+ permissionKey: "marketing",
3874
+ permissionAction: "editar",
3875
+ sideEffects: ["writes_firestore"]
3876
+ };
3877
+ var contenidoUpdaterContract = MartinContractSchema.parse(
3878
+ rawContract4
3879
+ );
3511
3880
  async function addCalendarSlot(input) {
3512
3881
  const { db, tenantId, brandId, mes, semana, slot } = input;
3513
3882
  const calQuery = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).where("mes", "==", mes).limit(1).get();
3514
3883
  if (calQuery.empty) {
3515
- return { ok: false, error: `Calendario ${mes} no encontrado para brand ${brandId}` };
3884
+ return {
3885
+ ok: false,
3886
+ error: `Calendario ${mes} no encontrado para brand ${brandId}`,
3887
+ code: "CALENDAR_NOT_FOUND"
3888
+ };
3516
3889
  }
3517
3890
  const calDocRef = calQuery.docs[0].ref;
3518
3891
  let resultSlotIndex = -1;
@@ -3542,40 +3915,47 @@ async function addCalendarSlot(input) {
3542
3915
  });
3543
3916
  return { ok: true, slotIndex: resultSlotIndex };
3544
3917
  }
3545
- var SlotSchema = import_zod30.z.object({
3546
- dia: import_zod30.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Formato YYYY-MM-DD"),
3547
- plataforma: import_zod30.z.enum(["gbp", "shopify_blog", "instagram", "review"]),
3548
- tipo: import_zod30.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]),
3549
- keyword: import_zod30.z.string().min(1),
3550
- tema: import_zod30.z.string().optional(),
3551
- productoId: import_zod30.z.string().optional(),
3552
- estado: import_zod30.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional(),
3553
- locationId: import_zod30.z.string().optional(),
3554
- locationNombre: import_zod30.z.string().optional()
3918
+ var SlotSchema = import_zod33.z.object({
3919
+ dia: import_zod33.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Format YYYY-MM-DD").describe("Slot date in YYYY-MM-DD format. Must fall within the week range."),
3920
+ plataforma: import_zod33.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Target platform for the content."),
3921
+ tipo: import_zod33.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).describe("Content type. Match it to the platform (e.g. blog \u2192 shopify_blog)."),
3922
+ keyword: import_zod33.z.string().min(1).describe("Primary keyword for the content."),
3923
+ tema: import_zod33.z.string().optional().describe("Content topic. OMIT the field if not applicable; do NOT send empty string or null."),
3924
+ productoId: import_zod33.z.string().optional().describe("Linked product ID. OMIT the field if not applicable."),
3925
+ estado: import_zod33.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe("Initial state. OMIT to default to 'planificado'."),
3926
+ locationId: import_zod33.z.string().optional().describe("GBP location ID \u2014 only for plataforma=gbp with multi-location. OMIT otherwise."),
3927
+ locationNombre: import_zod33.z.string().optional().describe("GBP location display name. OMIT if not applicable.")
3555
3928
  });
3556
- var ParamsSchema2 = import_zod30.z.object({
3557
- tenantId: import_zod30.z.string().min(1),
3558
- brandId: import_zod30.z.string().min(1),
3559
- mes: import_zod30.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM"),
3560
- semana: import_zod30.z.number().int().min(1).max(5),
3561
- slot: SlotSchema
3929
+ var ParamsSchema5 = import_zod33.z.object({
3930
+ tenantId: import_zod33.z.string().min(1).describe("Tenant identifier (the business account)."),
3931
+ brandId: import_zod33.z.string().min(1).describe("Brand identifier within the tenant."),
3932
+ mes: import_zod33.z.string().regex(/^\d{4}-\d{2}$/, "Format YYYY-MM").describe("Calendar month in YYYY-MM format."),
3933
+ semana: import_zod33.z.number().int().min(1).max(5).describe("Week number within the month (1-5)."),
3934
+ slot: SlotSchema.describe("New slot data to add to the calendar.")
3562
3935
  });
3563
- var OutputSchema2 = import_zod30.z.discriminatedUnion("ok", [
3564
- import_zod30.z.object({ ok: import_zod30.z.literal(true), slotIndex: import_zod30.z.number().int() }),
3565
- import_zod30.z.object({ ok: import_zod30.z.literal(false), error: import_zod30.z.string() })
3936
+ var OutputSchema5 = import_zod33.z.discriminatedUnion("ok", [
3937
+ import_zod33.z.object({ ok: import_zod33.z.literal(true), slotIndex: import_zod33.z.number().int() }),
3938
+ import_zod33.z.object({
3939
+ ok: import_zod33.z.literal(false),
3940
+ error: import_zod33.z.string(),
3941
+ code: import_zod33.z.enum(["CALENDAR_NOT_FOUND"]).optional()
3942
+ })
3566
3943
  ]);
3567
- var rawContract2 = {
3944
+ var rawContract5 = {
3568
3945
  name: "add_calendar_slot",
3569
- description: "Agrega un slot nuevo al calendario editorial. NO modifica slots existentes \u2014 para eso usa update_calendar_slot.",
3570
- paramsSchema: ParamsSchema2,
3571
- outputSchema: OutputSchema2,
3946
+ description: "Add a new slot to the editorial calendar. Does NOT modify existing slots \u2014 use update_calendar_slot for that.",
3947
+ paramsSchema: ParamsSchema5,
3948
+ outputSchema: OutputSchema5,
3572
3949
  requiresConfirmation: false,
3573
3950
  destructive: false,
3574
3951
  affectsPublication: false,
3575
3952
  affectsExternal: false,
3576
3953
  martinSummaryTemplate: (input, output, locale) => {
3577
3954
  if (!output.ok) {
3578
- return locale === "en" ? `I couldn't add the slot: ${output.error}` : `No pude agregar el slot: ${output.error}`;
3955
+ if (output.code) {
3956
+ return getMessage(`marketing.errors.${output.code}`, locale);
3957
+ }
3958
+ return getMessage("marketing.safeError.generic", locale);
3579
3959
  }
3580
3960
  return locale === "en" ? `Added a slot in week ${input.semana} of ${input.mes}.` : `Agregu\xE9 un slot en la semana ${input.semana} de ${input.mes}.`;
3581
3961
  },
@@ -3595,7 +3975,7 @@ var rawContract2 = {
3595
3975
  sideEffects: ["writes_firestore", "updates_calendar_slot"]
3596
3976
  };
3597
3977
  var addCalendarSlotContract = MartinContractSchema.parse(
3598
- rawContract2
3978
+ rawContract5
3599
3979
  );
3600
3980
  var CAMPOS_SEMANTICOS = ["keyword", "tema", "plataforma", "tipo"];
3601
3981
  function mapContenidoEstadoToSlotEstado(contenidoEstado) {
@@ -3636,7 +4016,7 @@ async function calendarSlotUpdater(input) {
3636
4016
  if (slotIndex >= items.length) {
3637
4017
  return {
3638
4018
  ok: false,
3639
- error: `slotIndex ${slotIndex} no existe (semana ${semana} tiene ${items.length} slots). Para agregar un slot nuevo usa add_calendar_slot.`,
4019
+ error: `slotIndex=${slotIndex} >= items.length=${items.length} for week=${semana}`,
3640
4020
  code: "SLOT_NOT_FOUND"
3641
4021
  };
3642
4022
  }
@@ -3648,14 +4028,9 @@ async function calendarSlotUpdater(input) {
3648
4028
  if (oldContenidoRef && tocaSemantica && !accionContenidoExistente) {
3649
4029
  return {
3650
4030
  ok: false,
3651
- error: `El slot tiene contenidoRef previo "${oldContenidoRef}" y los cambios tocan campos semanticos (${CAMPOS_SEMANTICOS.filter((f) => f in cambios).join(", ")}). El sistema NO decide automaticamente que hacer con el contenido viejo. Pregunta al tenant y vuelve a llamar con accionContenidoExistente en una de las 4 opciones.`,
4031
+ error: `slot has contenidoRef=${oldContenidoRef} and cambios touch semantic fields=[${CAMPOS_SEMANTICOS.filter((f) => f in cambios).join(",")}]; require accionContenidoExistente`,
3652
4032
  code: "ACCION_CONTENIDO_EXISTENTE_REQUIRED",
3653
- opciones: [
3654
- `'descartar' \u2014 marcar "${oldContenidoRef}" como descartado y aplicar cambios en este slot`,
3655
- `'mover:semana:N:slot:M' \u2014 mover "${oldContenidoRef}" al slot destino N/M (target debe estar vacio) y aplicar cambios aqui`,
3656
- `'nuevo_slot' \u2014 NO tocar este slot ni "${oldContenidoRef}". Crear un slot nuevo el mismo dia con los cambios (util para multiples publicaciones/dia \u2014 ej: almuerzo + cena GBP)`,
3657
- `'mantener' \u2014 mantener "${oldContenidoRef}" aqui y aplicar cambios (caso typo fix, el contenido viejo sigue valido para el nuevo keyword/tema)`
3658
- ]
4033
+ opciones: ["descartar", "mover", "nuevo_slot", "mantener"]
3659
4034
  };
3660
4035
  }
3661
4036
  let contenidosADescartar = [];
@@ -3675,7 +4050,7 @@ async function calendarSlotUpdater(input) {
3675
4050
  if (!m) {
3676
4051
  return {
3677
4052
  ok: false,
3678
- error: `accionContenidoExistente "${accionContenidoExistente}" invalido. Formato: mover:semana:N:slot:M`,
4053
+ error: `accionContenidoExistente="${accionContenidoExistente}" invalid format (expected: mover:semana:N:slot:M)`,
3679
4054
  code: "MOVE_TARGET_INVALID"
3680
4055
  };
3681
4056
  }
@@ -3684,7 +4059,7 @@ async function calendarSlotUpdater(input) {
3684
4059
  if (targetSemanaNum === semana && targetSlotIdx === slotIndex) {
3685
4060
  return {
3686
4061
  ok: false,
3687
- error: "mover al mismo slot origen no tiene sentido. Usa 'mantener' si quieres conservar el contenido aqui.",
4062
+ error: "move target same as origin (semantic noop)",
3688
4063
  code: "MOVE_TARGET_INVALID"
3689
4064
  };
3690
4065
  }
@@ -3692,7 +4067,7 @@ async function calendarSlotUpdater(input) {
3692
4067
  if (!targetSemana) {
3693
4068
  return {
3694
4069
  ok: false,
3695
- error: `Target semana ${targetSemanaNum} no existe en el calendario`,
4070
+ error: `target semana=${targetSemanaNum} does not exist`,
3696
4071
  code: "MOVE_TARGET_INVALID"
3697
4072
  };
3698
4073
  }
@@ -3700,14 +4075,14 @@ async function calendarSlotUpdater(input) {
3700
4075
  if (!targetItems[targetSlotIdx]) {
3701
4076
  return {
3702
4077
  ok: false,
3703
- error: `Target slot ${targetSlotIdx} no existe en semana ${targetSemanaNum}`,
4078
+ error: `target slot=${targetSlotIdx} does not exist in semana=${targetSemanaNum}`,
3704
4079
  code: "MOVE_TARGET_INVALID"
3705
4080
  };
3706
4081
  }
3707
4082
  if (targetItems[targetSlotIdx].contenidoRef) {
3708
4083
  return {
3709
4084
  ok: false,
3710
- error: `Target slot ya tiene contenidoRef "${targetItems[targetSlotIdx].contenidoRef}". No se sobrescribe para evitar perder contenido ajeno. Elige otro slot o usa 'descartar'.`,
4085
+ error: `target slot already has contenidoRef=${targetItems[targetSlotIdx].contenidoRef}`,
3711
4086
  code: "MOVE_TARGET_OCCUPIED"
3712
4087
  };
3713
4088
  }
@@ -3863,44 +4238,55 @@ async function handleNuevoSlot(args) {
3863
4238
  descartados: 0
3864
4239
  };
3865
4240
  }
3866
- var ParamsSchema3 = import_zod31.z.object({
3867
- tenantId: import_zod31.z.string().min(1),
3868
- brandId: import_zod31.z.string().min(1),
3869
- mes: import_zod31.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM"),
3870
- semana: import_zod31.z.number().int().min(1).max(5),
3871
- slotIndex: import_zod31.z.number().int().min(0),
3872
- cambios: import_zod31.z.record(import_zod31.z.string(), import_zod31.z.unknown()),
3873
- accionContenidoExistente: import_zod31.z.string().optional()
4241
+ var ParamsSchema6 = import_zod34.z.object({
4242
+ tenantId: import_zod34.z.string().min(1).describe("Tenant identifier (the business account)."),
4243
+ brandId: import_zod34.z.string().min(1).describe("Brand identifier within the tenant."),
4244
+ mes: import_zod34.z.string().regex(/^\d{4}-\d{2}$/, "Format YYYY-MM").describe("Calendar month in YYYY-MM format."),
4245
+ semana: import_zod34.z.number().int().min(1).max(5).describe("Week number within the month (1-5)."),
4246
+ slotIndex: import_zod34.z.number().int().min(0).describe("Zero-based index of the slot within the week.items[] array."),
4247
+ cambios: import_zod34.z.record(import_zod34.z.string(), import_zod34.z.unknown()).describe(
4248
+ "Fields to update on the slot. Accepts: dia, plataforma, tipo, keyword, tema, productoId, estado, locationId, locationNombre, contenidoRef, fotoIdAsignada, notas. Unknown fields are rejected by the helper."
4249
+ ),
4250
+ accionContenidoExistente: import_zod34.z.string().optional().describe(
4251
+ "Required when the slot already has a contenidoRef AND cambios touch semantic fields (keyword/tema/plataforma/tipo). One of: 'descartar' | 'mover:semana:N:slot:M' | 'nuevo_slot' | 'mantener'. OMIT if not applicable; do NOT send null."
4252
+ )
3874
4253
  });
3875
- var OutputSchema3 = import_zod31.z.discriminatedUnion("ok", [
3876
- import_zod31.z.object({
3877
- ok: import_zod31.z.literal(true),
3878
- action: import_zod31.z.enum(["updated", "nuevo_slot", "moved"]),
3879
- slotIndex: import_zod31.z.number().int(),
3880
- descartados: import_zod31.z.number().int(),
3881
- movedTo: import_zod31.z.string().optional()
4254
+ var OutputSchema6 = import_zod34.z.discriminatedUnion("ok", [
4255
+ import_zod34.z.object({
4256
+ ok: import_zod34.z.literal(true),
4257
+ action: import_zod34.z.enum(["updated", "nuevo_slot", "moved"]),
4258
+ slotIndex: import_zod34.z.number().int(),
4259
+ descartados: import_zod34.z.number().int(),
4260
+ movedTo: import_zod34.z.string().optional()
3882
4261
  }),
3883
- import_zod31.z.object({
3884
- ok: import_zod31.z.literal(false),
3885
- error: import_zod31.z.string(),
3886
- code: import_zod31.z.enum(["SLOT_NOT_FOUND", "ACCION_CONTENIDO_EXISTENTE_REQUIRED", "MOVE_TARGET_OCCUPIED", "MOVE_TARGET_INVALID"]).optional(),
3887
- opciones: import_zod31.z.array(import_zod31.z.string()).optional()
4262
+ import_zod34.z.object({
4263
+ ok: import_zod34.z.literal(false),
4264
+ error: import_zod34.z.string(),
4265
+ code: import_zod34.z.enum(["SLOT_NOT_FOUND", "ACCION_CONTENIDO_EXISTENTE_REQUIRED", "MOVE_TARGET_OCCUPIED", "MOVE_TARGET_INVALID"]).optional(),
4266
+ opciones: import_zod34.z.array(import_zod34.z.string()).optional()
3888
4267
  })
3889
4268
  ]);
3890
- var rawContract3 = {
4269
+ var rawContract6 = {
3891
4270
  name: "update_calendar_slot",
3892
- description: "MODIFICA un slot existente del calendario editorial. NO agrega slots nuevos \u2014 para eso usa add_calendar_slot. Si el slot ten\xEDa contenidoRef previo y los cambios tocan campos sem\xE1nticos (keyword/tema/plataforma/tipo), exige accionContenidoExistente con 4 opciones: descartar | mover:semana:N:slot:M | nuevo_slot | mantener.",
3893
- paramsSchema: ParamsSchema3,
3894
- outputSchema: OutputSchema3,
4271
+ description: "MODIFY an existing slot in the editorial calendar. Does NOT add new slots \u2014 use add_calendar_slot for that. If the slot already had a contenidoRef and the changes touch semantic fields (keyword/tema/plataforma/tipo), requires accionContenidoExistente with 4 options: descartar | mover:semana:N:slot:M | nuevo_slot | mantener.",
4272
+ paramsSchema: ParamsSchema6,
4273
+ outputSchema: OutputSchema6,
4274
+ // Por default, modificar un slot es reversible (cambias campos cosméticos).
4275
+ // PERO si accionContenidoExistente='descartar', el helper marca el
4276
+ // contenido vinculado como descartado — eso SÍ es destructivo. El predicado
4277
+ // runtime `isDestructiveForInput` lo detecta y obliga al wrapper a pedir
4278
+ // confirmación en ese caso específico.
3895
4279
  requiresConfirmation: false,
3896
4280
  destructive: false,
3897
- // Mutación, pero reversible (puede deshacerse cambiando el slot).
4281
+ isDestructiveForInput: (input) => input.accionContenidoExistente === "descartar",
3898
4282
  affectsPublication: false,
3899
4283
  affectsExternal: false,
3900
4284
  martinSummaryTemplate: (input, output, locale) => {
3901
4285
  if (!output.ok) {
3902
- if (locale === "en") return `I couldn't update the slot: ${output.error}`;
3903
- return `No pude actualizar el slot: ${output.error}`;
4286
+ if (output.code) {
4287
+ return getMessage(`marketing.errors.${output.code}`, locale);
4288
+ }
4289
+ return getMessage("marketing.safeError.generic", locale);
3904
4290
  }
3905
4291
  const verb = {
3906
4292
  updated: locale === "en" ? "updated" : "actualic\xE9",
@@ -3912,6 +4298,15 @@ var rawContract3 = {
3912
4298
  }
3913
4299
  return `${verb[0].toUpperCase() + verb.slice(1)} el slot en la semana ${input.semana} (${input.mes}).`;
3914
4300
  },
4301
+ // Confirmation message when isDestructiveForInput returns true
4302
+ // (accionContenidoExistente === 'descartar'). Only invoked if the wrapper
4303
+ // detects the destructive runtime case and asks the user for OK.
4304
+ martinConfirmationTemplate: (input, locale) => {
4305
+ if (locale === "en") {
4306
+ return `Are you sure? This will discard the existing content linked to slot ${input.slotIndex} in week ${input.semana} (${input.mes}). The content will be marked as discarded and cannot be easily recovered.`;
4307
+ }
4308
+ return `\xBFConfirmas? Esto descarta el contenido existente del slot ${input.slotIndex} en la semana ${input.semana} (${input.mes}). El contenido queda marcado como descartado y no se recupera f\xE1cilmente.`;
4309
+ },
3915
4310
  auditAction: "marketing.calendario.slot.actualizar",
3916
4311
  extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_calendario/(brand=${input.brandId},mes=${input.mes}).semana=${input.semana}.slot=${input.slotIndex}`,
3917
4312
  extractChanges: (input, output) => ({
@@ -3932,7 +4327,7 @@ var rawContract3 = {
3932
4327
  sideEffects: ["writes_firestore", "updates_calendar_slot"]
3933
4328
  };
3934
4329
  var calendarSlotUpdaterContract = MartinContractSchema.parse(
3935
- rawContract3
4330
+ rawContract6
3936
4331
  );
3937
4332
  async function getCalendar(input) {
3938
4333
  const { db, tenantId, brandId, mes } = input;
@@ -3954,23 +4349,23 @@ async function getCalendar(input) {
3954
4349
  calendario: { id: doc.id, ...doc.data() }
3955
4350
  };
3956
4351
  }
3957
- var ParamsSchema4 = import_zod32.z.object({
3958
- tenantId: import_zod32.z.string().min(1),
3959
- brandId: import_zod32.z.string().min(1),
3960
- mes: import_zod32.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM")
4352
+ var ParamsSchema7 = import_zod35.z.object({
4353
+ tenantId: import_zod35.z.string().min(1).describe("Tenant identifier (the business account)."),
4354
+ brandId: import_zod35.z.string().min(1).describe("Brand identifier within the tenant."),
4355
+ mes: import_zod35.z.string().regex(/^\d{4}-\d{2}$/, "Format YYYY-MM").describe("Calendar month to read in YYYY-MM format.")
3961
4356
  });
3962
- var OutputSchema4 = import_zod32.z.object({
3963
- ok: import_zod32.z.literal(true),
3964
- mes: import_zod32.z.string(),
3965
- brandId: import_zod32.z.string(),
3966
- calendario: import_zod32.z.record(import_zod32.z.string(), import_zod32.z.unknown()).nullable(),
3967
- mensaje: import_zod32.z.string().optional()
4357
+ var OutputSchema7 = import_zod35.z.object({
4358
+ ok: import_zod35.z.literal(true),
4359
+ mes: import_zod35.z.string(),
4360
+ brandId: import_zod35.z.string(),
4361
+ calendario: import_zod35.z.record(import_zod35.z.string(), import_zod35.z.unknown()).nullable(),
4362
+ mensaje: import_zod35.z.string().optional()
3968
4363
  });
3969
- var rawContract4 = {
4364
+ var rawContract7 = {
3970
4365
  name: "get_calendar",
3971
- description: "Lee el calendario editorial del mes para una brand. Retorna semanas con items planificados por plataforma.",
3972
- paramsSchema: ParamsSchema4,
3973
- outputSchema: OutputSchema4,
4366
+ description: "Read the editorial calendar for a given brand and month. Returns weeks with planned items per platform.",
4367
+ paramsSchema: ParamsSchema7,
4368
+ outputSchema: OutputSchema7,
3974
4369
  requiresConfirmation: false,
3975
4370
  destructive: false,
3976
4371
  affectsPublication: false,
@@ -3992,7 +4387,7 @@ var rawContract4 = {
3992
4387
  sideEffects: ["reads_firestore"]
3993
4388
  };
3994
4389
  var getCalendarContract = MartinContractSchema.parse(
3995
- rawContract4
4390
+ rawContract7
3996
4391
  );
3997
4392
  var PLATAFORMA_A_FORMATO = {
3998
4393
  gbp: "gbp_4_3",
@@ -4005,21 +4400,26 @@ async function photoAssigner(input) {
4005
4400
  const contenidoRefDoc = db.doc(`tenants/${tenantId}/marketing_contenido/${contenidoRef}`);
4006
4401
  const contenidoSnap = await contenidoRefDoc.get();
4007
4402
  if (!contenidoSnap.exists) {
4008
- return { ok: false, error: `Contenido ${contenidoRef} no encontrado` };
4403
+ return { ok: false, error: `Contenido ${contenidoRef} no encontrado`, code: "CONTENT_NOT_FOUND" };
4009
4404
  }
4010
4405
  const contenido = contenidoSnap.data();
4011
4406
  if (contenido.tenantId !== tenantId) {
4012
- return { ok: false, error: "Contenido no pertenece a este tenant" };
4407
+ return {
4408
+ ok: false,
4409
+ error: "Contenido no pertenece a este tenant",
4410
+ code: "CONTENT_TENANT_MISMATCH"
4411
+ };
4013
4412
  }
4014
4413
  const fotoQuery = await db.collection("tenants").doc(tenantId).collection("marketing_fotos").where("id", "==", fotoId).limit(1).get();
4015
4414
  if (fotoQuery.empty) {
4016
- return { ok: false, error: `Foto ${fotoId} no encontrada` };
4415
+ return { ok: false, error: `Foto ${fotoId} no encontrada`, code: "PHOTO_NOT_FOUND" };
4017
4416
  }
4018
4417
  const foto = fotoQuery.docs[0].data();
4019
4418
  if (foto.estado !== ESTADO_FOTO.EDITADA && foto.estado !== "usada") {
4020
4419
  return {
4021
4420
  ok: false,
4022
- error: `Foto en estado "${foto.estado}", debe ser "editada"`
4421
+ error: `Foto en estado "${foto.estado}", debe ser "editada"`,
4422
+ code: "PHOTO_NOT_READY"
4023
4423
  };
4024
4424
  }
4025
4425
  const formato = PLATAFORMA_A_FORMATO[contenido.plataforma] ?? "original";
@@ -4100,6 +4500,78 @@ async function photoAssigner(input) {
4100
4500
  formato
4101
4501
  };
4102
4502
  }
4503
+ var ParamsSchema8 = import_zod36.z.object({
4504
+ tenantId: import_zod36.z.string().min(1).describe("Tenant identifier (the business account)."),
4505
+ brandId: import_zod36.z.string().min(1).describe("Brand identifier within the tenant."),
4506
+ contenidoRef: import_zod36.z.string().min(1).describe("Document ID in marketing_contenido (the post receiving the photo)."),
4507
+ fotoId: import_zod36.z.string().min(1).describe("Photo ID in marketing_fotos. Photo must be in state='editada'."),
4508
+ calendarioItemRef: import_zod36.z.string().regex(/^semana:\d+:slot:\d+$/).optional().describe(
4509
+ 'Calendar slot reference in format "semana:N:slot:M". Optional \u2014 if provided, syncs the slot.fotoIdAsignada UI snapshot atomically with the content update. OMIT if there is no calendar slot to sync.'
4510
+ )
4511
+ });
4512
+ var OutputSchema8 = import_zod36.z.discriminatedUnion("ok", [
4513
+ import_zod36.z.object({
4514
+ ok: import_zod36.z.literal(true),
4515
+ fotoId: import_zod36.z.string(),
4516
+ contenidoRef: import_zod36.z.string(),
4517
+ plataforma: import_zod36.z.string(),
4518
+ varianteUrl: import_zod36.z.string().nullable(),
4519
+ formato: import_zod36.z.string()
4520
+ }),
4521
+ import_zod36.z.object({
4522
+ ok: import_zod36.z.literal(false),
4523
+ error: import_zod36.z.string(),
4524
+ code: import_zod36.z.enum(["CONTENT_NOT_FOUND", "CONTENT_TENANT_MISMATCH", "PHOTO_NOT_FOUND", "PHOTO_NOT_READY"]).optional()
4525
+ })
4526
+ ]);
4527
+ var rawContract8 = {
4528
+ name: "assign_photo_to_content",
4529
+ description: 'Assign an edited photo to an existing content. Writes atomically to BOTH marketing_contenido.fotoId+mediaVariante (canonical for publish) AND marketing_calendario slot.fotoIdAsignada (UI snapshot, only if calendarioItemRef provided). NEVER use update_calendar_slot for this \u2014 that one writes to the agenda, not the post. The photo must be in state="editada".',
4530
+ paramsSchema: ParamsSchema8,
4531
+ outputSchema: OutputSchema8,
4532
+ // Cambia la foto vinculada al contenido — reversible (volver a llamar con
4533
+ // otra fotoId), no publica nada externo. La foto editada queda intacta;
4534
+ // solo se actualiza la referencia.
4535
+ requiresConfirmation: false,
4536
+ destructive: false,
4537
+ affectsPublication: false,
4538
+ affectsExternal: false,
4539
+ martinSummaryTemplate: (input, output, locale) => {
4540
+ if (!output.ok) {
4541
+ if (output.code) {
4542
+ return getMessage(`marketing.errors.${output.code}`, locale);
4543
+ }
4544
+ return getMessage("marketing.safeError.generic", locale);
4545
+ }
4546
+ if (locale === "en") {
4547
+ return `I assigned the photo to the ${output.plataforma} content.`;
4548
+ }
4549
+ return `Asign\xE9 la foto al contenido de ${output.plataforma}.`;
4550
+ },
4551
+ auditAction: "marketing.contenido.foto_asignar",
4552
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_contenido/${input.contenidoRef}`,
4553
+ extractChanges: (input, output) => ({
4554
+ before: null,
4555
+ // El helper no captura fotoId previo del contenido.
4556
+ after: output.ok ? {
4557
+ fotoId: output.fotoId,
4558
+ plataforma: output.plataforma,
4559
+ formato: output.formato,
4560
+ varianteUrl: output.varianteUrl,
4561
+ slotSyncRef: input.calendarioItemRef ?? null
4562
+ } : null
4563
+ }),
4564
+ quotasConsumed: [],
4565
+ permissionScope: "module",
4566
+ permissionKey: "marketing",
4567
+ permissionAction: "editar",
4568
+ // updates_calendar_slot porque sincroniza el snapshot fotoIdAsignada
4569
+ // cuando viene calendarioItemRef.
4570
+ sideEffects: ["writes_firestore", "updates_calendar_slot"]
4571
+ };
4572
+ var photoAssignerContract = MartinContractSchema.parse(
4573
+ rawContract8
4574
+ );
4103
4575
  function findPageByHeuristic(pages, pattern) {
4104
4576
  return pages.find((p) => pattern.test(p.title || "")) || pages.find((p) => pattern.test(p.handle || "")) || null;
4105
4577
  }
@@ -4109,7 +4581,7 @@ async function brandBriefBuilder(input) {
4109
4581
  const { db, tenantId, brandId } = input;
4110
4582
  const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4111
4583
  if (!configSnap.exists) {
4112
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
4584
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
4113
4585
  }
4114
4586
  const brand = configSnap.data();
4115
4587
  const [snapshotMetaSnap, productsSnap, collectionsSnap, pagesSnap, siteMetaSnap, tenantSnap] = await Promise.all([
@@ -4287,26 +4759,27 @@ var BRAND_BRIEF_SCHEMA_HINT = {
4287
4759
  escenas: [{ id: string, nombre: string, promptHint: string }]
4288
4760
  }`
4289
4761
  };
4290
- var ParamsSchema5 = import_zod33.z.object({
4291
- tenantId: import_zod33.z.string().min(1),
4292
- brandId: import_zod33.z.string().min(1)
4762
+ var ParamsSchema9 = import_zod37.z.object({
4763
+ tenantId: import_zod37.z.string().min(1).describe("Tenant identifier (the business account)."),
4764
+ brandId: import_zod37.z.string().min(1).describe("Brand identifier within the tenant.")
4293
4765
  });
4294
- var OutputSchema5 = import_zod33.z.discriminatedUnion("ok", [
4295
- import_zod33.z.object({
4296
- ok: import_zod33.z.literal(true),
4766
+ var OutputSchema9 = import_zod37.z.discriminatedUnion("ok", [
4767
+ import_zod37.z.object({
4768
+ ok: import_zod37.z.literal(true),
4297
4769
  /** Payload con instrucción + datos del negocio para que Claude genere el brief. */
4298
- payload: import_zod33.z.record(import_zod33.z.string(), import_zod33.z.unknown())
4770
+ payload: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown())
4299
4771
  }),
4300
- import_zod33.z.object({
4301
- ok: import_zod33.z.literal(false),
4302
- error: import_zod33.z.string()
4772
+ import_zod37.z.object({
4773
+ ok: import_zod37.z.literal(false),
4774
+ error: import_zod37.z.string(),
4775
+ code: import_zod37.z.enum(["BRAND_NOT_FOUND"]).optional()
4303
4776
  })
4304
4777
  ]);
4305
- var rawContract5 = {
4778
+ var rawContract9 = {
4306
4779
  name: "generate_brand_brief",
4307
- description: "Prepara los datos del negocio (Shopify, SEO, GBP, site_content scrape, brand config, ubicaciones del tenant) para que el sistema genere un Brand Brief pre-llenado. NO escribe el brief \u2014 s\xF3lo arma el payload.",
4308
- paramsSchema: ParamsSchema5,
4309
- outputSchema: OutputSchema5,
4780
+ description: "Aggregate business data (Shopify, SEO, GBP, scraped site_content, brand config, tenant locations) into a payload to generate a pre-filled Brand Brief. Read-only \u2014 does NOT write the brief; the caller saves it via save_brand_brief.",
4781
+ paramsSchema: ParamsSchema9,
4782
+ outputSchema: OutputSchema9,
4310
4783
  // Lectura pura, sin side effects de escritura ni publicación.
4311
4784
  requiresConfirmation: false,
4312
4785
  destructive: false,
@@ -4314,8 +4787,10 @@ var rawContract5 = {
4314
4787
  affectsExternal: false,
4315
4788
  martinSummaryTemplate: (_input, output, locale) => {
4316
4789
  if (!output.ok) {
4317
- if (locale === "en") return `I couldn't gather the data: ${output.error}`;
4318
- return `No pude reunir los datos: ${output.error}`;
4790
+ if (output.code) {
4791
+ return getMessage(`marketing.errors.${output.code}`, locale);
4792
+ }
4793
+ return getMessage("marketing.safeError.generic", locale);
4319
4794
  }
4320
4795
  if (locale === "en") return `I gathered the data for the brief.`;
4321
4796
  return `Reun\xED los datos para el brief.`;
@@ -4329,7 +4804,7 @@ var rawContract5 = {
4329
4804
  sideEffects: ["reads_firestore"]
4330
4805
  };
4331
4806
  var brandBriefBuilderContract = MartinContractSchema.parse(
4332
- rawContract5
4807
+ rawContract9
4333
4808
  );
4334
4809
  async function resolveLastImportId(db, tenantId, brandId) {
4335
4810
  const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
@@ -4345,13 +4820,14 @@ async function marketingPlanBuilder(input) {
4345
4820
  const { db, tenantId, brandId } = input;
4346
4821
  const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4347
4822
  if (!configSnap.exists) {
4348
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
4823
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
4349
4824
  }
4350
4825
  const brand = configSnap.data();
4351
4826
  if (!brand.seoSnapshot) {
4352
4827
  return {
4353
4828
  ok: false,
4354
- error: "No hay snapshot SEO. Ejecuta marketingSeoSnapshot primero."
4829
+ error: "No hay snapshot SEO. Ejecuta marketingSeoSnapshot primero.",
4830
+ code: "SEO_SNAPSHOT_MISSING"
4355
4831
  };
4356
4832
  }
4357
4833
  const productosQ = await db.collection("productos").where("tenantId", "==", tenantId).limit(100).get();
@@ -4516,6 +4992,54 @@ var BLOG_STRATEGY_REGLAS = [
4516
4992
  "blogId, handle, title, ultimoPostFecha, ultimoPostKeyword, totalArticulos \u2014 copiar del shopifyBlogs (datos reales del import)",
4517
4993
  "defaultBlogId y defaultBlogHandle \u2014 usar el primer blog de la lista"
4518
4994
  ];
4995
+ var ParamsSchema10 = import_zod38.z.object({
4996
+ tenantId: import_zod38.z.string().min(1).describe("Tenant identifier (the business account)."),
4997
+ brandId: import_zod38.z.string().min(1).describe("Brand identifier within the tenant.")
4998
+ });
4999
+ var OutputSchema10 = import_zod38.z.discriminatedUnion("ok", [
5000
+ import_zod38.z.object({
5001
+ ok: import_zod38.z.literal(true),
5002
+ payload: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe(
5003
+ "Aggregated business data + instructions + schema hints for the LLM to generate a marketing plan. Includes seoSnapshot, productos, historialReciente, shopifyColecciones, shopifyBlogs, brandBrief, planActual, and schema/rules hints for coleccionesPriorizadas and blogStrategy."
5004
+ )
5005
+ }),
5006
+ import_zod38.z.object({
5007
+ ok: import_zod38.z.literal(false),
5008
+ error: import_zod38.z.string(),
5009
+ code: import_zod38.z.enum(["BRAND_NOT_FOUND", "SEO_SNAPSHOT_MISSING"]).optional()
5010
+ })
5011
+ ]);
5012
+ var rawContract10 = {
5013
+ name: "generate_marketing_plan",
5014
+ description: "Aggregate business data (seoSnapshot, productos, brand config, brandBrief, historial, Shopify collections + blogs) into a payload for the LLM to generate a marketing plan. Read-only \u2014 does NOT save the plan. The caller saves via save_marketing_plan or update_marketing_plan_field after generating. Requires an existing seoSnapshot on the brand (returns SEO_SNAPSHOT_MISSING otherwise).",
5015
+ paramsSchema: ParamsSchema10,
5016
+ outputSchema: OutputSchema10,
5017
+ // Lectura pura, no muta nada.
5018
+ requiresConfirmation: false,
5019
+ destructive: false,
5020
+ affectsPublication: false,
5021
+ affectsExternal: false,
5022
+ martinSummaryTemplate: (_input, output, locale) => {
5023
+ if (!output.ok) {
5024
+ if (output.code) {
5025
+ return getMessage(`marketing.errors.${output.code}`, locale);
5026
+ }
5027
+ return getMessage("marketing.safeError.generic", locale);
5028
+ }
5029
+ if (locale === "en") return `I gathered the data to generate the plan.`;
5030
+ return `Reun\xED los datos para generar el plan.`;
5031
+ },
5032
+ // AUDITA SIEMPRE — regla A5. Lectura no muta, changes son null/null.
5033
+ auditAction: "marketing.plan.preparar",
5034
+ quotasConsumed: [],
5035
+ permissionScope: "module",
5036
+ permissionKey: "marketing",
5037
+ permissionAction: "ver",
5038
+ sideEffects: ["reads_firestore"]
5039
+ };
5040
+ var marketingPlanBuilderContract = MartinContractSchema.parse(
5041
+ rawContract10
5042
+ );
4519
5043
  function buildGenId() {
4520
5044
  return `mkt_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
4521
5045
  }
@@ -4560,6 +5084,7 @@ async function contenidoWriter(input) {
4560
5084
  return {
4561
5085
  ok: false,
4562
5086
  error: `Limite de frecuencia alcanzado: ${activosEstaSemana.length}/${limite} ${plataforma} esta semana`,
5087
+ code: "CONTENT_FREQUENCY_LIMIT",
4563
5088
  activosEstaSemana: activosEstaSemana.length,
4564
5089
  limite
4565
5090
  };
@@ -4585,7 +5110,7 @@ async function contenidoWriter(input) {
4585
5110
  }
4586
5111
  const schemaError = validateDatosSchema(plataforma, datos);
4587
5112
  if (schemaError) {
4588
- return { ok: false, error: schemaError };
5113
+ return { ok: false, error: schemaError, code: "CONTENT_DATA_VALIDATION_FAILED" };
4589
5114
  }
4590
5115
  const zodSchemas = {
4591
5116
  shopify_blog: DatosBlogSchema,
@@ -4754,6 +5279,100 @@ async function contenidoWriter(input) {
4754
5279
  pipelineLinked
4755
5280
  };
4756
5281
  }
5282
+ var ParamsSchema11 = import_zod39.z.object({
5283
+ tenantId: import_zod39.z.string().min(1).describe("Tenant identifier (the business account)."),
5284
+ brandId: import_zod39.z.string().min(1).describe("Brand identifier within the tenant."),
5285
+ plataforma: import_zod39.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Target platform for the content."),
5286
+ tipo: import_zod39.z.string().optional().describe(
5287
+ "Content type ('post', 'blog', 'carousel', 'reel', 'story', 'review_response'). Match to the platform."
5288
+ ),
5289
+ keyword: import_zod39.z.string().optional().describe("Primary keyword for the content. OMIT if not applicable."),
5290
+ languageCode: import_zod39.z.string().optional().describe(
5291
+ "Content language code (e.g. 'es', 'en'). For shopify_blog auto-injects to datos.languageCode if not present."
5292
+ ),
5293
+ fotoId: import_zod39.z.string().optional().describe("Linked photo ID. OMIT if no photo associated yet (use assign_photo_to_content later)."),
5294
+ datos: import_zod39.z.record(import_zod39.z.string(), import_zod39.z.unknown()).describe(
5295
+ "Platform-specific content payload. Validated against Blog/GBP/IG/Review schemas. Use buildDatosBlog/GBP/IG/Review helpers to construct safely."
5296
+ ),
5297
+ calendarioItemRef: import_zod39.z.string().optional().describe(
5298
+ 'Calendar slot reference (format "semana:N:slot:M"). If provided, links content to that slot AND discards prior non-discarded content of the same slot.'
5299
+ )
5300
+ });
5301
+ var PipelineLinkResultSchema = import_zod39.z.object({
5302
+ linked: import_zod39.z.boolean(),
5303
+ paso: import_zod39.z.string().nullable(),
5304
+ motivo: import_zod39.z.string().optional(),
5305
+ code: import_zod39.z.string().optional(),
5306
+ _instrucciones: import_zod39.z.string().optional()
5307
+ });
5308
+ var OutputSchema11 = import_zod39.z.discriminatedUnion("ok", [
5309
+ import_zod39.z.object({
5310
+ ok: import_zod39.z.literal(true),
5311
+ contenidoId: import_zod39.z.string(),
5312
+ estado: import_zod39.z.string(),
5313
+ plataforma: import_zod39.z.enum(["gbp", "shopify_blog", "instagram", "review"]),
5314
+ descartados: import_zod39.z.number().int().nonnegative(),
5315
+ pipelineLinked: PipelineLinkResultSchema
5316
+ }),
5317
+ import_zod39.z.object({
5318
+ ok: import_zod39.z.literal(false),
5319
+ error: import_zod39.z.string(),
5320
+ code: import_zod39.z.enum(["CONTENT_FREQUENCY_LIMIT", "CONTENT_DATA_VALIDATION_FAILED"]).optional(),
5321
+ activosEstaSemana: import_zod39.z.number().int().optional(),
5322
+ limite: import_zod39.z.number().int().optional()
5323
+ })
5324
+ ]);
5325
+ var rawContract11 = {
5326
+ name: "save_generated_content",
5327
+ description: "Save newly generated marketing content to marketing_contenido. The content is saved as pendiente_aprobacion \u2014 it is NOT published until the tenant approves it. Enforces weekly frequency limits per platform. If calendarioItemRef is provided, links to the slot and discards any prior content of the same slot. Validates datos against the platform schema.",
5328
+ paramsSchema: ParamsSchema11,
5329
+ outputSchema: OutputSchema11,
5330
+ // Crea borrador + descarta borrador previo del mismo slot. Reversible
5331
+ // (volver a llamar con datos corregidos crea un nuevo borrador y descarta
5332
+ // el actual). NO publica nada externo — la CF de publish se encarga
5333
+ // cuando el tenant aprueba.
5334
+ requiresConfirmation: false,
5335
+ destructive: false,
5336
+ affectsPublication: false,
5337
+ affectsExternal: false,
5338
+ martinSummaryTemplate: (input, output, locale) => {
5339
+ if (!output.ok) {
5340
+ if (output.code) {
5341
+ return getMessage(`marketing.errors.${output.code}`, locale);
5342
+ }
5343
+ return getMessage("marketing.safeError.generic", locale);
5344
+ }
5345
+ if (locale === "en") {
5346
+ return `I saved a ${output.plataforma} draft. It needs your approval before publishing.`;
5347
+ }
5348
+ return `Guard\xE9 un borrador de ${output.plataforma}. Necesita tu aprobaci\xF3n antes de publicarse.`;
5349
+ },
5350
+ auditAction: "marketing.contenido.crear",
5351
+ extractTargetPath: (input, output) => output.ok ? `tenants/${input.tenantId}/marketing_contenido/${output.contenidoId}` : `tenants/${input.tenantId}/marketing_contenido/`,
5352
+ extractChanges: (input, output) => ({
5353
+ before: null,
5354
+ after: output.ok ? {
5355
+ contenidoId: output.contenidoId,
5356
+ plataforma: output.plataforma,
5357
+ tipo: input.tipo,
5358
+ keyword: input.keyword,
5359
+ fotoId: input.fotoId,
5360
+ calendarioItemRef: input.calendarioItemRef,
5361
+ descartados: output.descartados,
5362
+ pipelineLinked: output.pipelineLinked
5363
+ } : null
5364
+ }),
5365
+ quotasConsumed: [],
5366
+ permissionScope: "module",
5367
+ permissionKey: "marketing",
5368
+ permissionAction: "editar",
5369
+ // updates_calendar_slot porque sincroniza contenidoRef en el slot cuando
5370
+ // calendarioItemRef viene; sin él, solo writes_firestore.
5371
+ sideEffects: ["writes_firestore", "updates_calendar_slot"]
5372
+ };
5373
+ var contenidoWriterContract = MartinContractSchema.parse(
5374
+ rawContract11
5375
+ );
4757
5376
  function fmtDate(d) {
4758
5377
  const y = d.getFullYear();
4759
5378
  const m = String(d.getMonth() + 1).padStart(2, "0");
@@ -4794,7 +5413,7 @@ async function weeklyContentBuilder(input) {
4794
5413
  const targetSemana = semana ?? Math.ceil(now.getDate() / 7);
4795
5414
  const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
4796
5415
  if (!configSnap.exists) {
4797
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
5416
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
4798
5417
  }
4799
5418
  const brand = configSnap.data();
4800
5419
  const calQuery = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).where("mes", "==", targetMes).limit(1).get();
@@ -4858,7 +5477,8 @@ async function weeklyContentBuilder(input) {
4858
5477
  if (slotsExistentes.length === 0) {
4859
5478
  return {
4860
5479
  ok: false,
4861
- error: `La semana ${targetSemana} no tiene slots. Primero usa generate_weekly_content con modo='planificar' para proponer la distribuci\xF3n.`
5480
+ error: `La semana ${targetSemana} no tiene slots. Primero usa generate_weekly_content con modo='planificar' para proponer la distribuci\xF3n.`,
5481
+ code: "WEEK_HAS_NO_SLOTS"
4862
5482
  };
4863
5483
  }
4864
5484
  const slotsParaGenerar = slotsExistentes.filter(
@@ -5026,6 +5646,80 @@ OBLIGATORIO: calendarioItemRef con formato EXACTO "semana:N:slot:M" (ej: "semana
5026
5646
  OBLIGATORIO: fotoId \u2014 SIEMPRE pasa el ID de la foto que elegiste con get_photos_for_slot.
5027
5647
  Si el slot tiene notas[], LEERLAS y usarlas como contexto. Si estado es revisar, regenerar adaptando a las notas.
5028
5648
  BLOG SLOTS: Usa _blogJIT para el contexto de cada slot shopify_blog \u2014 blogTarget, tono, author, articulosExistentes para interlinking.`;
5649
+ var ParamsSchema12 = import_zod40.z.object({
5650
+ tenantId: import_zod40.z.string().min(1).describe("Tenant identifier (the business account)."),
5651
+ brandId: import_zod40.z.string().min(1).describe("Brand identifier within the tenant."),
5652
+ semana: import_zod40.z.number().int().min(1).max(5).optional().describe(
5653
+ "Week number within the current month (1-5). OMIT to default to the current week inferred from today."
5654
+ ),
5655
+ modo: import_zod40.z.enum(["planificar", "generar"]).optional().describe(
5656
+ "Operation mode. 'planificar' (default): propose distribution platform+keyword+tipo per day. 'generar': requires slots in pre_aprobado/revisar \u2014 generate actual content. OMIT to default to 'planificar'."
5657
+ )
5658
+ });
5659
+ var OutputSchema12 = import_zod40.z.discriminatedUnion("ok", [
5660
+ import_zod40.z.object({
5661
+ ok: import_zod40.z.literal(true),
5662
+ payload: import_zod40.z.record(import_zod40.z.string(), import_zod40.z.unknown()).describe(
5663
+ "Aggregated context for the LLM. Shape varies by modo: 'planificar' returns brand skeleton + slotsYaExistentes + historial; 'generar' returns slotsParaGenerar + fotosDisponibles + brand.blogStrategy + blogJIT (if blog slots present)."
5664
+ )
5665
+ }),
5666
+ import_zod40.z.object({
5667
+ ok: import_zod40.z.literal(false),
5668
+ error: import_zod40.z.string(),
5669
+ code: import_zod40.z.enum(["BRAND_NOT_FOUND", "WEEK_HAS_NO_SLOTS"]).optional()
5670
+ })
5671
+ ]);
5672
+ var rawContract12 = {
5673
+ name: "generate_weekly_content",
5674
+ description: "Build the week's content. Two modes: 'planificar' aggregates context for the LLM to propose distribution (LLM then uses add_calendar_slot/update_calendar_slot per day); 'generar' returns pre-approved slots + photos + blog JIT context for the LLM to generate actual content (LLM then calls save_generated_content per slot). AUTO-CREATES the monthly marketing_calendario if it does not exist. NEVER generates content directly \u2014 always returns a payload for the LLM consumer.",
5675
+ paramsSchema: ParamsSchema12,
5676
+ outputSchema: OutputSchema12,
5677
+ // Auto-create de calendario es bootstrap reversible (volver a llamar usa el
5678
+ // calendario existente). NO publica nada externo. modo='generar' tampoco
5679
+ // publica — solo prepara contexto.
5680
+ requiresConfirmation: false,
5681
+ destructive: false,
5682
+ affectsPublication: false,
5683
+ affectsExternal: false,
5684
+ martinSummaryTemplate: (input, output, locale) => {
5685
+ if (!output.ok) {
5686
+ if (output.code) {
5687
+ return getMessage(`marketing.errors.${output.code}`, locale);
5688
+ }
5689
+ return getMessage("marketing.safeError.generic", locale);
5690
+ }
5691
+ const modo = input.modo ?? "planificar";
5692
+ if (locale === "en") {
5693
+ return modo === "planificar" ? `I gathered the context to propose this week's distribution.` : `I gathered the context to generate this week's content.`;
5694
+ }
5695
+ return modo === "planificar" ? `Reun\xED el contexto para proponer la distribuci\xF3n de la semana.` : `Reun\xED el contexto para generar el contenido de la semana.`;
5696
+ },
5697
+ auditAction: "marketing.weekly_content.preparar",
5698
+ // El helper escribe el calendario solo si no existe (auto-create).
5699
+ // El path canónico que afecta es el calendario del mes.
5700
+ extractTargetPath: (input, _output) => {
5701
+ const mes = (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
5702
+ return `tenants/${input.tenantId}/marketing_calendario/${input.tenantId}_${input.brandId}_${mes}`;
5703
+ },
5704
+ extractChanges: (input, output) => ({
5705
+ before: null,
5706
+ after: output.ok ? {
5707
+ modo: input.modo ?? "planificar",
5708
+ semana: input.semana ?? null,
5709
+ payloadKeys: Object.keys(output.payload)
5710
+ } : null
5711
+ }),
5712
+ quotasConsumed: [],
5713
+ permissionScope: "module",
5714
+ permissionKey: "marketing",
5715
+ // 'editar' porque auto-crea calendario si no existe. Si solo fuera read,
5716
+ // sería 'ver' — pero el bootstrap del calendario requiere permiso de escritura.
5717
+ permissionAction: "editar",
5718
+ sideEffects: ["reads_firestore", "writes_firestore"]
5719
+ };
5720
+ var weeklyContentBuilderContract = MartinContractSchema.parse(
5721
+ rawContract12
5722
+ );
5029
5723
  var DEFAULT_TEXT_THRESHOLD = 0.35;
5030
5724
  var DEFAULT_IMAGE_THRESHOLD = 0.08;
5031
5725
  var DEFAULT_CONFLICT_THRESHOLD = 0.85;
@@ -5060,7 +5754,7 @@ function diversify(items, jaccardThreshold = 0.6) {
5060
5754
  }
5061
5755
  async function contentFinder(input) {
5062
5756
  const { db, tenantId, brandId, contexto, fecha, include, limit, diversidad, deps } = input;
5063
- const mode2 = input.mode ?? "text";
5757
+ const mode = input.mode ?? "text";
5064
5758
  const textThreshold = deps.thresholds?.text ?? DEFAULT_TEXT_THRESHOLD;
5065
5759
  const imageThreshold = deps.thresholds?.image ?? DEFAULT_IMAGE_THRESHOLD;
5066
5760
  const conflictThreshold = deps.thresholds?.conflictSimilarity ?? DEFAULT_CONFLICT_THRESHOLD;
@@ -5091,7 +5785,7 @@ async function contentFinder(input) {
5091
5785
  queryEmbedding: queryEmbedding ?? void 0,
5092
5786
  queryText
5093
5787
  };
5094
- if (mode2 === "text") {
5788
+ if (mode === "text") {
5095
5789
  return safeSearch(true, {
5096
5790
  ...baseParams,
5097
5791
  limit: lim,
@@ -5213,6 +5907,93 @@ async function contentFinder(input) {
5213
5907
  }
5214
5908
  return result;
5215
5909
  }
5910
+ var IncludeSchema = import_zod41.z.object({
5911
+ products: import_zod41.z.boolean(),
5912
+ collections: import_zod41.z.boolean(),
5913
+ articles: import_zod41.z.boolean(),
5914
+ pages: import_zod41.z.boolean()
5915
+ }).describe(
5916
+ "Toggles for which Shopify content types to search. Default truthy for products/collections/articles, false for pages. Set false to skip a category for performance."
5917
+ );
5918
+ var LimitSchema = import_zod41.z.object({
5919
+ products: import_zod41.z.number().int().min(0).max(20),
5920
+ collections: import_zod41.z.number().int().min(0).max(10),
5921
+ articles: import_zod41.z.number().int().min(0).max(20),
5922
+ pages: import_zod41.z.number().int().min(0).max(10)
5923
+ }).describe(
5924
+ "Per-category result count caps. Defaults: products 5, collections 3, articles 5, pages 2."
5925
+ );
5926
+ var ParamsSchema13 = import_zod41.z.object({
5927
+ tenantId: import_zod41.z.string().min(1).describe("Tenant identifier (the business account)."),
5928
+ brandId: import_zod41.z.string().min(1).describe("Brand identifier within the tenant."),
5929
+ contexto: import_zod41.z.string().min(1).describe(
5930
+ "Search context: a paragraph, keyword, or intent string. Embedded for vector search."
5931
+ ),
5932
+ fecha: import_zod41.z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe(
5933
+ "Content date in YYYY-MM-DD. Used to detect active season and prioritize matching collections."
5934
+ ),
5935
+ include: IncludeSchema,
5936
+ limit: LimitSchema,
5937
+ diversidad: import_zod41.z.boolean().describe(
5938
+ "Whether to apply Jaccard title diversification + handle dedupe to results. Default true."
5939
+ ),
5940
+ mode: import_zod41.z.enum(["text", "hybrid"]).optional().describe(
5941
+ "Search mode. 'text' (default): single query per collection against embeddingText, fast. 'hybrid': parallel text + image queries with Reciprocal Rank Fusion, 2x queries \u2014 useful when text mode returns few results or to surface visually-similar items with weak SEO."
5942
+ )
5943
+ });
5944
+ var VectorResultSchema = import_zod41.z.object({
5945
+ id: import_zod41.z.string(),
5946
+ similarity: import_zod41.z.number().optional()
5947
+ }).passthrough();
5948
+ var TemporadaSchema2 = import_zod41.z.object({
5949
+ coleccion: import_zod41.z.string().nullable(),
5950
+ titulo: import_zod41.z.string().nullable(),
5951
+ razon: import_zod41.z.string().nullable(),
5952
+ fechaInicio: import_zod41.z.string().nullable(),
5953
+ fechaFin: import_zod41.z.string().nullable()
5954
+ });
5955
+ var SuggestedActionSchema2 = import_zod41.z.record(import_zod41.z.string(), import_zod41.z.unknown());
5956
+ var DetectedConflictSchema2 = import_zod41.z.record(import_zod41.z.string(), import_zod41.z.unknown());
5957
+ var OutputSchema13 = import_zod41.z.object({
5958
+ productos: import_zod41.z.array(VectorResultSchema),
5959
+ colecciones: import_zod41.z.array(VectorResultSchema),
5960
+ articles: import_zod41.z.array(VectorResultSchema),
5961
+ pages: import_zod41.z.array(VectorResultSchema),
5962
+ _instrucciones: import_zod41.z.string(),
5963
+ _negativePrompt: import_zod41.z.string(),
5964
+ _temporadaActiva: TemporadaSchema2.nullable(),
5965
+ _detectedConflict: DetectedConflictSchema2.optional(),
5966
+ _suggestedActions: import_zod41.z.array(SuggestedActionSchema2)
5967
+ });
5968
+ var rawContract13 = {
5969
+ name: "find_content_for_topic",
5970
+ description: "Find Shopify content (products, collections, articles, pages) semantically related to a context/keyword + date. Uses multimodal vector search (1408d Vertex). Mode 'text' (default) queries embeddingText (fast); 'hybrid' merges text + image results via Reciprocal Rank Fusion (rescues items with weak text SEO). Auto-injects JIT context: linking instructions, brand visual negatives, active season collection, and conflict detection (>85% similar article).",
5971
+ paramsSchema: ParamsSchema13,
5972
+ outputSchema: OutputSchema13,
5973
+ // Lectura pura (vector search). Failures internas en search degradan a
5974
+ // arrays vacíos — el helper NO falla.
5975
+ requiresConfirmation: false,
5976
+ destructive: false,
5977
+ affectsPublication: false,
5978
+ affectsExternal: false,
5979
+ martinSummaryTemplate: (input, output, locale) => {
5980
+ const totalResults = output.productos.length + output.colecciones.length + output.articles.length + output.pages.length;
5981
+ if (locale === "en") {
5982
+ return `I found ${totalResults} item${totalResults === 1 ? "" : "s"} matching "${input.contexto}".`;
5983
+ }
5984
+ return `Encontr\xE9 ${totalResults} resultado${totalResults === 1 ? "" : "s"} relacionado${totalResults === 1 ? "" : "s"} a "${input.contexto}".`;
5985
+ },
5986
+ // AUDITA SIEMPRE — regla A5. Lectura no muta, changes son null/null.
5987
+ auditAction: "marketing.content.buscar",
5988
+ quotasConsumed: [],
5989
+ permissionScope: "module",
5990
+ permissionKey: "marketing",
5991
+ permissionAction: "ver",
5992
+ sideEffects: ["reads_firestore"]
5993
+ };
5994
+ var contentFinderContract = MartinContractSchema.parse(
5995
+ rawContract13
5996
+ );
5216
5997
  var DEFAULT_PHOTO_THRESHOLD = 0.7;
5217
5998
  var DEFAULT_SHOPIFY_THRESHOLD = 0.65;
5218
5999
  function mapPlataformaAFormato(plataforma) {
@@ -5244,7 +6025,7 @@ async function slotAssetFinder(input) {
5244
6025
  const shopifyThreshold = deps.thresholds?.shopify ?? DEFAULT_SHOPIFY_THRESHOLD;
5245
6026
  const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
5246
6027
  if (!configSnap.exists) {
5247
- return { ok: false, error: `Brand "${brandId}" no encontrada` };
6028
+ return { ok: false, error: `Brand "${brandId}" no encontrada`, code: "BRAND_NOT_FOUND" };
5248
6029
  }
5249
6030
  const brand = configSnap.data();
5250
6031
  const brandBrief = brand.brandBrief ?? null;
@@ -5343,6 +6124,7 @@ async function slotAssetFinder(input) {
5343
6124
  temporada ? `Hay temporada activa (${temporada.coleccion.title ?? temporada.coleccion.id}). Respeta bloqueoProducto si esta activo.` : null
5344
6125
  ].filter(Boolean).join(" ");
5345
6126
  return {
6127
+ ok: true,
5346
6128
  fotos,
5347
6129
  _instrucciones,
5348
6130
  _negativePrompt: negativePrompt,
@@ -5357,6 +6139,79 @@ async function slotAssetFinder(input) {
5357
6139
  _fuente: fuente
5358
6140
  };
5359
6141
  }
6142
+ var ParamsSchema14 = import_zod42.z.object({
6143
+ tenantId: import_zod42.z.string().min(1).describe("Tenant identifier (the business account)."),
6144
+ brandId: import_zod42.z.string().min(1).describe("Brand identifier within the tenant."),
6145
+ keyword: import_zod42.z.string().min(1).describe(
6146
+ "Slot keyword to search photos for. Used as embedding query for multimodal vector search."
6147
+ ),
6148
+ plataforma: import_zod42.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe(
6149
+ "Target platform \u2014 determines the variant format to resolve (gbp_4_3, blog_3_2, ig_4_5, ig_1_1)."
6150
+ ),
6151
+ fecha: import_zod42.z.string().regex(/^\d{4}-\d{2}-\d{2}$/).describe(
6152
+ "Slot date in YYYY-MM-DD. Used to detect active season and apply seasonal catalog overrides."
6153
+ ),
6154
+ limit: import_zod42.z.number().int().min(1).max(10).optional().describe("Max number of photos to return. Default 5. Max 10.")
6155
+ });
6156
+ var TemporadaSchema22 = import_zod42.z.object({
6157
+ coleccion: import_zod42.z.string().nullable(),
6158
+ titulo: import_zod42.z.string().nullable(),
6159
+ razon: import_zod42.z.string().nullable(),
6160
+ fechaInicio: import_zod42.z.string().nullable(),
6161
+ fechaFin: import_zod42.z.string().nullable()
6162
+ });
6163
+ var OutputSchema14 = import_zod42.z.discriminatedUnion("ok", [
6164
+ // Note: success case does not include `ok: true` literal in helper return —
6165
+ // helper returns the success shape directly. Adapter to discriminated union
6166
+ // happens at wrapper level if needed; here we accept both shapes.
6167
+ import_zod42.z.object({
6168
+ ok: import_zod42.z.literal(true),
6169
+ fotos: import_zod42.z.array(import_zod42.z.record(import_zod42.z.string(), import_zod42.z.unknown())),
6170
+ _instrucciones: import_zod42.z.string(),
6171
+ _negativePrompt: import_zod42.z.string(),
6172
+ _temporadaActiva: TemporadaSchema22.nullable(),
6173
+ _bloqueoProducto: import_zod42.z.boolean(),
6174
+ _fuente: import_zod42.z.enum(["tenant", "shopify_product"])
6175
+ }),
6176
+ import_zod42.z.object({
6177
+ ok: import_zod42.z.literal(false),
6178
+ error: import_zod42.z.string(),
6179
+ code: import_zod42.z.enum(["BRAND_NOT_FOUND"]).optional()
6180
+ })
6181
+ ]);
6182
+ var rawContract14 = {
6183
+ name: "get_photos_for_slot",
6184
+ description: "Find tenant photos for a calendar slot (keyword + platform + date) via multimodal vector search (1408d Vertex). Resolves the platform-specific variant (gbp_4_3/blog_3_2/ig_4_5/ig_1_1). Auto-injects JIT context: how to choose, brand visual negatives, active season overrides, and source layer (tenant photos vs Shopify product fallback). If <3 photos returned, consider calling request_photo_shoot to alert the tenant.",
6185
+ paramsSchema: ParamsSchema14,
6186
+ outputSchema: OutputSchema14,
6187
+ requiresConfirmation: false,
6188
+ destructive: false,
6189
+ affectsPublication: false,
6190
+ affectsExternal: false,
6191
+ martinSummaryTemplate: (input, output, locale) => {
6192
+ if (!output.ok) {
6193
+ if (output.code) {
6194
+ return getMessage(`marketing.errors.${output.code}`, locale);
6195
+ }
6196
+ return getMessage("marketing.safeError.generic", locale);
6197
+ }
6198
+ const count = output.fotos.length;
6199
+ if (locale === "en") {
6200
+ return `I found ${count} photo${count === 1 ? "" : "s"} for "${input.keyword}".`;
6201
+ }
6202
+ return `Encontr\xE9 ${count} foto${count === 1 ? "" : "s"} para "${input.keyword}".`;
6203
+ },
6204
+ // AUDITA SIEMPRE — regla A5.
6205
+ auditAction: "marketing.fotos.buscar_para_slot",
6206
+ quotasConsumed: [],
6207
+ permissionScope: "module",
6208
+ permissionKey: "marketing",
6209
+ permissionAction: "ver",
6210
+ sideEffects: ["reads_firestore"]
6211
+ };
6212
+ var slotAssetFinderContract = MartinContractSchema.parse(
6213
+ rawContract14
6214
+ );
5360
6215
  var DEFAULT_SIMILARITY_THRESHOLD = 0.6;
5361
6216
  function cosineSimilarity(a, b) {
5362
6217
  let dot = 0;
@@ -5432,6 +6287,72 @@ async function canvaTemplateSelector(input) {
5432
6287
  _instrucciones: "Plantilla Canva seleccionada. Llama a marketingDesignWithCanva({contenidoId, plantillaId, fotoVariantePath, textos}) para renderizar la pieza final."
5433
6288
  };
5434
6289
  }
6290
+ var ParamsSchema15 = import_zod43.z.object({
6291
+ tenantId: import_zod43.z.string().min(1).describe("Tenant identifier (the business account)."),
6292
+ brandId: import_zod43.z.string().min(1).describe("Brand identifier within the tenant."),
6293
+ plataforma: import_zod43.z.string().min(1).describe(
6294
+ "Target platform (e.g. 'gbp', 'shopify_blog', 'instagram'). Filters templates that declare this plataforma."
6295
+ ),
6296
+ tipoContenido: import_zod43.z.string().min(1).describe(
6297
+ "Content type (e.g. 'post', 'carousel', 'story', 'blog'). Filters templates that declare this tipoContenido."
6298
+ ),
6299
+ keyword: import_zod43.z.string().min(1).describe("Slot keyword. Used as embedding query for cosine similarity match.")
6300
+ });
6301
+ var OutputSchema15 = import_zod43.z.union([
6302
+ import_zod43.z.object({
6303
+ plantillaId: import_zod43.z.string(),
6304
+ titulo: import_zod43.z.string().nullable(),
6305
+ thumbnailUrl: import_zod43.z.string().nullable(),
6306
+ similarity: import_zod43.z.number(),
6307
+ _instrucciones: import_zod43.z.string()
6308
+ }),
6309
+ import_zod43.z.object({
6310
+ plantillaId: import_zod43.z.null(),
6311
+ motivo: import_zod43.z.enum([
6312
+ "brand_no_encontrada",
6313
+ "no_canva",
6314
+ "no_match_plataforma",
6315
+ "modo_cliente_no_soportado",
6316
+ "similarity_baja"
6317
+ ]),
6318
+ similarity: import_zod43.z.number().optional(),
6319
+ _instrucciones: import_zod43.z.string().optional(),
6320
+ _todo: import_zod43.z.string().optional()
6321
+ })
6322
+ ]);
6323
+ var rawContract15 = {
6324
+ name: "select_canva_template",
6325
+ description: "Select the best Canva template from the tenant for a slot. Filters templates by plataforma+tipoContenido, then cosine-similarity matches embeddings vs the keyword query. Returns plantillaId on match (similarity >= 0.60). On miss returns plantillaId=null with motivo enum (no_canva, no_match_plataforma, modo_cliente_no_soportado, similarity_baja, brand_no_encontrada). The LLM should degrade gracefully (generate without Canva template) on any miss.",
6326
+ paramsSchema: ParamsSchema15,
6327
+ outputSchema: OutputSchema15,
6328
+ // Lectura pura. NO falla — todo "no encontré" es resultado válido del helper.
6329
+ requiresConfirmation: false,
6330
+ destructive: false,
6331
+ affectsPublication: false,
6332
+ affectsExternal: false,
6333
+ martinSummaryTemplate: (_input, output, locale) => {
6334
+ if (output.plantillaId !== null) {
6335
+ if (locale === "en") {
6336
+ return `I selected the Canva template "${output.titulo ?? output.plantillaId}".`;
6337
+ }
6338
+ return `Seleccion\xE9 la plantilla Canva "${output.titulo ?? output.plantillaId}".`;
6339
+ }
6340
+ if (locale === "en") {
6341
+ return `I couldn't pick a Canva template (${output.motivo}). Generating without one.`;
6342
+ }
6343
+ return `No pude elegir plantilla Canva (${output.motivo}). Genero sin plantilla.`;
6344
+ },
6345
+ // AUDITA SIEMPRE — regla A5.
6346
+ auditAction: "marketing.canva.seleccionar_plantilla",
6347
+ quotasConsumed: [],
6348
+ permissionScope: "module",
6349
+ permissionKey: "marketing",
6350
+ permissionAction: "ver",
6351
+ sideEffects: ["reads_firestore"]
6352
+ };
6353
+ var canvaTemplateSelectorContract = MartinContractSchema.parse(
6354
+ rawContract15
6355
+ );
5435
6356
  function buildDirectorPlanInstrucciones(catalogoVisual) {
5436
6357
  const etiquetas = catalogoVisual.etiquetas || {};
5437
6358
  return `Eres el director de arte de este negocio. Estas viendo la foto ORIGINAL sin editar que el tenant subio (comprimida para transporte, la original en calidad completa esta en archivoOriginal).
@@ -5654,6 +6575,159 @@ async function photoDirectorExecute(input) {
5654
6575
  _instrucciones: buildDirectorExecuteSuccessInstrucciones(result.balanceAfter)
5655
6576
  };
5656
6577
  }
6578
+ var PlanParamsSchema = import_zod44.z.object({
6579
+ tenantId: import_zod44.z.string().min(1).describe("Tenant identifier (the business account)."),
6580
+ fotoId: import_zod44.z.string().min(1).describe("Photo ID in marketing_fotos. The photo must have an archivoOriginal uploaded.")
6581
+ });
6582
+ var PlanContextSchema = import_zod44.z.object({
6583
+ fotoId: import_zod44.z.string(),
6584
+ archivoOriginal: import_zod44.z.string(),
6585
+ estrategia: import_zod44.z.string(),
6586
+ brandBrief: import_zod44.z.object({
6587
+ segmento: import_zod44.z.string().nullable(),
6588
+ personalidad: import_zod44.z.unknown().nullable()
6589
+ }),
6590
+ visualRules: import_zod44.z.record(import_zod44.z.string(), import_zod44.z.unknown()),
6591
+ catalogoVisual: import_zod44.z.record(import_zod44.z.string(), import_zod44.z.unknown()),
6592
+ estiloVisual: import_zod44.z.unknown().nullable(),
6593
+ notaTenant: import_zod44.z.unknown().nullable(),
6594
+ productoLinkeadoManual: import_zod44.z.unknown().nullable(),
6595
+ _instrucciones: import_zod44.z.string()
6596
+ });
6597
+ var PlanOutputSchema = import_zod44.z.discriminatedUnion("ok", [
6598
+ import_zod44.z.object({
6599
+ ok: import_zod44.z.literal(true),
6600
+ imageBase64: import_zod44.z.string().describe("Compressed photo as base64 for transport to the LLM."),
6601
+ context: PlanContextSchema
6602
+ }),
6603
+ import_zod44.z.object({
6604
+ ok: import_zod44.z.literal(false),
6605
+ code: import_zod44.z.string(),
6606
+ error: import_zod44.z.string(),
6607
+ _instrucciones: import_zod44.z.string().optional(),
6608
+ fix: import_zod44.z.string().optional(),
6609
+ fotoId: import_zod44.z.string().optional()
6610
+ })
6611
+ ]);
6612
+ var planRawContract = {
6613
+ name: "plan_photo_edit",
6614
+ description: "Load the original photo (compressed) + brand context (brandBrief, visualRules, catalogoVisual, notaTenant, productoLinkeadoManual) so the LLM can act as art director and decide tags + edit prompt. Read-only. Returns code=BRAND_CATALOG_NOT_DEFINED if the brand has no catalogoVisual yet \u2014 call generate_brand_brief first.",
6615
+ paramsSchema: PlanParamsSchema,
6616
+ outputSchema: PlanOutputSchema,
6617
+ requiresConfirmation: false,
6618
+ destructive: false,
6619
+ affectsPublication: false,
6620
+ affectsExternal: false,
6621
+ martinSummaryTemplate: (_input, output, locale) => {
6622
+ if (!output.ok) {
6623
+ return getMessage(`marketing.errors.${output.code}`, locale);
6624
+ }
6625
+ if (locale === "en") return `I loaded the photo and the brand context. Decide the edit.`;
6626
+ return `Cargu\xE9 la foto y el contexto de la brand. Decide la edici\xF3n.`;
6627
+ },
6628
+ // AUDITA SIEMPRE — regla A5.
6629
+ auditAction: "marketing.fotos.plan_edicion",
6630
+ quotasConsumed: [],
6631
+ permissionScope: "module",
6632
+ permissionKey: "marketing",
6633
+ permissionAction: "ver",
6634
+ sideEffects: ["reads_firestore"]
6635
+ };
6636
+ var photoDirectorPlanContract = MartinContractSchema.parse(
6637
+ planRawContract
6638
+ );
6639
+ var ExecuteParamsSchema = import_zod44.z.object({
6640
+ // tenantId is injected by the wrapper from ctx (A7 module-scope pattern,
6641
+ // even though the helper function itself derives tenantId via the foto's
6642
+ // brandId). Needed here for extractTargetPath canonical path.
6643
+ tenantId: import_zod44.z.string().min(1).describe("Tenant identifier (the business account)."),
6644
+ fotoId: import_zod44.z.string().min(1).describe("Photo ID to edit (must have been planned via plan_photo_edit)."),
6645
+ prompt: import_zod44.z.string().nullable().describe(
6646
+ "English prompt for Gemini Image Edit when acciones includes edit_background. Pass null when strategy is 'tal_cual' (no editing \u2014 just regenerate thumbnail + embedding with new tags)."
6647
+ ),
6648
+ acciones: import_zod44.z.array(import_zod44.z.enum(["edit_background", "none"])).describe(
6649
+ "Edit operations to perform. Use ['edit_background'] for AI background edit, ['none'] when strategy is 'tal_cual'."
6650
+ ),
6651
+ descripcion: import_zod44.z.string().describe("Semantic description in Spanish (saved as descripcionVision on the photo)."),
6652
+ tipo: import_zod44.z.string().nullable().describe("Photo type from the brand catalogoVisual. null only if catalog has no matching type."),
6653
+ tagsPrimarios: import_zod44.z.array(import_zod44.z.string()).describe("Primary tags from the catalogoVisual."),
6654
+ tagsSecundarios: import_zod44.z.array(import_zod44.z.string()).describe("Secondary tags from the catalogoVisual."),
6655
+ tagsContexto: import_zod44.z.array(import_zod44.z.string()).describe("Contextual tags (occasion, mood, style).")
6656
+ });
6657
+ var ExecuteOutputSchema = import_zod44.z.discriminatedUnion("ok", [
6658
+ import_zod44.z.object({
6659
+ ok: import_zod44.z.literal(true),
6660
+ fotoId: import_zod44.z.string(),
6661
+ archivoEditado: import_zod44.z.string().optional(),
6662
+ thumbnailUrl: import_zod44.z.string().optional(),
6663
+ iteracion: import_zod44.z.number().optional(),
6664
+ editCosto: import_zod44.z.number().optional(),
6665
+ creditsConsumed: import_zod44.z.number().optional(),
6666
+ balanceAfter: import_zod44.z.number().optional(),
6667
+ imageBase64: import_zod44.z.string().nullable(),
6668
+ _instrucciones: import_zod44.z.string()
6669
+ }),
6670
+ import_zod44.z.object({
6671
+ ok: import_zod44.z.literal(false),
6672
+ error: import_zod44.z.string(),
6673
+ code: import_zod44.z.string(),
6674
+ details: import_zod44.z.record(import_zod44.z.string(), import_zod44.z.unknown()).optional(),
6675
+ _instrucciones: import_zod44.z.string()
6676
+ })
6677
+ ]);
6678
+ var executeRawContract = {
6679
+ name: "execute_photo_edit",
6680
+ description: "Execute photo edit via Gemini Image Edit using the prompt and tags decided after viewing the photo in plan_photo_edit. Consumes credits per edit. Returns the edited photo for review; if not satisfactory, call again with adjusted prompt (max 3 iterations per photo). For 'tal_cual' strategy pass acciones=['none'] \u2014 no editing but thumbnail + embedding regenerate with the new tags.",
6681
+ paramsSchema: ExecuteParamsSchema,
6682
+ outputSchema: ExecuteOutputSchema,
6683
+ // CONSUME CRÉDITOS reales y muta la foto del tenant. requiresConfirmation
6684
+ // canónico para acciones costosas (regla decision tree del MARTIN_CONTRACT_PATTERN).
6685
+ requiresConfirmation: true,
6686
+ destructive: false,
6687
+ affectsPublication: false,
6688
+ affectsExternal: true,
6689
+ martinConfirmationTemplate: (_input, locale) => {
6690
+ if (locale === "en") {
6691
+ return `Edit this photo with AI? It consumes credits from your monthly balance.`;
6692
+ }
6693
+ return `\xBFEdito esta foto con IA? Consume cr\xE9ditos de tu saldo mensual.`;
6694
+ },
6695
+ martinSummaryTemplate: (_input, output, locale) => {
6696
+ if (!output.ok) {
6697
+ return getMessage(`marketing.errors.${output.code}`, locale);
6698
+ }
6699
+ if (locale === "en") {
6700
+ return `I edited the photo. Review the result; if it's not right, call again with an adjusted prompt.`;
6701
+ }
6702
+ return `Edit\xE9 la foto. Revisa el resultado; si no qued\xF3, llama de nuevo con prompt ajustado.`;
6703
+ },
6704
+ auditAction: "marketing.fotos.editar",
6705
+ extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_fotos/${input.fotoId}`,
6706
+ extractChanges: (input, output) => ({
6707
+ before: null,
6708
+ after: output.ok ? {
6709
+ fotoId: output.fotoId,
6710
+ tipo: input.tipo,
6711
+ tagsPrimarios: input.tagsPrimarios,
6712
+ tagsSecundarios: input.tagsSecundarios,
6713
+ tagsContexto: input.tagsContexto,
6714
+ acciones: input.acciones,
6715
+ archivoEditado: output.archivoEditado,
6716
+ iteracion: output.iteracion,
6717
+ creditsConsumed: output.creditsConsumed
6718
+ } : null
6719
+ }),
6720
+ quotasConsumed: ["photoEditsPerMonth"],
6721
+ permissionScope: "module",
6722
+ permissionKey: "marketing",
6723
+ permissionAction: "editar",
6724
+ // affectsExternal=true porque llama a Gemini Image Edit + descarga thumbnail
6725
+ // + escribe doc actualizado.
6726
+ sideEffects: ["writes_firestore", "spends_credits", "consumes_ai_tokens"]
6727
+ };
6728
+ var photoDirectorExecuteContract = MartinContractSchema.parse(
6729
+ executeRawContract
6730
+ );
5657
6731
  var PARTE_1_IDENTIDAD = `
5658
6732
  Eres el asistente de marketing digital de {{brand_nombre}}.
5659
6733
  Tu trabajo es generar contenido de alta calidad para publicar en
@@ -6320,6 +7394,90 @@ async function checkQuota(tenantId, quotaName) {
6320
7394
  async function getCurrentUsage(_tenantId, _quotaName) {
6321
7395
  return 0;
6322
7396
  }
7397
+ var NIVELES_CANONICOS = [
7398
+ "ninguno",
7399
+ "ver",
7400
+ "editar",
7401
+ "completo"
7402
+ ];
7403
+ var CACHE_TTL_MS = 60 * 1e3;
7404
+ var _rolesCache = /* @__PURE__ */ new Map();
7405
+ var _userCache = /* @__PURE__ */ new Map();
7406
+ function _getCached(map, key) {
7407
+ const entry = map.get(key);
7408
+ if (!entry) return void 0;
7409
+ if (Date.now() - entry.t > CACHE_TTL_MS) {
7410
+ map.delete(key);
7411
+ return void 0;
7412
+ }
7413
+ return entry.data;
7414
+ }
7415
+ function _setCached(map, key, data) {
7416
+ map.set(key, { t: Date.now(), data });
7417
+ }
7418
+ async function _readRolesDoc(db, tenantId) {
7419
+ const key = `roles:${tenantId}`;
7420
+ const cached = _getCached(_rolesCache, key);
7421
+ if (cached !== void 0) return cached;
7422
+ const ref = db.collection("configuracion").doc(`${tenantId}_roles`);
7423
+ const snap = await ref.get();
7424
+ const data = snap.exists ? snap.data() : null;
7425
+ _setCached(_rolesCache, key, data);
7426
+ return data;
7427
+ }
7428
+ async function _readUserDoc(db, uid) {
7429
+ const key = `user:${uid}`;
7430
+ const cached = _getCached(_userCache, key);
7431
+ if (cached !== void 0) return cached;
7432
+ const ref = db.collection("usuarios").doc(uid);
7433
+ const snap = await ref.get();
7434
+ const data = snap.exists ? snap.data() : null;
7435
+ _setCached(_userCache, key, data);
7436
+ return data;
7437
+ }
7438
+ async function getUserContext({
7439
+ db,
7440
+ tenantId,
7441
+ uid
7442
+ }) {
7443
+ const userDoc = await _readUserDoc(db, uid);
7444
+ if (!userDoc) {
7445
+ throw new Error(`userContext: usuario ${uid} no encontrado.`);
7446
+ }
7447
+ const rolId = userDoc.rol ?? "empleado";
7448
+ const rolNombre = rolId === "super_admin" ? "Super Admin" : await _resolveRolNombre(db, tenantId, rolId);
7449
+ return {
7450
+ uid,
7451
+ nombre: userDoc.nombre ?? userDoc.email ?? "Usuario",
7452
+ email: userDoc.email ?? null,
7453
+ rolId,
7454
+ rolNombre,
7455
+ idiomaPreferido: userDoc.idiomaPreferido ?? "es"
7456
+ };
7457
+ }
7458
+ async function _resolveRolNombre(db, tenantId, rolId) {
7459
+ const rolesData = await _readRolesDoc(db, tenantId);
7460
+ if (!rolesData) return null;
7461
+ const rolData = rolesData[rolId];
7462
+ return rolData?.nombre ?? null;
7463
+ }
7464
+ async function getUserPermissionLevel({
7465
+ db,
7466
+ tenantId,
7467
+ userRol,
7468
+ modulo
7469
+ }) {
7470
+ if (userRol === "super_admin") return "completo";
7471
+ if (!userRol) return "ninguno";
7472
+ const rolesData = await _readRolesDoc(db, tenantId);
7473
+ if (!rolesData) return "ninguno";
7474
+ const rolData = rolesData[userRol];
7475
+ if (!rolData || rolData.activo === false) return "ninguno";
7476
+ const permisos = rolData.permisosPorModulo || {};
7477
+ const nivel = permisos[modulo];
7478
+ if (!nivel || !NIVELES_CANONICOS.includes(nivel)) return "ninguno";
7479
+ return nivel;
7480
+ }
6323
7481
  async function resolveTenantIdioma(tenantId) {
6324
7482
  const db = (0, import_firestore5.getFirestore)();
6325
7483
  const tenantSnap = await db.doc(`tenants/${tenantId}`).get();
@@ -6335,72 +7493,44 @@ async function resolveTenantIdioma(tenantId) {
6335
7493
  }
6336
7494
  return idioma;
6337
7495
  }
6338
- var MESSAGES = {
6339
- es: {
6340
- generic: "Tuve un problema. Intent\xE9moslo de nuevo en un momento.",
6341
- quota_exceeded: "Alcanzaste el l\xEDmite mensual.",
6342
- not_found: "No lo encontr\xE9. \xBFPodr\xEDas verificar el dato?",
6343
- permission: "No tengo permiso para hacer eso desde tu cuenta.",
6344
- timeout: "Esto est\xE1 tardando m\xE1s de lo normal. \xBFLo intento de nuevo?"
6345
- },
6346
- en: {
6347
- generic: "I had a problem. Let's try again in a moment.",
6348
- quota_exceeded: "You reached the monthly limit.",
6349
- not_found: "I couldn't find it. Could you verify the data?",
6350
- permission: "I don't have permission to do that from your account.",
6351
- timeout: "This is taking longer than usual. Should I try again?"
6352
- }
6353
- };
6354
7496
  function martinSafeError(err, locale) {
6355
- if (!(locale in MESSAGES)) {
6356
- throw new Error(
6357
- `Locale '${locale}' no soportado en martinSafeError. Locales activos: ${Object.keys(MESSAGES).join(", ")}. Agregar traducciones antes de usar.`
6358
- );
6359
- }
6360
- const msgs = MESSAGES[locale];
6361
7497
  const e = err;
6362
7498
  const message = e?.message ?? "";
6363
7499
  const code = e?.code ?? "";
6364
- if (/quota|límite|limit/i.test(message)) return msgs.quota_exceeded;
6365
- if (/not.found|no.encontrado|no existe/i.test(message)) return msgs.not_found;
6366
- if (/permission|permiso/i.test(message)) return msgs.permission;
6367
- if (code === "deadline-exceeded" || /timeout/i.test(message)) return msgs.timeout;
6368
- return msgs.generic;
6369
- }
6370
- var MESSAGES2 = {
6371
- es: {
6372
- denied_role: "No tengo permiso para hacer eso desde tu cuenta. P\xEDdelo al encargado o admin que te autorice.",
6373
- input_invalido: "Falta algo o est\xE1 mal. \xBFLo intentamos de nuevo?",
6374
- confirm_default: "\xBFConfirmas?"
6375
- },
6376
- en: {
6377
- denied_role: "I don't have permission to do that from your account. Ask the manager or admin to authorize it.",
6378
- input_invalido: "Something is missing or off. Could you try again?",
6379
- confirm_default: "Are you sure?"
6380
- }
6381
- };
7500
+ let key = "generic";
7501
+ if (/quota|límite|limit/i.test(message)) key = "quota_exceeded";
7502
+ else if (/not.found|no.encontrado|no existe/i.test(message)) key = "not_found";
7503
+ else if (/permission|permiso/i.test(message)) key = "permission";
7504
+ else if (code === "deadline-exceeded" || /timeout/i.test(message)) key = "timeout";
7505
+ return getMessage(`marketing.safeError.${key}`, locale);
7506
+ }
6382
7507
  function getWrapperMessage(key, locale) {
6383
- if (!(locale in MESSAGES2)) {
6384
- throw new Error(
6385
- `Locale '${locale}' no soportado en getWrapperMessage. Locales activos: ${Object.keys(MESSAGES2).join(", ")}. Agregar traducciones antes de usar.`
6386
- );
6387
- }
6388
- const msg = MESSAGES2[locale][key];
6389
- if (!msg) {
6390
- throw new Error(
6391
- `Mensaje '${key}' no definido en wrapperMessages para locale '${locale}'.`
6392
- );
6393
- }
6394
- return msg;
7508
+ return getMessage(`marketing.wrapper.${key}`, locale);
6395
7509
  }
6396
7510
  var NIVELES_ORDER = ["ninguno", "ver", "editar", "completo"];
6397
- function nivelAlcanza(nivel, accion) {
7511
+ function nivelAlcanza2(nivel, accion) {
6398
7512
  return NIVELES_ORDER.indexOf(nivel) >= NIVELES_ORDER.indexOf(accion);
6399
7513
  }
6400
7514
  function wrapWithContract(contract, helper, options = {}) {
6401
7515
  return async function wrappedTool(input, ctx) {
6402
7516
  const startMs = Date.now();
6403
7517
  const locale = ctx.user.idiomaPreferido;
7518
+ const actorType = ctx.user.clientType === "martin" ? "martin" : "mcp_client";
7519
+ const buildActor = () => {
7520
+ const base = {
7521
+ type: actorType,
7522
+ uid: ctx.user.uid,
7523
+ nombre: ctx.user.nombre
7524
+ };
7525
+ const cm = ctx.user.clientMetadata;
7526
+ if (cm && (cm.mcpClient || cm.mcpClientVersion)) {
7527
+ base.metadata = {
7528
+ ...cm.mcpClient ? { mcpClient: cm.mcpClient } : {},
7529
+ ...cm.mcpClientVersion ? { mcpClientVersion: cm.mcpClientVersion } : {}
7530
+ };
7531
+ }
7532
+ return base;
7533
+ };
6404
7534
  let accesoOk = false;
6405
7535
  let reqDesc = "";
6406
7536
  switch (contract.permissionScope) {
@@ -6425,7 +7555,7 @@ function wrapWithContract(contract, helper, options = {}) {
6425
7555
  userRol: ctx.user.rol,
6426
7556
  modulo: contract.permissionKey
6427
7557
  });
6428
- accesoOk = nivelAlcanza(nivel, contract.permissionAction);
7558
+ accesoOk = nivelAlcanza2(nivel, contract.permissionAction);
6429
7559
  break;
6430
7560
  }
6431
7561
  case "self": {
@@ -6449,7 +7579,7 @@ function wrapWithContract(contract, helper, options = {}) {
6449
7579
  await writeAuditLog({
6450
7580
  tenantId: ctx.tenantId,
6451
7581
  brandId: ctx.brandId ?? null,
6452
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
7582
+ actor: buildActor(),
6453
7583
  action: contract.auditAction,
6454
7584
  motivo: `Acceso denegado \u2014 rol '${ctx.user.rol}' sin permiso para ${reqDesc}`,
6455
7585
  conversacionId: ctx.conversacionId ?? null,
@@ -6481,7 +7611,7 @@ function wrapWithContract(contract, helper, options = {}) {
6481
7611
  await writeAuditLog({
6482
7612
  tenantId: ctx.tenantId,
6483
7613
  brandId: ctx.brandId ?? null,
6484
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
7614
+ actor: buildActor(),
6485
7615
  action: contract.auditAction,
6486
7616
  motivo: "Input inv\xE1lido \u2014 Zod parse failed",
6487
7617
  conversacionId: ctx.conversacionId ?? null,
@@ -6497,7 +7627,10 @@ function wrapWithContract(contract, helper, options = {}) {
6497
7627
  };
6498
7628
  }
6499
7629
  const parsedInput = parseResult.data;
6500
- if (contract.requiresConfirmation && !ctx.confirmationGranted) {
7630
+ const effectiveDestructive = contract.isDestructiveForInput?.(parsedInput) ?? contract.destructive;
7631
+ const effectivePublication = contract.isPublicationForInput?.(parsedInput) ?? contract.affectsPublication;
7632
+ const effectiveRequiresConfirmation = contract.requiresConfirmation || effectiveDestructive || effectivePublication;
7633
+ if (effectiveRequiresConfirmation && !ctx.confirmationGranted) {
6501
7634
  const confirmMsg = contract.martinConfirmationTemplate ? contract.martinConfirmationTemplate(parsedInput, locale) : getWrapperMessage("confirm_default", locale);
6502
7635
  return {
6503
7636
  text: confirmMsg,
@@ -6522,7 +7655,7 @@ function wrapWithContract(contract, helper, options = {}) {
6522
7655
  await writeAuditLog({
6523
7656
  tenantId: ctx.tenantId,
6524
7657
  brandId: ctx.brandId ?? null,
6525
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
7658
+ actor: buildActor(),
6526
7659
  action: contract.auditAction,
6527
7660
  motivo: `Quota excedida \u2014 ${quotaName}: ${check.current}/${check.limit}`,
6528
7661
  conversacionId: ctx.conversacionId ?? null,
@@ -6544,9 +7677,9 @@ function wrapWithContract(contract, helper, options = {}) {
6544
7677
  await writeAuditLog({
6545
7678
  tenantId: ctx.tenantId,
6546
7679
  brandId: ctx.brandId ?? null,
6547
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
7680
+ actor: buildActor(),
6548
7681
  action: contract.auditAction,
6549
- motivo: "Acci\xF3n solicitada v\xEDa Martin",
7682
+ motivo: `Acci\xF3n solicitada v\xEDa ${actorType}`,
6550
7683
  conversacionId: ctx.conversacionId ?? null,
6551
7684
  status: "error",
6552
7685
  errorMessage: err?.message ?? "unknown",
@@ -6573,7 +7706,7 @@ function wrapWithContract(contract, helper, options = {}) {
6573
7706
  await writeAuditLog({
6574
7707
  tenantId: ctx.tenantId,
6575
7708
  brandId: ctx.brandId ?? null,
6576
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
7709
+ actor: buildActor(),
6577
7710
  action: contract.auditAction,
6578
7711
  motivo: `Output del helper inv\xE1lido \u2014 bug de "${contract.name}"`,
6579
7712
  conversacionId: ctx.conversacionId ?? null,
@@ -6606,7 +7739,7 @@ function wrapWithContract(contract, helper, options = {}) {
6606
7739
  await writeAuditLog({
6607
7740
  tenantId: ctx.tenantId,
6608
7741
  brandId: ctx.brandId ?? null,
6609
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
7742
+ actor: buildActor(),
6610
7743
  action: contract.auditAction,
6611
7744
  motivo: `disabled \u2014 ${code}${detail ? `: ${JSON.stringify(detail)}` : ""}`,
6612
7745
  conversacionId: ctx.conversacionId ?? null,
@@ -6625,11 +7758,11 @@ function wrapWithContract(contract, helper, options = {}) {
6625
7758
  await writeAuditLog({
6626
7759
  tenantId: ctx.tenantId,
6627
7760
  brandId: ctx.brandId ?? null,
6628
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
7761
+ actor: buildActor(),
6629
7762
  action: contract.auditAction,
6630
7763
  targetPath,
6631
7764
  changes,
6632
- motivo: "Acci\xF3n solicitada v\xEDa Martin",
7765
+ motivo: `Acci\xF3n solicitada v\xEDa ${actorType}`,
6633
7766
  conversacionId: ctx.conversacionId ?? null,
6634
7767
  status: "success",
6635
7768
  durationMs: Date.now() - startMs
@@ -6642,100 +7775,136 @@ function wrapWithContract(contract, helper, options = {}) {
6642
7775
  };
6643
7776
  };
6644
7777
  }
6645
- var RecordarMemoriaParamsSchema = import_zod34.z.object({
6646
- tipo: TipoMemoriaEnum,
6647
- categoria: CategoriaMemoriaEnum,
6648
- contenido: import_zod34.z.string().min(3).max(500)
7778
+ var RecordarMemoriaParamsSchema = import_zod45.z.object({
7779
+ // tenantId + uid are injected by the wrapper from ctx (A7 self-scope pattern).
7780
+ tenantId: import_zod45.z.string().min(1).describe("Tenant identifier (the business account)."),
7781
+ uid: import_zod45.z.string().min(1).describe("User ID. Memories are private to this user within the tenant."),
7782
+ tipo: TipoMemoriaEnum.describe(
7783
+ "Memory type: 'preferencia' (personal preference), 'regla' (operational rule, e.g. 'do not alert for amounts <$500'), 'patron' (observed behavioral pattern), 'aversion' (explicit 'do not do X' rule)."
7784
+ ),
7785
+ categoria: CategoriaMemoriaEnum.describe(
7786
+ "Business area this memory applies to (compras, produccion, dispatch, ventas, marketing, operacion, personal, delegacion)."
7787
+ ),
7788
+ contenido: import_zod45.z.string().min(3).max(500).describe("Memory content in the user's own words (3-500 chars).")
6649
7789
  });
6650
- var RecordarMemoriaOutputSchema = import_zod34.z.object({
6651
- memoriaId: import_zod34.z.string(),
6652
- status: import_zod34.z.literal("creada")
7790
+ var RecordarMemoriaOutputSchema = import_zod45.z.object({
7791
+ memoriaId: import_zod45.z.string(),
7792
+ status: import_zod45.z.literal("creada")
6653
7793
  });
6654
- var OlvidarMemoriaParamsSchema = import_zod34.z.object({
6655
- memoriaId: import_zod34.z.string(),
6656
- motivo: import_zod34.z.string().optional()
7794
+ var OlvidarMemoriaParamsSchema = import_zod45.z.object({
7795
+ // tenantId + uid are injected by the wrapper from ctx (A7 self-scope pattern).
7796
+ tenantId: import_zod45.z.string().min(1).describe("Tenant identifier (the business account)."),
7797
+ uid: import_zod45.z.string().min(1).describe("User ID. Memories are private to this user within the tenant."),
7798
+ memoriaId: import_zod45.z.string().describe("Memory document ID to archive."),
7799
+ motivo: import_zod45.z.string().optional().describe("Optional reason for archiving (logged for audit). OMIT if not applicable.")
6657
7800
  });
6658
- var OlvidarMemoriaOutputSchema = import_zod34.z.object({
6659
- status: import_zod34.z.literal("archivada")
7801
+ var OlvidarMemoriaOutputSchema = import_zod45.z.object({
7802
+ status: import_zod45.z.literal("archivada")
6660
7803
  });
6661
- var ConfigInputSchema = import_zod35.z.object({
6662
- diaSemana: import_zod35.z.number().int().min(0).max(6).nullable(),
6663
- diaMes: import_zod35.z.number().int().min(1).max(31).nullable(),
6664
- hora: import_zod35.z.string().regex(/^\d{2}:\d{2}$/),
6665
- // Timezone NO viene en input — hereda de tenants/{tid}.zonaHoraria (audit fix 2).
6666
- fechaPuntual: import_zod35.z.string().datetime({ offset: true }).nullable()
7804
+ var ConfigInputSchema = import_zod46.z.object({
7805
+ diaSemana: import_zod46.z.number().int().min(0).max(6).nullable().describe("Day of week (0=Sunday, 6=Saturday) for weekly routines. null for daily/monthly/puntual."),
7806
+ diaMes: import_zod46.z.number().int().min(1).max(31).nullable().describe("Day of month (1-31) for monthly routines. null for daily/weekly/puntual."),
7807
+ hora: import_zod46.z.string().regex(/^\d{2}:\d{2}$/).describe("Execution time in HH:MM format (24h, tenant timezone)."),
7808
+ // Timezone is NOT input — inherited from tenants/{tid}.zonaHoraria (audit fix 2).
7809
+ fechaPuntual: import_zod46.z.string().datetime({ offset: true }).nullable().describe("ISO datetime with offset for one-time routines (frecuencia=puntual). null otherwise.")
6667
7810
  }).strict();
6668
- var AccionInputSchema = import_zod35.z.object({
6669
- tool: import_zod35.z.string().min(1),
6670
- params: import_zod35.z.record(import_zod35.z.string(), import_zod35.z.unknown())
7811
+ var AccionInputSchema = import_zod46.z.object({
7812
+ tool: import_zod46.z.string().min(1).describe("MCP tool name to invoke when the routine fires (e.g. generate_weekly_content)."),
7813
+ params: import_zod46.z.record(import_zod46.z.string(), import_zod46.z.unknown()).describe("Parameters to pass to the tool. Schema depends on the target tool.")
6671
7814
  }).strict();
6672
- var ProgramarRutinaParamsSchema = import_zod35.z.object({
6673
- tipo: TipoRutinaEnum,
6674
- frecuencia: FrecuenciaRutinaEnum,
6675
- config: ConfigInputSchema,
6676
- accion: AccionInputSchema,
6677
- uidDestinatario: import_zod35.z.string()
7815
+ var ProgramarRutinaParamsSchema = import_zod46.z.object({
7816
+ // tenantId + uid are injected by the wrapper from ctx (A7 self-scope pattern).
7817
+ tenantId: import_zod46.z.string().min(1).describe("Tenant identifier (the business account)."),
7818
+ uid: import_zod46.z.string().min(1).describe("User ID. Routines are private to this user within the tenant."),
7819
+ tipo: TipoRutinaEnum.describe("Routine type/category from the canonical enum."),
7820
+ frecuencia: FrecuenciaRutinaEnum.describe(
7821
+ "Execution cadence: 'diaria' | 'semanal' | 'mensual' | 'puntual'."
7822
+ ),
7823
+ config: ConfigInputSchema.describe("Schedule configuration. Match fields to the frecuencia."),
7824
+ accion: AccionInputSchema.describe("Action to execute on each fire (tool + params)."),
7825
+ uidDestinatario: import_zod46.z.string().describe("User ID who should receive the routine output (usually same as uid).")
6678
7826
  });
6679
- var ProgramarRutinaOutputSchema = import_zod35.z.object({
6680
- rutinaId: import_zod35.z.string(),
6681
- proximaEjecucionAt: import_zod35.z.string().datetime()
7827
+ var ProgramarRutinaOutputSchema = import_zod46.z.object({
7828
+ rutinaId: import_zod46.z.string(),
7829
+ proximaEjecucionAt: import_zod46.z.string().datetime()
6682
7830
  });
6683
- var PausarRutinaParamsSchema = import_zod35.z.object({
6684
- rutinaId: import_zod35.z.string(),
6685
- motivo: import_zod35.z.string().optional()
7831
+ var PausarRutinaParamsSchema = import_zod46.z.object({
7832
+ // tenantId + uid are injected by the wrapper from ctx (A7 self-scope pattern).
7833
+ tenantId: import_zod46.z.string().min(1).describe("Tenant identifier (the business account)."),
7834
+ uid: import_zod46.z.string().min(1).describe("User ID. Routines are private to this user within the tenant."),
7835
+ rutinaId: import_zod46.z.string().describe("Routine document ID to pause."),
7836
+ motivo: import_zod46.z.string().optional().describe("Optional reason for pausing (logged for audit). OMIT if not applicable.")
6686
7837
  });
6687
- var PausarRutinaOutputSchema = import_zod35.z.object({
6688
- status: import_zod35.z.literal("pausada")
7838
+ var PausarRutinaOutputSchema = import_zod46.z.object({
7839
+ status: import_zod46.z.literal("pausada")
6689
7840
  });
6690
- var ArchivarRutinaParamsSchema = import_zod35.z.object({
6691
- rutinaId: import_zod35.z.string(),
6692
- motivo: import_zod35.z.string().optional()
7841
+ var ArchivarRutinaParamsSchema = import_zod46.z.object({
7842
+ // tenantId + uid are injected by the wrapper from ctx (A7 self-scope pattern).
7843
+ tenantId: import_zod46.z.string().min(1).describe("Tenant identifier (the business account)."),
7844
+ uid: import_zod46.z.string().min(1).describe("User ID. Routines are private to this user within the tenant."),
7845
+ rutinaId: import_zod46.z.string().describe("Routine document ID to archive (will not execute again)."),
7846
+ motivo: import_zod46.z.string().optional().describe("Optional reason for archiving (logged for audit). OMIT if not applicable.")
6693
7847
  });
6694
- var ArchivarRutinaOutputSchema = import_zod35.z.object({
6695
- status: import_zod35.z.literal("archivada")
7848
+ var ArchivarRutinaOutputSchema = import_zod46.z.object({
7849
+ status: import_zod46.z.literal("archivada")
6696
7850
  });
6697
- var ListarRutinasParamsSchema = import_zod35.z.object({
6698
- uid: import_zod35.z.string().optional()
7851
+ var ListarRutinasParamsSchema = import_zod46.z.object({
7852
+ uid: import_zod46.z.string().optional().describe(
7853
+ "User ID to list routines for. OMIT to default to the calling user (the wrapper rejects requests for other uids unless caller is super_admin)."
7854
+ )
6699
7855
  });
6700
- var ListarRutinasOutputSchema = import_zod35.z.object({
6701
- rutinas: import_zod35.z.array(MartinRutinaSchema)
7856
+ var ListarRutinasOutputSchema = import_zod46.z.object({
7857
+ rutinas: import_zod46.z.array(MartinRutinaSchema)
6702
7858
  });
6703
7859
 
6704
- // src/tools/martinContext.ts
6705
- function buildMartinContext(session, brandId, opts = {}) {
7860
+ // src/tools/buildContext.ts
7861
+ async function buildContext(session, brandId, opts = {}) {
7862
+ const tenantId = session.requireTenant();
7863
+ const ci = session.mcpClientIdentity;
7864
+ let userCtx = null;
7865
+ if (session.userId && getSdkMode() === "admin") {
7866
+ try {
7867
+ userCtx = await getUserContext({
7868
+ db: getAdminDb(),
7869
+ tenantId,
7870
+ uid: session.userId
7871
+ });
7872
+ } catch {
7873
+ userCtx = null;
7874
+ }
7875
+ }
6706
7876
  return {
6707
- tenantId: session.requireTenant(),
7877
+ tenantId,
6708
7878
  brandId,
6709
7879
  user: {
6710
7880
  uid: session.userId ?? "cowork-admin",
6711
- nombre: session.userName ?? "Cowork Admin",
6712
- rol: session.rol ?? "super_admin",
6713
- idiomaPreferido: "es"
6714
- // TODO HITO 8: leer usuarios/{uid}.idiomaPreferido
7881
+ nombre: userCtx?.nombre ?? session.userName ?? "Cowork Admin",
7882
+ rol: userCtx?.rolId ?? session.rol ?? "super_admin",
7883
+ rolNombre: userCtx?.rolNombre ?? null,
7884
+ idiomaPreferido: userCtx?.idiomaPreferido ?? "es",
7885
+ // Any caller entering through this factory is an MCP client (Claude
7886
+ // Desktop, Cursor, custom LLM scripts), NOT the Martin product app.
7887
+ // When Martin product app ships (211.3+), its server-side caller will
7888
+ // override clientType to 'martin' via the same `_martinContext` channel.
7889
+ clientType: "mcp_client",
7890
+ clientMetadata: {
7891
+ mcpClient: ci.mcpClient,
7892
+ mcpClientVersion: ci.mcpClientVersion
7893
+ }
6715
7894
  },
6716
7895
  conversacionId: opts.conversacionId ?? null,
6717
- confirmationGranted: opts.confirmationGranted ?? true,
7896
+ // HITO 6 A7.8: default false — secure default para acciones destructivas.
7897
+ // Tools sin requiresConfirmation lo ignoran; tools con
7898
+ // requiresConfirmation: true necesitan el flag explícito para ejecutar.
7899
+ confirmationGranted: opts.confirmationGranted ?? false,
6718
7900
  doubleConfirmationGranted: opts.doubleConfirmationGranted ?? false
6719
7901
  };
6720
7902
  }
6721
7903
  async function dispatchWithContract(args) {
6722
7904
  const { contract, helper, callable, input, ctx } = args;
6723
- if (getMode() === "admin") {
7905
+ if (getSdkMode() === "admin") {
6724
7906
  const db = getAdminDb();
6725
- const permissionResolver = async (args2) => {
6726
- if (args2.userRol === "super_admin") return "completo";
6727
- const ref = db.collection("configuracion").doc(`${args2.tenantId}_roles`);
6728
- const snap = await ref.get();
6729
- if (!snap.exists) return "ninguno";
6730
- const rolesData = snap.data();
6731
- const rolData = rolesData[args2.userRol];
6732
- if (!rolData || rolData.activo === false) return "ninguno";
6733
- const nivel = rolData.permisosPorModulo?.[args2.modulo];
6734
- if (nivel === "completo" || nivel === "editar" || nivel === "ver" || nivel === "ninguno") {
6735
- return nivel;
6736
- }
6737
- return "ninguno";
6738
- };
7907
+ const permissionResolver = (args2) => getUserPermissionLevel({ db, ...args2 });
6739
7908
  const wrapped = wrapWithContract(
6740
7909
  contract,
6741
7910
  async (i) => helper({ ...i, db }),
@@ -6743,15 +7912,20 @@ async function dispatchWithContract(args) {
6743
7912
  );
6744
7913
  return wrapped(input, ctx);
6745
7914
  }
6746
- return callable({
7915
+ const result = await callable({
6747
7916
  ...input,
6748
7917
  _martinContext: {
6749
7918
  conversacionId: ctx.conversacionId ?? null,
6750
7919
  confirmationGranted: ctx.confirmationGranted === true,
6751
7920
  doubleConfirmationGranted: ctx.doubleConfirmationGranted === true,
6752
- userIdiomaPreferido: ctx.user.idiomaPreferido
7921
+ userIdiomaPreferido: ctx.user.idiomaPreferido,
7922
+ // HITO 6 A7.5: propagar clientType + identidad concreta para que
7923
+ // el factory CF arme actor.type + actor.metadata en el audit log.
7924
+ clientType: ctx.user.clientType ?? "mcp_client",
7925
+ clientMetadata: ctx.user.clientMetadata ?? null
6753
7926
  }
6754
7927
  });
7928
+ return result;
6755
7929
  }
6756
7930
 
6757
7931
  // src/services/marketingHelperCallables.ts
@@ -6828,7 +8002,7 @@ function callPhotoDirectorExecute(input) {
6828
8002
  }
6829
8003
 
6830
8004
  // src/tools/marketing/photos.ts
6831
- var import_zod37 = require("zod");
8005
+ var import_zod47 = require("zod");
6832
8006
 
6833
8007
  // src/services/marketingEmbeddings.ts
6834
8008
  var import_google_auth_library = require("google-auth-library");
@@ -6883,7 +8057,7 @@ async function generateTextEmbedding(text, session) {
6883
8057
  if (!trimmed) {
6884
8058
  throw new Error("generateTextEmbedding: text no puede estar vacio");
6885
8059
  }
6886
- if (getMode() === "client") {
8060
+ if (getSdkMode() === "client") {
6887
8061
  return null;
6888
8062
  }
6889
8063
  return callEmbeddingCF({ text: trimmed }, session);
@@ -6977,7 +8151,7 @@ async function findNearestInCollection(params) {
6977
8151
  `findNearestInCollection: vectorField obligatorio (embeddingText | embeddingImage), recibido ${vectorField}`
6978
8152
  );
6979
8153
  }
6980
- if (getMode() === "client") {
8154
+ if (getSdkMode() === "client") {
6981
8155
  if (!queryText || typeof queryText !== "string") {
6982
8156
  throw new Error(
6983
8157
  "findNearestInCollection en Modo B (client) requiere queryText. El MCP cliente no puede generar embeddings localmente; la CF puente marketingVectorSearchCallable los genera server-side."
@@ -7095,202 +8269,6 @@ async function findNearestInCollectionWithOverride(params) {
7095
8269
  return findNearestInCollection(params);
7096
8270
  }
7097
8271
 
7098
- // src/tools/marketing/content.ts
7099
- var import_zod36 = require("zod");
7100
- var _logOverride = null;
7101
- async function logToMcpLogs(entry) {
7102
- if (_logOverride) return _logOverride(entry);
7103
- try {
7104
- const db = getAdminDb();
7105
- await db.collection("marketing_mcp_logs").add({
7106
- ...entry,
7107
- timestamp: import_firebase_admin.default.firestore.FieldValue.serverTimestamp()
7108
- });
7109
- } catch {
7110
- }
7111
- }
7112
- var IncludeSchema = import_zod36.z.object({
7113
- products: import_zod36.z.boolean().default(true),
7114
- collections: import_zod36.z.boolean().default(true),
7115
- articles: import_zod36.z.boolean().default(true),
7116
- pages: import_zod36.z.boolean().default(false)
7117
- }).default({
7118
- products: true,
7119
- collections: true,
7120
- articles: true,
7121
- pages: false
7122
- });
7123
- var LimitSchema = import_zod36.z.object({
7124
- products: import_zod36.z.number().int().min(0).max(20).default(5),
7125
- collections: import_zod36.z.number().int().min(0).max(10).default(3),
7126
- articles: import_zod36.z.number().int().min(0).max(20).default(5),
7127
- pages: import_zod36.z.number().int().min(0).max(10).default(2)
7128
- }).default({ products: 5, collections: 3, articles: 5, pages: 2 });
7129
- async function findContentForTopicHandler(input, session) {
7130
- const tenantId = session.requireTenant();
7131
- const brandId = input.brandId;
7132
- const mode2 = getMode();
7133
- const r = mode2 === "admin" ? await contentFinder({
7134
- db: getAdminDb(),
7135
- tenantId,
7136
- brandId,
7137
- contexto: input.contexto,
7138
- fecha: input.fecha,
7139
- include: input.include,
7140
- limit: input.limit,
7141
- diversidad: input.diversidad,
7142
- mode: input.mode,
7143
- deps: {
7144
- generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
7145
- findNearestInCollection: (params) => findNearestInCollectionWithOverride(
7146
- params
7147
- ),
7148
- rrfMerge,
7149
- thresholds: {
7150
- text: MARKETING_THRESHOLDS.content.text,
7151
- image: MARKETING_THRESHOLDS.content.image,
7152
- conflictSimilarity: MARKETING_THRESHOLDS.content.conflictSimilarity
7153
- }
7154
- }
7155
- }) : await callContentFinder({
7156
- tenantId,
7157
- brandId,
7158
- contexto: input.contexto,
7159
- fecha: input.fecha,
7160
- include: input.include,
7161
- limit: input.limit,
7162
- diversidad: input.diversidad,
7163
- mode: input.mode
7164
- });
7165
- logToMcpLogs({
7166
- toolName: "find_content_for_topic",
7167
- tenantId,
7168
- brandId,
7169
- input: {
7170
- contexto: input.contexto.slice(0, 500),
7171
- fecha: input.fecha,
7172
- include: input.include,
7173
- diversidad: input.diversidad
7174
- },
7175
- output: {
7176
- productsCount: r.productos.length,
7177
- collectionsCount: r.colecciones.length,
7178
- articlesCount: r.articles.length,
7179
- pagesCount: r.pages.length,
7180
- _suggestedActionsCount: r._suggestedActions.length,
7181
- _hadConflict: !!r._detectedConflict,
7182
- conflictType: r._detectedConflict?.type || null
7183
- }
7184
- }).catch((err) => console.error("[mcp_logs] write failed:", err));
7185
- return r;
7186
- }
7187
- function registerContentTools(server, session) {
7188
- server.tool(
7189
- "find_content_for_topic",
7190
- `Busca contenido semanticamente relevante del negocio (productos, colecciones, articles, pages) para un contexto/keyword dado, usando vector search multimodal 1408d. Retorna 4 listas en paralelo + JIT context del brand brief y temporada activa.
7191
-
7192
- RETORNA:
7193
- - productos, colecciones, articles, pages (segun include)
7194
- - _instrucciones: reglas para linkear contenido
7195
- - _negativePrompt: negativos visuales del brand brief
7196
- - _temporadaActiva: si hay coleccion priorizada para la fecha
7197
- - _detectedConflict / _suggestedActions: si detecta article muy similar (>85%)
7198
-
7199
- USAR:
7200
- (a) cuando escribas blogs, captions o contenido que linkee material del negocio (internal linking tier 1).
7201
- (b) cuando el tenant te pregunte que tiene sobre un tema ("tengo algo de flores moradas?", "que productos tengo para regalo de mama?") \u2014 exploracion ad-hoc del catalogo.
7202
- (c) antes de proponer un tema nuevo al calendario, para validar que el catalogo lo soporte.
7203
-
7204
- MODOS (parametro mode):
7205
- - 'text' (default): busca contra embeddingText. Rapido, preciso cuando las descripciones del catalogo estan optimizadas. Un solo query por coleccion.
7206
- - 'hybrid': combina embeddingText + embeddingImage con Reciprocal Rank Fusion. 2x queries por coleccion. Rescata productos que tienen buen visual pero SEO debil en texto \u2014 util para descubrir items que el modo text pierde. Recomendado cuando:
7207
- * El modo text devuelve pocos resultados
7208
- * Exploraras tu catalogo y quieres cobertura maxima
7209
- * Vas a hacer recomendaciones de SEO (items que solo aparecen en image = se\xF1al de texto debil)
7210
-
7211
- Cuando uses hybrid: inspecciona el campo \`modesFound\` en los resultados. 2 = el doc aparecio en ambos modos (alta confianza). 1 = aparecio solo en text (problema visual) o solo en image (problema SEO). Esos "solo image" son candidatos perfectos para recomendar optimizacion al tenant.`,
7212
- {
7213
- brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7214
- contexto: import_zod36.z.string().min(1).describe("Parrafo, keyword o intencion"),
7215
- fecha: import_zod36.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
7216
- include: IncludeSchema.optional(),
7217
- limit: LimitSchema.optional(),
7218
- diversidad: import_zod36.z.boolean().default(true),
7219
- mode: import_zod36.z.enum(["text", "hybrid"]).default("text").describe("Modo de busqueda: 'text' (rapido, 1 query) o 'hybrid' (text+image via RRF, 2x queries, rescata SEO debil)")
7220
- },
7221
- async ({ brandId: inputBrandId, contexto, fecha, include, limit, diversidad, mode: mode2 }) => {
7222
- const brandId = inputBrandId ?? session.requireBrand();
7223
- const resolvedInclude = include || {
7224
- products: true,
7225
- collections: true,
7226
- articles: true,
7227
- pages: false
7228
- };
7229
- const resolvedLimit = limit || {
7230
- products: 5,
7231
- collections: 3,
7232
- articles: 5,
7233
- pages: 2
7234
- };
7235
- try {
7236
- const result = await findContentForTopicHandler(
7237
- {
7238
- brandId,
7239
- contexto,
7240
- fecha,
7241
- include: resolvedInclude,
7242
- limit: resolvedLimit,
7243
- diversidad,
7244
- mode: mode2
7245
- },
7246
- session
7247
- );
7248
- const slimResult = (item) => ({
7249
- id: item.id,
7250
- similarity: item.similarity,
7251
- title: item.title ?? item.nombre ?? null,
7252
- handle: item.handle ?? null,
7253
- description: typeof item.description === "string" ? item.description.slice(0, 200) : null,
7254
- image: item.image ? { src: item.image.src, alt: item.image.alt } : null,
7255
- collectionHandles: item.collectionHandles ?? null,
7256
- modesFound: item.modesFound ?? null,
7257
- rrfScore: item.rrfScore ?? null
7258
- });
7259
- const slimmed = {
7260
- productos: result.productos.map(slimResult),
7261
- colecciones: result.colecciones.map(slimResult),
7262
- articles: result.articles.map(slimResult),
7263
- pages: result.pages.map(slimResult),
7264
- _instrucciones: result._instrucciones,
7265
- _negativePrompt: result._negativePrompt,
7266
- _temporadaActiva: result._temporadaActiva,
7267
- _suggestedActions: result._suggestedActions,
7268
- ...result._detectedConflict ? { _detectedConflict: result._detectedConflict } : {}
7269
- };
7270
- return {
7271
- content: [
7272
- {
7273
- type: "text",
7274
- text: JSON.stringify(slimmed, null, 2)
7275
- }
7276
- ]
7277
- };
7278
- } catch (err) {
7279
- return {
7280
- content: [
7281
- {
7282
- type: "text",
7283
- text: JSON.stringify({
7284
- error: err.message || "Error desconocido"
7285
- })
7286
- }
7287
- ]
7288
- };
7289
- }
7290
- }
7291
- );
7292
- }
7293
-
7294
8272
  // src/tools/marketing/photos.ts
7295
8273
  async function compressImageForTransport(imageUrl) {
7296
8274
  const sharp = (await import("sharp")).default;
@@ -7320,34 +8298,41 @@ REGLAS:
7320
8298
 
7321
8299
  USAR: antes de generar contenido de cualquier slot del calendario.`,
7322
8300
  {
7323
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7324
- keyword: import_zod37.z.string().describe("Keyword del slot"),
7325
- plataforma: import_zod37.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino del slot"),
7326
- fecha: import_zod37.z.string().describe("Fecha del slot en ISO YYYY-MM-DD"),
7327
- limit: import_zod37.z.number().int().min(1).max(10).default(5)
8301
+ brandId: import_zod47.z.string().optional().describe("ID de la brand"),
8302
+ keyword: import_zod47.z.string().describe("Keyword del slot"),
8303
+ plataforma: import_zod47.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino del slot"),
8304
+ fecha: import_zod47.z.string().describe("Fecha del slot en ISO YYYY-MM-DD"),
8305
+ limit: import_zod47.z.number().int().min(1).max(10).default(5)
7328
8306
  },
7329
8307
  async ({ brandId: inputBrandId, keyword, plataforma, fecha, limit }) => {
7330
8308
  const tenantId = session.requireTenant();
7331
8309
  const brandId = inputBrandId ?? session.requireBrand();
7332
- const result = getMode() === "admin" ? await slotAssetFinder({
7333
- db: getAdminDb(),
7334
- tenantId,
7335
- brandId,
7336
- keyword,
7337
- plataforma,
7338
- fecha,
7339
- limit,
7340
- deps: {
7341
- generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
7342
- findNearestPhotos: (params) => findNearestPhotosWithOverride(params),
7343
- findNearestInCollection: (params) => findNearestInCollectionWithOverride(params),
7344
- thresholds: {
7345
- photos: MARKETING_THRESHOLDS.fotos.capa1Tenant,
7346
- shopify: MARKETING_THRESHOLDS.fotos.capa2Shopify
8310
+ const ctx = await buildContext(session, brandId);
8311
+ const result = await dispatchWithContract({
8312
+ contract: slotAssetFinderContract,
8313
+ helper: (input) => slotAssetFinder({
8314
+ ...input,
8315
+ deps: {
8316
+ generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
8317
+ findNearestPhotos: (params) => findNearestPhotosWithOverride(params),
8318
+ findNearestInCollection: (params) => findNearestInCollectionWithOverride(params),
8319
+ thresholds: {
8320
+ photos: MARKETING_THRESHOLDS.fotos.capa1Tenant,
8321
+ shopify: MARKETING_THRESHOLDS.fotos.capa2Shopify
8322
+ }
7347
8323
  }
7348
- }
7349
- }) : await callSlotAssetFinder({ tenantId, brandId, keyword, plataforma, fecha, limit });
7350
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8324
+ }),
8325
+ callable: callSlotAssetFinder,
8326
+ input: { tenantId, brandId, keyword, plataforma, fecha, limit },
8327
+ ctx
8328
+ });
8329
+ const payload = result.state === "success" ? result.structuredOutput : {
8330
+ ok: false,
8331
+ state: result.state,
8332
+ mensaje: result.text,
8333
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
8334
+ };
8335
+ return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
7351
8336
  }
7352
8337
  );
7353
8338
  server.tool(
@@ -7365,18 +8350,33 @@ DESPUES de ver la foto, decide:
7365
8350
 
7366
8351
  Luego llama execute_photo_edit con tu analisis y prompt.`,
7367
8352
  {
7368
- fotoId: import_zod37.z.string().describe("ID de la foto")
8353
+ fotoId: import_zod47.z.string().describe("ID de la foto")
7369
8354
  },
7370
8355
  async ({ fotoId }) => {
7371
8356
  const tenantId = session.requireTenant();
7372
8357
  const lang = await resolveTenantIdioma(tenantId);
7373
- const result = getMode() === "admin" ? await photoDirectorPlan({
7374
- db: getAdminDb(),
7375
- tenantId,
7376
- fotoId,
7377
- deps: { compressImageForTransport },
7378
- lang
7379
- }) : await callPhotoDirectorPlan({ tenantId, fotoId });
8358
+ const ctx = await buildContext(session, null);
8359
+ const dispatchResult = await dispatchWithContract({
8360
+ contract: photoDirectorPlanContract,
8361
+ helper: (input) => photoDirectorPlan({
8362
+ ...input,
8363
+ deps: { compressImageForTransport },
8364
+ lang
8365
+ }),
8366
+ callable: callPhotoDirectorPlan,
8367
+ input: { tenantId, fotoId },
8368
+ ctx
8369
+ });
8370
+ if (dispatchResult.state !== "success") {
8371
+ const errPayload = {
8372
+ ok: false,
8373
+ state: dispatchResult.state,
8374
+ mensaje: dispatchResult.text,
8375
+ ...dispatchResult.validationErrors && dispatchResult.validationErrors.length > 0 ? { validationErrors: dispatchResult.validationErrors } : {}
8376
+ };
8377
+ return { content: [{ type: "text", text: JSON.stringify(errPayload, null, 2) }] };
8378
+ }
8379
+ const result = dispatchResult.structuredOutput;
7380
8380
  if (!result.ok) {
7381
8381
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
7382
8382
  }
@@ -7398,33 +8398,44 @@ Retorna la foto editada para que la revises. Si no te gusta:
7398
8398
 
7399
8399
  Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si se genera thumbnail y embedding con tus tags.`,
7400
8400
  {
7401
- fotoId: import_zod37.z.string(),
7402
- prompt: import_zod37.z.string().nullable().describe("Prompt en ingles para Gemini Image Edit. null si tal_cual"),
7403
- acciones: import_zod37.z.array(import_zod37.z.enum(["edit_background", "none"])),
7404
- descripcion: import_zod37.z.string().describe("Descripcion semantica en espanol"),
7405
- tipo: import_zod37.z.string().nullable().describe("Tipo del catalogoVisual del tenant"),
7406
- tagsPrimarios: import_zod37.z.array(import_zod37.z.string()),
7407
- tagsSecundarios: import_zod37.z.array(import_zod37.z.string()),
7408
- tagsContexto: import_zod37.z.array(import_zod37.z.string())
8401
+ fotoId: import_zod47.z.string(),
8402
+ prompt: import_zod47.z.string().nullable().describe("Prompt en ingles para Gemini Image Edit. null si tal_cual"),
8403
+ acciones: import_zod47.z.array(import_zod47.z.enum(["edit_background", "none"])),
8404
+ descripcion: import_zod47.z.string().describe("Descripcion semantica en espanol"),
8405
+ tipo: import_zod47.z.string().nullable().describe("Tipo del catalogoVisual del tenant"),
8406
+ tagsPrimarios: import_zod47.z.array(import_zod47.z.string()),
8407
+ tagsSecundarios: import_zod47.z.array(import_zod47.z.string()),
8408
+ tagsContexto: import_zod47.z.array(import_zod47.z.string())
7409
8409
  },
7410
8410
  async ({ fotoId, prompt, acciones, descripcion, tipo, tagsPrimarios, tagsSecundarios, tagsContexto }) => {
7411
8411
  const tenantId = session.requireTenant();
7412
8412
  const lang = await resolveTenantIdioma(tenantId);
7413
- if (getMode() === "admin") {
7414
- const executePhotoEditAdapter = async (payload) => {
7415
- const cfUrl = process.env.MARKETING_PHOTO_EDIT_CF_URL || "https://us-central1-atteyo-ops.cloudfunctions.net/marketingExecutePhotoEdit";
7416
- const { GoogleAuth: GoogleAuth2 } = await import("google-auth-library");
7417
- const auth = new GoogleAuth2({ keyFile: session.serviceAccountPath });
7418
- const client = await auth.getIdTokenClient(cfUrl);
7419
- const response = await client.request({
7420
- url: cfUrl,
7421
- method: "POST",
7422
- data: payload,
7423
- headers: { "Content-Type": "application/json" }
7424
- });
7425
- return response.data ?? {};
7426
- };
7427
- const result2 = await photoDirectorExecute({
8413
+ const executePhotoEditAdapter = async (payload) => {
8414
+ const cfUrl = process.env.MARKETING_PHOTO_EDIT_CF_URL || "https://us-central1-atteyo-ops.cloudfunctions.net/marketingExecutePhotoEdit";
8415
+ const { GoogleAuth: GoogleAuth2 } = await import("google-auth-library");
8416
+ const auth = new GoogleAuth2({ keyFile: session.serviceAccountPath });
8417
+ const client = await auth.getIdTokenClient(cfUrl);
8418
+ const response = await client.request({
8419
+ url: cfUrl,
8420
+ method: "POST",
8421
+ data: payload,
8422
+ headers: { "Content-Type": "application/json" }
8423
+ });
8424
+ return response.data ?? {};
8425
+ };
8426
+ const ctx = await buildContext(session, null);
8427
+ const dispatchResult = await dispatchWithContract({
8428
+ contract: photoDirectorExecuteContract,
8429
+ helper: (input) => photoDirectorExecute({
8430
+ ...input,
8431
+ deps: { compressImageForTransport, executePhotoEdit: executePhotoEditAdapter },
8432
+ lang
8433
+ }),
8434
+ callable: callPhotoDirectorExecute,
8435
+ // Note: tenantId en el input para que extractTargetPath pueda armar
8436
+ // el path canónico (tenants/{tid}/marketing_fotos/{fotoId}).
8437
+ input: {
8438
+ tenantId,
7428
8439
  fotoId,
7429
8440
  prompt,
7430
8441
  acciones,
@@ -7432,32 +8443,20 @@ Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si
7432
8443
  tipo,
7433
8444
  tagsPrimarios,
7434
8445
  tagsSecundarios,
7435
- tagsContexto,
7436
- deps: { compressImageForTransport, executePhotoEdit: executePhotoEditAdapter },
7437
- lang
7438
- });
7439
- if (!result2.ok) {
7440
- return { content: [{ type: "text", text: JSON.stringify(result2, null, 2) }] };
7441
- }
7442
- const contentArray2 = [];
7443
- if (result2.imageBase64) {
7444
- contentArray2.push({ type: "image", data: result2.imageBase64, mimeType: "image/jpeg" });
7445
- }
7446
- const { imageBase64: _unused2, ...textFields2 } = result2;
7447
- contentArray2.push({ type: "text", text: JSON.stringify(textFields2, null, 2) });
7448
- return { content: contentArray2 };
7449
- }
7450
- const result = await callPhotoDirectorExecute({
7451
- tenantId,
7452
- fotoId,
7453
- prompt,
7454
- acciones,
7455
- descripcion,
7456
- tipo,
7457
- tagsPrimarios,
7458
- tagsSecundarios,
7459
- tagsContexto
8446
+ tagsContexto
8447
+ },
8448
+ ctx
7460
8449
  });
8450
+ if (dispatchResult.state !== "success") {
8451
+ const errPayload = {
8452
+ ok: false,
8453
+ state: dispatchResult.state,
8454
+ mensaje: dispatchResult.text,
8455
+ ...dispatchResult.validationErrors && dispatchResult.validationErrors.length > 0 ? { validationErrors: dispatchResult.validationErrors } : {}
8456
+ };
8457
+ return { content: [{ type: "text", text: JSON.stringify(errPayload, null, 2) }] };
8458
+ }
8459
+ const result = dispatchResult.structuredOutput;
7461
8460
  if (!result.ok) {
7462
8461
  return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
7463
8462
  }
@@ -7482,7 +8481,7 @@ Retorna:
7482
8481
  - creditos: { balance, planId, periodEnd, costoPhotoEdit }
7483
8482
  - _instrucciones: que hacer segun el estado`,
7484
8483
  {
7485
- fotoId: import_zod37.z.string().describe("ID de la foto")
8484
+ fotoId: import_zod47.z.string().describe("ID de la foto")
7486
8485
  },
7487
8486
  async ({ fotoId }) => {
7488
8487
  const tenantId = session.requireTenant();
@@ -7582,19 +8581,34 @@ Retorna:
7582
8581
  "find_products_for_content",
7583
8582
  `[DEPRECATED 179.5] Wrapper interno de find_content_for_topic. Para nuevas integraciones, usa find_content_for_topic con include.products=true directamente. Este wrapper existe solo por compat hacia atras.`,
7584
8583
  {
7585
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7586
- contexto: import_zod37.z.string().describe("Parrafo, keyword o intencion del contenido"),
7587
- fecha: import_zod37.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
7588
- limit: import_zod37.z.number().int().min(1).max(10).default(5),
7589
- diversidad: import_zod37.z.boolean().default(true)
8584
+ brandId: import_zod47.z.string().optional().describe("ID de la brand"),
8585
+ contexto: import_zod47.z.string().describe("Parrafo, keyword o intencion del contenido"),
8586
+ fecha: import_zod47.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
8587
+ limit: import_zod47.z.number().int().min(1).max(10).default(5),
8588
+ diversidad: import_zod47.z.boolean().default(true)
7590
8589
  },
7591
8590
  async ({ brandId: inputBrandId, contexto, fecha, limit, diversidad }) => {
7592
8591
  console.warn(
7593
8592
  "[deprecated] find_products_for_content \u2192 use find_content_for_topic with include.products"
7594
8593
  );
8594
+ const tenantId = session.requireTenant();
7595
8595
  const brandId = inputBrandId ?? session.requireBrand();
7596
- const newResult = await findContentForTopicHandler(
7597
- {
8596
+ const ctx = await buildContext(session, brandId);
8597
+ const dispatchResult = await dispatchWithContract({
8598
+ contract: contentFinderContract,
8599
+ helper: (input) => contentFinder({
8600
+ ...input,
8601
+ deps: {
8602
+ generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
8603
+ findNearestInCollection: (params) => findNearestInCollectionWithOverride(
8604
+ params
8605
+ ),
8606
+ rrfMerge
8607
+ }
8608
+ }),
8609
+ callable: callContentFinder,
8610
+ input: {
8611
+ tenantId,
7598
8612
  brandId,
7599
8613
  contexto,
7600
8614
  fecha,
@@ -7602,8 +8616,18 @@ Retorna:
7602
8616
  limit: { products: limit, collections: 0, articles: 0, pages: 0 },
7603
8617
  diversidad
7604
8618
  },
7605
- session
7606
- );
8619
+ ctx
8620
+ });
8621
+ if (dispatchResult.state !== "success") {
8622
+ const errPayload = {
8623
+ productos: [],
8624
+ error: dispatchResult.text,
8625
+ state: dispatchResult.state,
8626
+ ...dispatchResult.validationErrors && dispatchResult.validationErrors.length > 0 ? { validationErrors: dispatchResult.validationErrors } : {}
8627
+ };
8628
+ return { content: [{ type: "text", text: JSON.stringify(errPayload) }] };
8629
+ }
8630
+ const newResult = dispatchResult.structuredOutput;
7607
8631
  const productos = (newResult.productos || []).map((r) => ({
7608
8632
  productId: r.id,
7609
8633
  similarity: r.similarity,
@@ -7624,7 +8648,6 @@ Retorna:
7624
8648
  titulo: newResult._temporadaActiva.titulo,
7625
8649
  bloqueo: true
7626
8650
  } : null,
7627
- // Campo nuevo aditivo (no rompe consumers) — senaliza deprecation
7628
8651
  _deprecated: "Usa find_content_for_topic con include.products en su lugar"
7629
8652
  }, null, 2)
7630
8653
  }]
@@ -7639,32 +8662,47 @@ RETORNA { plantillaId, titulo, thumbnailUrl } o { plantillaId: null, motivo: 'no
7639
8662
 
7640
8663
  USAR: solo si tenant tiene Canva conectado (tenants/{tenantId}/marketing_config/{brandId}.canva.connected=true).`,
7641
8664
  {
7642
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7643
- plataforma: import_zod37.z.string().describe("gbp | instagram | shopify_blog"),
7644
- tipoContenido: import_zod37.z.string().describe("post | carousel | story | blog"),
7645
- keyword: import_zod37.z.string().describe("Keyword del slot")
8665
+ brandId: import_zod47.z.string().optional().describe("ID de la brand"),
8666
+ plataforma: import_zod47.z.string().describe("gbp | instagram | shopify_blog"),
8667
+ tipoContenido: import_zod47.z.string().describe("post | carousel | story | blog"),
8668
+ keyword: import_zod47.z.string().describe("Keyword del slot")
7646
8669
  },
7647
8670
  async ({ brandId: inputBrandId, plataforma, tipoContenido, keyword }) => {
7648
8671
  const tenantId = session.requireTenant();
7649
8672
  const brandId = inputBrandId ?? session.requireBrand();
7650
- const result = getMode() === "admin" ? await canvaTemplateSelector({
7651
- db: getAdminDb(),
7652
- tenantId,
7653
- brandId,
7654
- plataforma,
7655
- tipoContenido,
7656
- keyword,
7657
- deps: {
7658
- generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
7659
- threshold: 0.6
7660
- }
7661
- }) : await callCanvaTemplateSelector({ tenantId, brandId, plataforma, tipoContenido, keyword });
7662
- return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
8673
+ const ctx = await buildContext(session, brandId);
8674
+ const dispatchResult = await dispatchWithContract({
8675
+ contract: canvaTemplateSelectorContract,
8676
+ helper: (input) => canvaTemplateSelector({
8677
+ ...input,
8678
+ deps: {
8679
+ generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
8680
+ threshold: 0.6
8681
+ }
8682
+ }),
8683
+ callable: callCanvaTemplateSelector,
8684
+ input: { tenantId, brandId, plataforma, tipoContenido, keyword },
8685
+ ctx
8686
+ });
8687
+ if (dispatchResult.state !== "success") {
8688
+ const errPayload = {
8689
+ plantillaId: null,
8690
+ motivo: dispatchResult.state,
8691
+ mensaje: dispatchResult.text,
8692
+ ...dispatchResult.validationErrors && dispatchResult.validationErrors.length > 0 ? { validationErrors: dispatchResult.validationErrors } : {}
8693
+ };
8694
+ return { content: [{ type: "text", text: JSON.stringify(errPayload, null, 2) }] };
8695
+ }
8696
+ return {
8697
+ content: [
8698
+ { type: "text", text: JSON.stringify(dispatchResult.structuredOutput, null, 2) }
8699
+ ]
8700
+ };
7663
8701
  }
7664
8702
  );
7665
8703
  server.tool(
7666
8704
  "request_photo_shoot",
7667
- `Agrega necesidades al FotoBriefing semanal del tenant. Lo usa Claude cuando detecta gaps fotograficos mientras planifica contenido (get_photos_for_slot retorna < 3 fotos).
8705
+ `Agrega necesidades al FotoBriefing semanal del tenant. The LLM uses this when it detects photo gaps mientras planifica contenido (get_photos_for_slot retorna < 3 fotos).
7668
8706
 
7669
8707
  EFECTO: merge de necesidades en marketing_fotobriefings/{tenantId}_{brandId}_{semana}. Si el doc no existe, lo crea. Idempotente por tema.
7670
8708
 
@@ -7672,15 +8710,15 @@ USAR: cuando get_photos_for_slot retorna pocas fotos y el slot aun no esta cubie
7672
8710
 
7673
8711
  IMPORTANTE \u2014 campo 'razon': Este texto lo ve el tenant en la app. Escribe en lenguaje amigable y claro, NO tecnico. Ejemplo MALO: "get_photos_for_slot retorno 0 fotos para shopify_blog". Ejemplo BUENO: "No hay fotos de alcatraz para el blog del 8 de abril". Siempre en espanol si el tenant habla espanol.`,
7674
8712
  {
7675
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7676
- semana: import_zod37.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "semana debe ser ISO YYYY-MM-DD").describe("Semana en ISO del lunes"),
7677
- necesidades: import_zod37.z.array(
7678
- import_zod37.z.object({
7679
- tema: import_zod37.z.string(),
7680
- keyword: import_zod37.z.string(),
7681
- cantidadSugerida: import_zod37.z.number().int().positive(),
7682
- razon: import_zod37.z.string(),
7683
- slotsAfectados: import_zod37.z.array(import_zod37.z.string()).optional().describe('Refs de slots afectados (formato "semana:N:slot:M")')
8713
+ brandId: import_zod47.z.string().optional().describe("ID de la brand"),
8714
+ semana: import_zod47.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "semana debe ser ISO YYYY-MM-DD").describe("Semana en ISO del lunes"),
8715
+ necesidades: import_zod47.z.array(
8716
+ import_zod47.z.object({
8717
+ tema: import_zod47.z.string(),
8718
+ keyword: import_zod47.z.string(),
8719
+ cantidadSugerida: import_zod47.z.number().int().positive(),
8720
+ razon: import_zod47.z.string(),
8721
+ slotsAfectados: import_zod47.z.array(import_zod47.z.string()).optional().describe('Refs de slots afectados (formato "semana:N:slot:M")')
7684
8722
  })
7685
8723
  ).min(1)
7686
8724
  },
@@ -7742,6 +8780,193 @@ IMPORTANTE \u2014 campo 'razon': Este texto lo ve el tenant en la app. Escribe e
7742
8780
  );
7743
8781
  }
7744
8782
 
8783
+ // src/tools/marketing/content.ts
8784
+ var import_zod48 = require("zod");
8785
+ var _logOverride = null;
8786
+ async function logToMcpLogs(entry) {
8787
+ if (_logOverride) return _logOverride(entry);
8788
+ try {
8789
+ const db = getAdminDb();
8790
+ await db.collection("marketing_mcp_logs").add({
8791
+ ...entry,
8792
+ timestamp: import_firebase_admin.default.firestore.FieldValue.serverTimestamp()
8793
+ });
8794
+ } catch {
8795
+ }
8796
+ }
8797
+ var IncludeSchema2 = import_zod48.z.object({
8798
+ products: import_zod48.z.boolean().default(true),
8799
+ collections: import_zod48.z.boolean().default(true),
8800
+ articles: import_zod48.z.boolean().default(true),
8801
+ pages: import_zod48.z.boolean().default(false)
8802
+ }).default({
8803
+ products: true,
8804
+ collections: true,
8805
+ articles: true,
8806
+ pages: false
8807
+ });
8808
+ var LimitSchema2 = import_zod48.z.object({
8809
+ products: import_zod48.z.number().int().min(0).max(20).default(5),
8810
+ collections: import_zod48.z.number().int().min(0).max(10).default(3),
8811
+ articles: import_zod48.z.number().int().min(0).max(20).default(5),
8812
+ pages: import_zod48.z.number().int().min(0).max(10).default(2)
8813
+ }).default({ products: 5, collections: 3, articles: 5, pages: 2 });
8814
+ function registerContentTools(server, session) {
8815
+ server.tool(
8816
+ "find_content_for_topic",
8817
+ `Busca contenido semanticamente relevante del negocio (productos, colecciones, articles, pages) para un contexto/keyword dado, usando vector search multimodal 1408d. Retorna 4 listas en paralelo + JIT context del brand brief y temporada activa.
8818
+
8819
+ RETORNA:
8820
+ - productos, colecciones, articles, pages (segun include)
8821
+ - _instrucciones: reglas para linkear contenido
8822
+ - _negativePrompt: negativos visuales del brand brief
8823
+ - _temporadaActiva: si hay coleccion priorizada para la fecha
8824
+ - _detectedConflict / _suggestedActions: si detecta article muy similar (>85%)
8825
+
8826
+ USAR:
8827
+ (a) cuando escribas blogs, captions o contenido que linkee material del negocio (internal linking tier 1).
8828
+ (b) cuando el tenant te pregunte que tiene sobre un tema ("tengo algo de flores moradas?", "que productos tengo para regalo de mama?") \u2014 exploracion ad-hoc del catalogo.
8829
+ (c) antes de proponer un tema nuevo al calendario, para validar que el catalogo lo soporte.
8830
+
8831
+ MODOS (parametro mode):
8832
+ - 'text' (default): busca contra embeddingText. Rapido, preciso cuando las descripciones del catalogo estan optimizadas. Un solo query por coleccion.
8833
+ - 'hybrid': combina embeddingText + embeddingImage con Reciprocal Rank Fusion. 2x queries por coleccion. Rescata productos que tienen buen visual pero SEO debil en texto \u2014 util para descubrir items que el modo text pierde. Recomendado cuando:
8834
+ * El modo text devuelve pocos resultados
8835
+ * Exploraras tu catalogo y quieres cobertura maxima
8836
+ * Vas a hacer recomendaciones de SEO (items que solo aparecen en image = se\xF1al de texto debil)
8837
+
8838
+ Cuando uses hybrid: inspecciona el campo \`modesFound\` en los resultados. 2 = el doc aparecio en ambos modos (alta confianza). 1 = aparecio solo en text (problema visual) o solo en image (problema SEO). Esos "solo image" son candidatos perfectos para recomendar optimizacion al tenant.`,
8839
+ {
8840
+ brandId: import_zod48.z.string().optional().describe("ID de la brand"),
8841
+ contexto: import_zod48.z.string().min(1).describe("Parrafo, keyword o intencion"),
8842
+ fecha: import_zod48.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
8843
+ include: IncludeSchema2.optional(),
8844
+ limit: LimitSchema2.optional(),
8845
+ diversidad: import_zod48.z.boolean().default(true),
8846
+ mode: import_zod48.z.enum(["text", "hybrid"]).default("text").describe("Modo de busqueda: 'text' (rapido, 1 query) o 'hybrid' (text+image via RRF, 2x queries, rescata SEO debil)")
8847
+ },
8848
+ async ({ brandId: inputBrandId, contexto, fecha, include, limit, diversidad, mode }) => {
8849
+ const tenantId = session.requireTenant();
8850
+ const brandId = inputBrandId ?? session.requireBrand();
8851
+ const resolvedInclude = include || {
8852
+ products: true,
8853
+ collections: true,
8854
+ articles: true,
8855
+ pages: false
8856
+ };
8857
+ const resolvedLimit = limit || {
8858
+ products: 5,
8859
+ collections: 3,
8860
+ articles: 5,
8861
+ pages: 2
8862
+ };
8863
+ try {
8864
+ const ctx = await buildContext(session, brandId);
8865
+ const dispatchResult = await dispatchWithContract({
8866
+ contract: contentFinderContract,
8867
+ helper: (input) => contentFinder({
8868
+ ...input,
8869
+ deps: {
8870
+ generateEmbedding: (q) => generateTextEmbeddingWithOverride(q, session),
8871
+ findNearestInCollection: (params) => findNearestInCollectionWithOverride(
8872
+ params
8873
+ ),
8874
+ rrfMerge,
8875
+ thresholds: {
8876
+ text: MARKETING_THRESHOLDS.content.text,
8877
+ image: MARKETING_THRESHOLDS.content.image,
8878
+ conflictSimilarity: MARKETING_THRESHOLDS.content.conflictSimilarity
8879
+ }
8880
+ }
8881
+ }),
8882
+ callable: callContentFinder,
8883
+ input: {
8884
+ tenantId,
8885
+ brandId,
8886
+ contexto,
8887
+ fecha,
8888
+ include: resolvedInclude,
8889
+ limit: resolvedLimit,
8890
+ diversidad,
8891
+ mode
8892
+ },
8893
+ ctx
8894
+ });
8895
+ if (dispatchResult.state !== "success") {
8896
+ const errPayload = {
8897
+ error: dispatchResult.text,
8898
+ state: dispatchResult.state,
8899
+ ...dispatchResult.validationErrors && dispatchResult.validationErrors.length > 0 ? { validationErrors: dispatchResult.validationErrors } : {}
8900
+ };
8901
+ return { content: [{ type: "text", text: JSON.stringify(errPayload) }] };
8902
+ }
8903
+ const result = dispatchResult.structuredOutput;
8904
+ const slimResult = (item) => ({
8905
+ id: item.id,
8906
+ similarity: item.similarity,
8907
+ title: item.title ?? item.nombre ?? null,
8908
+ handle: item.handle ?? null,
8909
+ description: typeof item.description === "string" ? item.description.slice(0, 200) : null,
8910
+ image: item.image ? { src: item.image.src, alt: item.image.alt } : null,
8911
+ collectionHandles: item.collectionHandles ?? null,
8912
+ modesFound: item.modesFound ?? null,
8913
+ rrfScore: item.rrfScore ?? null
8914
+ });
8915
+ const slimmed = {
8916
+ productos: result.productos.map(slimResult),
8917
+ colecciones: result.colecciones.map(slimResult),
8918
+ articles: result.articles.map(slimResult),
8919
+ pages: result.pages.map(slimResult),
8920
+ _instrucciones: result._instrucciones,
8921
+ _negativePrompt: result._negativePrompt,
8922
+ _temporadaActiva: result._temporadaActiva,
8923
+ _suggestedActions: result._suggestedActions,
8924
+ ...result._detectedConflict ? { _detectedConflict: result._detectedConflict } : {}
8925
+ };
8926
+ logToMcpLogs({
8927
+ toolName: "find_content_for_topic",
8928
+ tenantId,
8929
+ brandId,
8930
+ input: {
8931
+ contexto: contexto.slice(0, 500),
8932
+ fecha,
8933
+ include: resolvedInclude,
8934
+ diversidad
8935
+ },
8936
+ output: {
8937
+ productsCount: result.productos.length,
8938
+ collectionsCount: result.colecciones.length,
8939
+ articlesCount: result.articles.length,
8940
+ pagesCount: result.pages.length,
8941
+ _suggestedActionsCount: result._suggestedActions.length,
8942
+ _hadConflict: !!result._detectedConflict,
8943
+ conflictType: result._detectedConflict?.type || null
8944
+ }
8945
+ }).catch((err) => console.error("[mcp_logs] write failed:", err));
8946
+ return {
8947
+ content: [
8948
+ {
8949
+ type: "text",
8950
+ text: JSON.stringify(slimmed, null, 2)
8951
+ }
8952
+ ]
8953
+ };
8954
+ } catch (err) {
8955
+ return {
8956
+ content: [
8957
+ {
8958
+ type: "text",
8959
+ text: JSON.stringify({
8960
+ error: err.message || "Error desconocido"
8961
+ })
8962
+ }
8963
+ ]
8964
+ };
8965
+ }
8966
+ }
8967
+ );
8968
+ }
8969
+
7745
8970
  // src/services/pipelineLink.ts
7746
8971
  var PLATAFORMA_A_PASO = {
7747
8972
  shopify_blog: "blog",
@@ -7826,14 +9051,14 @@ function registerMarketingTools(server, session) {
7826
9051
  "get_calendar",
7827
9052
  "Lee el calendario editorial del mes. Muestra semanas con items planificados por plataforma.",
7828
9053
  {
7829
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
7830
- mes: import_zod38.z.string().optional().describe("Mes en formato YYYY-MM (default: mes actual)")
9054
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9055
+ mes: import_zod49.z.string().optional().describe("Mes en formato YYYY-MM (default: mes actual)")
7831
9056
  },
7832
9057
  async ({ brandId: inputBrandId, mes }) => {
7833
9058
  const tenantId = session.requireTenant();
7834
9059
  const brandId = inputBrandId ?? session.requireBrand();
7835
9060
  const targetMes = mes ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
7836
- const ctx = buildMartinContext(session, brandId);
9061
+ const ctx = await buildContext(session, brandId);
7837
9062
  const result = await dispatchWithContract({
7838
9063
  contract: getCalendarContract,
7839
9064
  helper: getCalendar,
@@ -7860,7 +9085,7 @@ function registerMarketingTools(server, session) {
7860
9085
  "get_seo_snapshot",
7861
9086
  "Lee el snapshot SEO de la brand: rank, keywords, oportunidades, competidores.",
7862
9087
  {
7863
- brandId: import_zod38.z.string().optional().describe("ID de la brand")
9088
+ brandId: import_zod49.z.string().optional().describe("ID de la brand")
7864
9089
  },
7865
9090
  async ({ brandId: inputBrandId }) => {
7866
9091
  const tenantId = session.requireTenant();
@@ -7879,8 +9104,8 @@ function registerMarketingTools(server, session) {
7879
9104
  "get_photo_gallery",
7880
9105
  "Fotos disponibles filtradas por estado. Muestra fotos con metadata y conteo.",
7881
9106
  {
7882
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
7883
- estado: import_zod38.z.string().optional().describe("Filtrar por estado (nueva, procesando, editada, usada, descartada, error)")
9107
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9108
+ estado: import_zod49.z.string().optional().describe("Filtrar por estado (nueva, procesando, editada, usada, descartada, error)")
7884
9109
  },
7885
9110
  async ({ brandId: inputBrandId, estado }) => {
7886
9111
  session.requireTenant();
@@ -7918,32 +9143,32 @@ function registerMarketingTools(server, session) {
7918
9143
  );
7919
9144
  server.tool(
7920
9145
  "generate_marketing_plan",
7921
- "Prepara los datos necesarios para generar un plan de marketing estrategico. Retorna snapshot SEO + productos para que Claude genere el plan con el system prompt.",
9146
+ "Aggregate the data needed to generate a strategic marketing plan: SEO snapshot + products. The LLM uses the system prompt to generate the plan from this payload.",
7922
9147
  {
7923
- brandId: import_zod38.z.string().optional().describe("ID de la brand")
9148
+ brandId: import_zod49.z.string().optional().describe("ID de la brand")
7924
9149
  },
7925
9150
  async ({ brandId: inputBrandId }) => {
7926
9151
  const tenantId = session.requireTenant();
7927
9152
  const brandId = inputBrandId ?? session.requireBrand();
7928
- const result = getMode() === "admin" ? await marketingPlanBuilder({ db: getAdminDb(), tenantId, brandId }) : await callMarketingPlanBuilder({ tenantId, brandId });
9153
+ const result = getSdkMode() === "admin" ? await marketingPlanBuilder({ db: getAdminDb(), tenantId, brandId }) : await callMarketingPlanBuilder({ tenantId, brandId });
7929
9154
  const payload = result.ok ? result.payload : result;
7930
9155
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
7931
9156
  }
7932
9157
  );
7933
9158
  server.tool(
7934
9159
  "save_marketing_plan",
7935
- "Guarda el plan de marketing generado por Claude en la configuracion de la brand. Escribe en tenants/{tenantId}/marketing_config/{brandId}.plan.",
9160
+ "Save a marketing plan to the brand configuration. Writes to tenants/{tenantId}/marketing_config/{brandId}.plan. If the plan object includes a blogStrategy field, it is extracted and stored at brand level (not nested inside plan).",
7936
9161
  {
7937
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
7938
- plan: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Plan de marketing generado (keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, etc.)")
9162
+ brandId: import_zod49.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context."),
9163
+ plan: import_zod49.z.record(import_zod49.z.string(), import_zod49.z.unknown()).describe("Full marketing plan object. Common fields: keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, coleccionesPriorizadas, blogStrategy.")
7939
9164
  },
7940
9165
  async ({ brandId: inputBrandId, plan }) => {
7941
9166
  const tenantId = session.requireTenant();
7942
9167
  const brandId = inputBrandId ?? session.requireBrand();
7943
- const ctx = buildMartinContext(session, brandId);
9168
+ const ctx = await buildContext(session, brandId);
7944
9169
  const result = await dispatchWithContract({
7945
9170
  contract: planWriterSaveContract,
7946
- helper: planWriter.save,
9171
+ helper: ({ db, ...rest }) => planWriter.save({ db, ...rest }),
7947
9172
  callable: callPlanWriterSave,
7948
9173
  input: { tenantId, brandId, plan },
7949
9174
  ctx
@@ -7952,8 +9177,8 @@ function registerMarketingTools(server, session) {
7952
9177
  ok: false,
7953
9178
  state: result.state,
7954
9179
  mensaje: result.text,
7955
- // HITO 6 A6.7: incluir detalles Zod estructurados para que
7956
- // Claude (LLM) pueda auto-recuperarse en el siguiente intento.
9180
+ // HITO 6 A6.7: include structured Zod details so
9181
+ // the LLM can self-recover on the next attempt.
7957
9182
  ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
7958
9183
  };
7959
9184
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
@@ -7963,27 +9188,27 @@ function registerMarketingTools(server, session) {
7963
9188
  "update_marketing_plan_field",
7964
9189
  "Actualiza UN campo del plan de marketing sin sobreescribir el resto. Merge parcial. Ideal para agregar coleccionesPriorizadas, actualizar quickWins, etc.",
7965
9190
  {
7966
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
7967
- field: import_zod38.z.string().describe('Nombre del campo a actualizar (ej: "coleccionesPriorizadas", "quickWins", "temporadas")'),
7968
- value: import_zod38.z.unknown().describe("Valor del campo")
9191
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9192
+ field: import_zod49.z.string().describe('Nombre del campo a actualizar (ej: "coleccionesPriorizadas", "quickWins", "temporadas")'),
9193
+ value: import_zod49.z.unknown().describe("Valor del campo")
7969
9194
  },
7970
9195
  async ({ brandId: inputBrandId, field, value }) => {
7971
9196
  const tenantId = session.requireTenant();
7972
9197
  const brandId = inputBrandId ?? session.requireBrand();
7973
- const result = getMode() === "admin" ? await planWriter.updateField({ db: getAdminDb(), tenantId, brandId, field, value }) : await callPlanWriterUpdateField({ tenantId, brandId, field, value });
9198
+ const result = getSdkMode() === "admin" ? await planWriter.updateField({ db: getAdminDb(), tenantId, brandId, field, value }) : await callPlanWriterUpdateField({ tenantId, brandId, field, value });
7974
9199
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
7975
9200
  }
7976
9201
  );
7977
9202
  server.tool(
7978
9203
  "generate_brand_brief",
7979
- "Prepara todos los datos del negocio para que Claude genere un Brand Brief pre-llenado. Retorna Shopify + SEO + GBP + tenant data. Claude genera el brief, luego usa save_brand_brief para guardar.",
9204
+ "Aggregate all business data needed to generate a Brand Brief: Shopify (products, collections, orders, shop info), SEO snapshot, GBP profiles, scraped site_content, brand config, tenant locations. Read-only \u2014 does NOT write the brief. After receiving the payload, generate the brief content and save it via save_brand_brief.",
7980
9205
  {
7981
- brandId: import_zod38.z.string().optional().describe("ID de la brand")
9206
+ brandId: import_zod49.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context.")
7982
9207
  },
7983
9208
  async ({ brandId: inputBrandId }) => {
7984
9209
  const tenantId = session.requireTenant();
7985
9210
  const brandId = inputBrandId ?? session.requireBrand();
7986
- const ctx = buildMartinContext(session, brandId);
9211
+ const ctx = await buildContext(session, brandId);
7987
9212
  const result = await dispatchWithContract({
7988
9213
  contract: brandBriefBuilderContract,
7989
9214
  helper: brandBriefBuilder,
@@ -8004,77 +9229,93 @@ function registerMarketingTools(server, session) {
8004
9229
  );
8005
9230
  server.tool(
8006
9231
  "save_brand_brief",
8007
- "Guarda el Brand Brief generado por Claude en tenants/{tenantId}/marketing_config/{brandId}.brandBrief. Escribe solo ese campo (merge parcial, no rebuild del doc).",
9232
+ "Save the Brand Brief at tenants/{tenantId}/marketing_config/{brandId}.brandBrief. Partial merge \u2014 only the brandBrief field is overwritten.",
8008
9233
  {
8009
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8010
- brandBrief: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Brand Brief completo generado por Claude")
9234
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9235
+ brandBrief: import_zod49.z.record(import_zod49.z.string(), import_zod49.z.unknown()).describe("Full Brand Brief object.")
8011
9236
  },
8012
9237
  async ({ brandId: inputBrandId, brandBrief }) => {
8013
9238
  const tenantId = session.requireTenant();
8014
9239
  const brandId = inputBrandId ?? session.requireBrand();
8015
- const result = getMode() === "admin" ? await brandBriefWriter({ db: getAdminDb(), tenantId, brandId, brandBrief }) : await callBrandBriefWriter({ tenantId, brandId, brandBrief });
8016
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
9240
+ const ctx = await buildContext(session, brandId);
9241
+ const result = await dispatchWithContract({
9242
+ contract: brandBriefWriterContract,
9243
+ helper: brandBriefWriter,
9244
+ callable: callBrandBriefWriter,
9245
+ input: { tenantId, brandId, brandBrief },
9246
+ ctx
9247
+ });
9248
+ const payload = result.state === "success" ? result.structuredOutput : {
9249
+ ok: false,
9250
+ state: result.state,
9251
+ mensaje: result.text,
9252
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9253
+ };
9254
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
8017
9255
  }
8018
9256
  );
8019
9257
  server.tool(
8020
9258
  "generate_weekly_content",
8021
- "Prepara datos del calendario + fotos + plan para generar el contenido de la semana. Claude genera con el system prompt, luego usa save_generated_content para guardar.",
9259
+ "Aggregate calendar + photos + plan data to generate the week's content. The LLM generates with the system prompt, then uses save_generated_content to persist.",
8022
9260
  {
8023
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8024
- semana: import_zod38.z.number().optional().describe("Numero de semana (1-5). Default: semana actual del mes."),
8025
- modo: import_zod38.z.enum(["planificar", "generar"]).optional().describe("'planificar' = proponer distribucion (plataforma+keyword por dia). 'generar' = generar contenido real para slots ya planificados. Default: 'planificar'.")
9261
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9262
+ semana: import_zod49.z.number().optional().describe("Numero de semana (1-5). Default: semana actual del mes."),
9263
+ modo: import_zod49.z.enum(["planificar", "generar"]).optional().describe("'planificar' = proponer distribucion (plataforma+keyword por dia). 'generar' = generar contenido real para slots ya planificados. Default: 'planificar'.")
8026
9264
  },
8027
9265
  async ({ brandId: inputBrandId, semana, modo }) => {
8028
9266
  const tenantId = session.requireTenant();
8029
9267
  const brandId = inputBrandId ?? session.requireBrand();
8030
- const result = getMode() === "admin" ? await weeklyContentBuilder({ db: getAdminDb(), tenantId, brandId, semana, modo }) : await callWeeklyContentBuilder({ tenantId, brandId, semana, modo });
8031
- const payload = result.ok ? result.payload : result;
9268
+ const ctx = await buildContext(session, brandId);
9269
+ const result = await dispatchWithContract({
9270
+ contract: weeklyContentBuilderContract,
9271
+ helper: weeklyContentBuilder,
9272
+ callable: callWeeklyContentBuilder,
9273
+ input: { tenantId, brandId, semana, modo },
9274
+ ctx
9275
+ });
9276
+ const payload = result.state === "success" ? result.structuredOutput?.payload ?? result.structuredOutput : {
9277
+ ok: false,
9278
+ state: result.state,
9279
+ mensaje: result.text,
9280
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9281
+ };
8032
9282
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
8033
9283
  }
8034
9284
  );
8035
9285
  server.tool(
8036
9286
  "save_generated_content",
8037
- `Guarda contenido generado por Claude en Firestore. Usa buildContenido para validar la estructura.
9287
+ `Save generated content to Firestore. Uses buildContenido to validate the structure.
8038
9288
 
8039
- IMPORTANTE: Si seleccionaste una foto con get_photos_for_slot, SIEMPRE pasa fotoId aqu\xED. Sin fotoId el post se publica sin imagen.`,
9289
+ IMPORTANT: If you selected a photo with get_photos_for_slot, ALWAYS pass fotoId here. Without fotoId the post will be published without an image.`,
8040
9290
  {
8041
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8042
- plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
8043
- tipo: import_zod38.z.string().optional().describe("Tipo de contenido (post, blog, carousel, etc.)"),
8044
- keyword: import_zod38.z.string().optional().describe("Keyword target"),
8045
- languageCode: import_zod38.z.string().optional().describe("Idioma (es/en)"),
8046
- fotoId: import_zod38.z.string().optional().describe("ID de la foto a asociar"),
8047
- datos: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Datos especificos de la plataforma (output de buildDatos*)"),
8048
- calendarioItemRef: import_zod38.z.string().optional().describe("Referencia al item del calendario")
9291
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
9292
+ plataforma: import_zod49.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
9293
+ tipo: import_zod49.z.string().optional().describe("Tipo de contenido (post, blog, carousel, etc.)"),
9294
+ keyword: import_zod49.z.string().optional().describe("Keyword target"),
9295
+ languageCode: import_zod49.z.string().optional().describe("Idioma (es/en)"),
9296
+ fotoId: import_zod49.z.string().optional().describe("ID de la foto a asociar"),
9297
+ datos: import_zod49.z.record(import_zod49.z.string(), import_zod49.z.unknown()).describe("Datos especificos de la plataforma (output de buildDatos*)"),
9298
+ calendarioItemRef: import_zod49.z.string().optional().describe("Referencia al item del calendario")
8049
9299
  },
8050
9300
  async ({ brandId: inputBrandId, plataforma, tipo, keyword, languageCode, fotoId, datos, calendarioItemRef }) => {
8051
9301
  const tenantId = session.requireTenant();
8052
9302
  const brandId = inputBrandId ?? session.requireBrand();
8053
9303
  try {
8054
- const result = getMode() === "admin" ? await contenidoWriter({
8055
- db: getAdminDb(),
8056
- tenantId,
8057
- brandId,
8058
- plataforma,
8059
- tipo,
8060
- keyword,
8061
- languageCode,
8062
- fotoId,
8063
- datos,
8064
- calendarioItemRef,
8065
- linkPipeline: linkContenidoAPasoPipeline
8066
- }) : await callContenidoWriter({
8067
- tenantId,
8068
- brandId,
8069
- plataforma,
8070
- tipo,
8071
- keyword,
8072
- languageCode,
8073
- fotoId,
8074
- datos,
8075
- calendarioItemRef
9304
+ const ctx = await buildContext(session, brandId);
9305
+ const result = await dispatchWithContract({
9306
+ contract: contenidoWriterContract,
9307
+ helper: (input) => contenidoWriter({ ...input, linkPipeline: linkContenidoAPasoPipeline }),
9308
+ callable: callContenidoWriter,
9309
+ input: { tenantId, brandId, plataforma, tipo, keyword, languageCode, fotoId, datos, calendarioItemRef },
9310
+ ctx
8076
9311
  });
8077
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
9312
+ const payload = result.state === "success" ? result.structuredOutput : {
9313
+ ok: false,
9314
+ state: result.state,
9315
+ mensaje: result.text,
9316
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9317
+ };
9318
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
8078
9319
  } catch (err) {
8079
9320
  const msg = err instanceof Error ? err.message : String(err);
8080
9321
  console.error("[save_generated_content] UNCAUGHT ERROR:", msg);
@@ -8090,61 +9331,64 @@ Usa para: corregir body, metaTitle, tags, fotoId, o cualquier campo sin tener qu
8090
9331
  NO puede cambiar: tenantId, brandId, id (inmutables).
8091
9332
  Si pasas campos dentro de "datos", se hace merge con los datos existentes (no los reemplaza entero).`,
8092
9333
  {
8093
- contenidoId: import_zod38.z.string().describe("ID del doc en marketing_contenido"),
8094
- datos: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).optional().describe('Campos de datos a actualizar (merge parcial sobre datos existentes). Ej: { body: "...", metaTitle: "..." }'),
8095
- fotoId: import_zod38.z.string().nullable().optional().describe("Actualizar foto asociada"),
8096
- keyword: import_zod38.z.string().nullable().optional().describe("Actualizar keyword"),
8097
- languageCode: import_zod38.z.string().optional().describe("Actualizar idioma"),
8098
- estado: import_zod38.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe("Cambiar estado manualmente"),
8099
- calendarioItemRef: import_zod38.z.string().nullable().optional().describe("Vincular a un slot del calendario")
9334
+ contenidoId: import_zod49.z.string().describe("ID del doc en marketing_contenido"),
9335
+ datos: import_zod49.z.record(import_zod49.z.string(), import_zod49.z.unknown()).optional().describe('Campos de datos a actualizar (merge parcial sobre datos existentes). Ej: { body: "...", metaTitle: "..." }'),
9336
+ fotoId: import_zod49.z.string().nullable().optional().describe("Actualizar foto asociada"),
9337
+ keyword: import_zod49.z.string().nullable().optional().describe("Actualizar keyword"),
9338
+ languageCode: import_zod49.z.string().optional().describe("Actualizar idioma"),
9339
+ estado: import_zod49.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe("Cambiar estado manualmente"),
9340
+ calendarioItemRef: import_zod49.z.string().nullable().optional().describe("Vincular a un slot del calendario")
8100
9341
  },
8101
9342
  async ({ contenidoId, datos: newDatos, fotoId, keyword, languageCode, estado, calendarioItemRef }) => {
8102
9343
  const tenantId = session.requireTenant();
8103
- const result = getMode() === "admin" ? await contenidoUpdater({
8104
- db: getAdminDb(),
8105
- tenantId,
8106
- contenidoId,
8107
- datos: newDatos,
8108
- fotoId,
8109
- keyword,
8110
- languageCode,
8111
- estado,
8112
- calendarioItemRef
8113
- }) : await callContenidoUpdater({
8114
- tenantId,
8115
- contenidoId,
8116
- datos: newDatos,
8117
- fotoId,
8118
- keyword,
8119
- languageCode,
8120
- estado,
8121
- calendarioItemRef
9344
+ const ctx = await buildContext(session, null);
9345
+ const result = await dispatchWithContract({
9346
+ contract: contenidoUpdaterContract,
9347
+ helper: contenidoUpdater,
9348
+ callable: callContenidoUpdater,
9349
+ input: {
9350
+ tenantId,
9351
+ contenidoId,
9352
+ datos: newDatos,
9353
+ fotoId,
9354
+ keyword,
9355
+ languageCode,
9356
+ estado,
9357
+ calendarioItemRef
9358
+ },
9359
+ ctx
8122
9360
  });
8123
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
9361
+ const payload = result.state === "success" ? result.structuredOutput : {
9362
+ ok: false,
9363
+ state: result.state,
9364
+ mensaje: result.text,
9365
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9366
+ };
9367
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
8124
9368
  }
8125
9369
  );
8126
9370
  server.tool(
8127
9371
  "add_calendar_slot",
8128
- "Agrega un slot NUEVO al calendario editorial. Para modificar un slot existente usa update_calendar_slot.",
9372
+ "Add a NEW slot to the editorial calendar. To modify an existing slot use update_calendar_slot instead.",
8129
9373
  {
8130
- brandId: import_zod38.z.string().describe("ID de la brand"),
8131
- mes: import_zod38.z.string().describe("Mes del calendario en formato YYYY-MM"),
8132
- semana: import_zod38.z.number().describe("Numero de semana (1-5)"),
8133
- slot: import_zod38.z.object({
8134
- dia: import_zod38.z.string().describe("Fecha del slot en formato YYYY-MM-DD. Debe caer dentro del rango fechaInicio/fechaFin de la semana indicada."),
8135
- plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
8136
- tipo: import_zod38.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).describe("Tipo de contenido"),
8137
- keyword: import_zod38.z.string().describe("Keyword principal del contenido"),
8138
- tema: import_zod38.z.string().optional().describe("Tema del contenido. OMITE el campo si no aplica, no envies cadena vacia ni null."),
8139
- productoId: import_zod38.z.string().optional().describe("ID de producto vinculado. OMITE el campo si no aplica."),
8140
- estado: import_zod38.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe("Estado inicial. OMITE si quieres default 'planificado'."),
8141
- locationId: import_zod38.z.string().optional().describe("GBP location ID \u2014 solo si plataforma=gbp y multi-sucursal. OMITE si no aplica."),
8142
- locationNombre: import_zod38.z.string().optional().describe("Nombre de la sucursal GBP. OMITE si no aplica.")
8143
- }).describe("Datos del slot nuevo")
9374
+ brandId: import_zod49.z.string().describe('Brand ID (e.g. "ponch", "teleglobos").'),
9375
+ mes: import_zod49.z.string().describe('Calendar month in YYYY-MM format (e.g. "2026-05").'),
9376
+ semana: import_zod49.z.number().describe("Week number within the month (1-5)."),
9377
+ slot: import_zod49.z.object({
9378
+ dia: import_zod49.z.string().describe("Slot date in YYYY-MM-DD format. Must fall within the week's fechaInicio/fechaFin range."),
9379
+ plataforma: import_zod49.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Target publishing platform."),
9380
+ tipo: import_zod49.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).describe("Content type."),
9381
+ keyword: import_zod49.z.string().describe("Primary keyword for the content."),
9382
+ tema: import_zod49.z.string().optional().describe("Content topic/theme. OMIT this field if it does not apply. Do NOT send empty string or null."),
9383
+ productoId: import_zod49.z.string().optional().describe("Linked product ID. OMIT this field if no product is linked."),
9384
+ estado: import_zod49.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe('Initial status. OMIT to use default "planificado".'),
9385
+ locationId: import_zod49.z.string().optional().describe("GBP location ID \u2014 only when plataforma=gbp AND tenant is multi-location. OMIT otherwise."),
9386
+ locationNombre: import_zod49.z.string().optional().describe("Human-readable GBP location name. OMIT if locationId is not provided.")
9387
+ }).describe("New slot data.")
8144
9388
  },
8145
9389
  async ({ brandId, mes, semana, slot }) => {
8146
9390
  const tenantId = session.requireTenant();
8147
- const ctx = buildMartinContext(session, brandId);
9391
+ const ctx = await buildContext(session, brandId);
8148
9392
  const result = await dispatchWithContract({
8149
9393
  contract: addCalendarSlotContract,
8150
9394
  helper: addCalendarSlot,
@@ -8163,40 +9407,48 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
8163
9407
  );
8164
9408
  server.tool(
8165
9409
  "update_calendar_slot",
8166
- "MODIFICA un slot EXISTENTE del calendario editorial. Para crear un slot nuevo usa add_calendar_slot. Si slotIndex no existe, retorna error SLOT_NOT_FOUND.",
9410
+ "MODIFY an EXISTING slot in the editorial calendar. To create a new slot use add_calendar_slot. If slotIndex does not exist, returns error SLOT_NOT_FOUND.",
8167
9411
  {
8168
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8169
- mes: import_zod38.z.string().describe("Mes del calendario (YYYY-MM)"),
8170
- semana: import_zod38.z.number().describe("Numero de semana (1-5)"),
8171
- slotIndex: import_zod38.z.number().describe("Indice del slot (0-based). Si >= items.length, agrega nuevo slot al final."),
8172
- cambios: import_zod38.z.object({
8173
- dia: import_zod38.z.string().nullable().optional(),
8174
- plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional(),
8175
- tipo: import_zod38.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional(),
8176
- keyword: import_zod38.z.string().nullable().optional(),
8177
- tema: import_zod38.z.string().nullable().optional(),
8178
- productoId: import_zod38.z.string().nullable().optional(),
8179
- estado: import_zod38.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional(),
8180
- contenidoRef: import_zod38.z.string().nullable().optional(),
8181
- fotoIdAsignada: import_zod38.z.string().nullable().optional(),
8182
- notas: import_zod38.z.array(NotaCalendarioSchema).optional(),
8183
- locationId: import_zod38.z.string().nullable().optional().describe("GBP location ID \u2014 a qu\xE9 sucursal publicar el post"),
8184
- locationNombre: import_zod38.z.string().nullable().optional().describe("Nombre de la sucursal GBP (para UI)")
8185
- }).strict().describe("Campos del slot. Solo acepta campos validos del schema. Campos no reconocidos seran rechazados."),
8186
- accionContenidoExistente: import_zod38.z.union([
8187
- import_zod38.z.enum(["descartar", "nuevo_slot", "mantener"]),
8188
- import_zod38.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Formato: mover:semana:N:slot:M")
9412
+ brandId: import_zod49.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context."),
9413
+ mes: import_zod49.z.string().describe("Calendar month in YYYY-MM format."),
9414
+ semana: import_zod49.z.number().describe("Week number within the month (1-5)."),
9415
+ slotIndex: import_zod49.z.number().describe("Slot index (0-based). Must point to an existing slot \u2014 to add new slots use add_calendar_slot."),
9416
+ cambios: import_zod49.z.object({
9417
+ dia: import_zod49.z.string().nullable().optional().describe("Slot date in YYYY-MM-DD. OMIT if not changing date. Use null to explicitly clear."),
9418
+ plataforma: import_zod49.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional().describe("OMIT if not changing platform."),
9419
+ tipo: import_zod49.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional().describe("OMIT if not changing content type."),
9420
+ keyword: import_zod49.z.string().nullable().optional().describe("OMIT if not changing keyword."),
9421
+ tema: import_zod49.z.string().nullable().optional().describe("OMIT if not changing topic."),
9422
+ productoId: import_zod49.z.string().nullable().optional().describe("OMIT if not changing linked product. Use null to unlink."),
9423
+ estado: import_zod49.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe("OMIT if not changing status manually."),
9424
+ contenidoRef: import_zod49.z.string().nullable().optional().describe("OMIT \u2014 managed by the system, not by callers."),
9425
+ fotoIdAsignada: import_zod49.z.string().nullable().optional().describe("Use assign_photo_to_content for photos. OMIT here."),
9426
+ notas: import_zod49.z.array(NotaCalendarioSchema).optional().describe("Append-only notes for the slot."),
9427
+ locationId: import_zod49.z.string().nullable().optional().describe("GBP location ID \u2014 only when plataforma=gbp."),
9428
+ locationNombre: import_zod49.z.string().nullable().optional().describe("Human-readable GBP location name (for UI).")
9429
+ }).strict().describe("Fields to update on the slot. Only declared fields accepted; unknown fields are rejected. OMIT any field you are not changing."),
9430
+ accionContenidoExistente: import_zod49.z.union([
9431
+ import_zod49.z.enum(["descartar", "nuevo_slot", "mantener"]),
9432
+ import_zod49.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Format: mover:semana:N:slot:M")
8189
9433
  ]).optional().describe(
8190
- "Decision del tenant cuando el slot ya tiene un contenidoRef existente Y los cambios tocan keyword/tema/plataforma/tipo. Requerido en ese escenario (si no se pasa, el helper retorna ACCION_CONTENIDO_EXISTENTE_REQUIRED con las 4 opciones para que preguntes al tenant). Valores: 'descartar' (marca el contenido existente como descartado y aplica cambios al slot) | 'mover:semana:N:slot:M' (mueve el contenido existente al slot destino vacio y aplica cambios al slot origen) | 'nuevo_slot' (NO toca este slot ni su contenido; crea un slot nuevo el mismo dia con los cambios \u2014 util para multiples publicaciones/dia) | 'mantener' (conserva el contenido existente aqui y aplica los cambios igualmente \u2014 caso typo fix)."
9434
+ 'Required ONLY when the slot already has a contenidoRef AND the cambios touch semantic fields (keyword/tema/plataforma/tipo). In that case, the helper returns ACCION_CONTENIDO_EXISTENTE_REQUIRED with the 4 options listed below \u2014 ask the tenant which one to apply. OMIT this field in any other case. Do NOT send null. Valid values: "descartar" (mark existing content as discarded and apply changes to this slot) | "mover:semana:N:slot:M" (move existing content to target empty slot N/M and apply changes to origin slot) | "nuevo_slot" (do NOT touch this slot or its content; create a new slot same day with the changes \u2014 useful for multiple posts per day) | "mantener" (keep existing content here and apply changes anyway \u2014 typo-fix case where old content stays valid).'
8191
9435
  )
8192
9436
  },
8193
9437
  async ({ brandId: inputBrandId, mes, semana, slotIndex, cambios, accionContenidoExistente }) => {
8194
9438
  const tenantId = session.requireTenant();
8195
9439
  const brandId = inputBrandId ?? session.requireBrand();
8196
- const ctx = buildMartinContext(session, brandId);
9440
+ const ctx = await buildContext(session, brandId);
8197
9441
  const result = await dispatchWithContract({
8198
9442
  contract: calendarSlotUpdaterContract,
8199
- helper: calendarSlotUpdater,
9443
+ // Wrap en arrow para que TS infiera el tipo del input desde el
9444
+ // schema del contract (acepta `cambios: Record<string, unknown>`),
9445
+ // no desde la interface estricta CalendarSlotUpdaterInput.
9446
+ // El helper internamente acepta el shape — Zod ya valido en el wrapper.
9447
+ helper: (input) => calendarSlotUpdater({
9448
+ ...input,
9449
+ cambios: input.cambios,
9450
+ accionContenidoExistente: input.accionContenidoExistente
9451
+ }),
8200
9452
  callable: callCalendarSlotUpdater,
8201
9453
  input: { tenantId, brandId, mes, semana, slotIndex, cambios, accionContenidoExistente },
8202
9454
  ctx
@@ -8205,8 +9457,8 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
8205
9457
  ok: false,
8206
9458
  state: result.state,
8207
9459
  mensaje: result.text,
8208
- // HITO 6 A6.7: incluir detalles Zod estructurados para que
8209
- // Claude (LLM) pueda auto-recuperarse en el siguiente intento.
9460
+ // HITO 6 A6.7: include structured Zod details so
9461
+ // the LLM can self-recover on the next attempt.
8210
9462
  ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
8211
9463
  };
8212
9464
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
@@ -8222,22 +9474,35 @@ ESCRIBE EN DOS LUGARES:
8222
9474
 
8223
9475
  NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agenda, no en el post.`,
8224
9476
  {
8225
- contenidoRef: import_zod38.z.string().describe("ID del doc en marketing_contenido"),
8226
- fotoId: import_zod38.z.string().describe("ID de la foto en marketing_fotos (estado editada)"),
8227
- calendarioItemRef: import_zod38.z.string().optional().describe('Ref del slot (formato "semana:N:slot:M") para actualizar fotoIdAsignada')
9477
+ contenidoRef: import_zod49.z.string().describe("ID del doc en marketing_contenido"),
9478
+ fotoId: import_zod49.z.string().describe("ID de la foto en marketing_fotos (estado editada)"),
9479
+ calendarioItemRef: import_zod49.z.string().optional().describe('Ref del slot (formato "semana:N:slot:M") para actualizar fotoIdAsignada')
8228
9480
  },
8229
9481
  async ({ contenidoRef, fotoId, calendarioItemRef }) => {
8230
9482
  const tenantId = session.requireTenant();
8231
9483
  const brandId = session.requireBrand();
8232
- const result = getMode() === "admin" ? await photoAssigner({ db: getAdminDb(), tenantId, brandId, contenidoRef, fotoId, calendarioItemRef }) : await callPhotoAssigner({ tenantId, brandId, contenidoRef, fotoId, calendarioItemRef });
8233
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
9484
+ const ctx = await buildContext(session, brandId);
9485
+ const result = await dispatchWithContract({
9486
+ contract: photoAssignerContract,
9487
+ helper: photoAssigner,
9488
+ callable: callPhotoAssigner,
9489
+ input: { tenantId, brandId, contenidoRef, fotoId, calendarioItemRef },
9490
+ ctx
9491
+ });
9492
+ const payload = result.state === "success" ? result.structuredOutput : {
9493
+ ok: false,
9494
+ state: result.state,
9495
+ mensaje: result.text,
9496
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9497
+ };
9498
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
8234
9499
  }
8235
9500
  );
8236
9501
  server.tool(
8237
9502
  "approve_content",
8238
9503
  "Aprueba contenido para publicacion. Valida transicion de estado.",
8239
9504
  {
8240
- contenidoId: import_zod38.z.string().describe("ID del contenido a aprobar")
9505
+ contenidoId: import_zod49.z.string().describe("ID del contenido a aprobar")
8241
9506
  },
8242
9507
  async ({ contenidoId }) => {
8243
9508
  session.requireTenant();
@@ -8261,8 +9526,8 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8261
9526
  "reject_content",
8262
9527
  "Rechaza contenido con motivo. Valida transicion de estado.",
8263
9528
  {
8264
- contenidoId: import_zod38.z.string().describe("ID del contenido a rechazar"),
8265
- motivo: import_zod38.z.string().describe("Motivo del rechazo")
9529
+ contenidoId: import_zod49.z.string().describe("ID del contenido a rechazar"),
9530
+ motivo: import_zod49.z.string().describe("Motivo del rechazo")
8266
9531
  },
8267
9532
  async ({ contenidoId, motivo }) => {
8268
9533
  session.requireTenant();
@@ -8285,7 +9550,7 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8285
9550
  "get_collections",
8286
9551
  "Lee todas las colecciones canonicas (Shopify/WordPress/webonly) con sus 6 campos SEO actuales. Incluye best practices 2026.",
8287
9552
  {
8288
- brandId: import_zod38.z.string().optional().describe("ID de la brand")
9553
+ brandId: import_zod49.z.string().optional().describe("ID de la brand")
8289
9554
  },
8290
9555
  async ({ brandId: inputBrandId }) => {
8291
9556
  const tenantId = session.requireTenant();
@@ -8372,22 +9637,35 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8372
9637
  "save_collection_suggestions",
8373
9638
  "Guarda sugerencias SEO para colecciones de Shopify. El tenant las aprueba en la UI.",
8374
9639
  {
8375
- brandId: import_zod38.z.string().optional().describe("ID de la brand"),
9640
+ brandId: import_zod49.z.string().optional().describe("ID de la brand"),
8376
9641
  suggestions: CollectionSuggestionsInputArraySchema.describe("Array de sugerencias SEO. Cada una con max 60 chars en metaTitle, max 158 en metaDescription, max 125 en imageAlt")
8377
9642
  },
8378
9643
  async ({ brandId: inputBrandId, suggestions }) => {
8379
9644
  const tenantId = session.requireTenant();
8380
9645
  const brandId = inputBrandId ?? session.requireBrand();
8381
- const result = getMode() === "admin" ? await collectionSuggestionsWriter({ db: getAdminDb(), tenantId, brandId, suggestions }) : await callCollectionSuggestionsWriter({ tenantId, brandId, suggestions });
8382
- return { content: [{ type: "text", text: JSON.stringify(result) }] };
9646
+ const ctx = await buildContext(session, brandId);
9647
+ const result = await dispatchWithContract({
9648
+ contract: collectionSuggestionsWriterContract,
9649
+ helper: collectionSuggestionsWriter,
9650
+ callable: callCollectionSuggestionsWriter,
9651
+ input: { tenantId, brandId, suggestions },
9652
+ ctx
9653
+ });
9654
+ const payload = result.state === "success" ? result.structuredOutput : {
9655
+ ok: false,
9656
+ state: result.state,
9657
+ mensaje: result.text,
9658
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
9659
+ };
9660
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
8383
9661
  }
8384
9662
  );
8385
9663
  }
8386
9664
 
8387
9665
  // src/index.ts
8388
9666
  async function buildSystemPrompt2(session) {
8389
- const mode2 = getMode();
8390
- if (mode2 === "admin") {
9667
+ const mode = getSdkMode();
9668
+ if (mode === "admin") {
8391
9669
  const db = getAdminDb();
8392
9670
  return buildSystemPrompt({
8393
9671
  db,
@@ -8404,7 +9682,9 @@ async function buildSystemPrompt2(session) {
8404
9682
  async function main() {
8405
9683
  const authContext = resolveAuth();
8406
9684
  const session = new Session(authContext);
8407
- console.error(`[ponch-mcp] Modo: ${authContext.mode}`);
9685
+ console.error(
9686
+ `[ponch-mcp] Auth resuelto. canSwitchTenant=${authContext.canSwitchTenant} tenantId=${authContext.tenantId ?? "(none)"} rol=${authContext.rol ?? "(none)"}`
9687
+ );
8408
9688
  let firebaseReady = false;
8409
9689
  if (authContext.serviceAccountPath) {
8410
9690
  initFirebaseAdmin(authContext.serviceAccountPath);
@@ -8416,7 +9696,7 @@ async function main() {
8416
9696
  console.error(`[ponch-mcp] Firebase Client autenticado. Tenant: ${authContext.tenantId}`);
8417
9697
  } else {
8418
9698
  console.error("[ponch-mcp] Token expirado. Usa connect_account para reconectar.");
8419
- session.forceMode("tenant");
9699
+ session.revokeCrossTenant();
8420
9700
  }
8421
9701
  } else {
8422
9702
  console.error("[ponch-mcp] Sin conexion. Usa connect_account para conectar.");
@@ -8425,6 +9705,13 @@ async function main() {
8425
9705
  name: "ponch",
8426
9706
  version: "1.0.1"
8427
9707
  });
9708
+ server.server.oninitialized = () => {
9709
+ const ci = server.server.getClientVersion();
9710
+ if (ci) {
9711
+ session.setMcpClientIdentity(ci.name ?? null, ci.version ?? null);
9712
+ console.error(`[ponch-mcp] Cliente MCP: ${ci.name ?? "(sin name)"} v${ci.version ?? "(sin version)"}`);
9713
+ }
9714
+ };
8428
9715
  server.resource(
8429
9716
  "system-prompt",
8430
9717
  "ponch://system-prompt",