ponch-mcp-server 1.0.75 → 1.0.77

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()
@@ -2726,7 +2761,7 @@ function registerCoreTools(server, session) {
2726
2761
  }
2727
2762
 
2728
2763
  // src/tools/marketing.ts
2729
- var import_zod37 = require("zod");
2764
+ var import_zod38 = require("zod");
2730
2765
 
2731
2766
  // ../packages/marketing-business-logic/dist/index.js
2732
2767
  var import_firebase_admin2 = require("firebase-admin");
@@ -2734,23 +2769,29 @@ var import_firebase_admin3 = require("firebase-admin");
2734
2769
  var import_zod27 = require("zod");
2735
2770
  var import_zod28 = require("zod");
2736
2771
  var import_zod29 = require("zod");
2772
+ var import_fs3 = require("fs");
2773
+ var import_path2 = require("path");
2774
+ var import_url = require("url");
2737
2775
  var import_firebase_admin4 = require("firebase-admin");
2738
2776
  var import_firebase_admin5 = require("firebase-admin");
2739
2777
  var import_firebase_admin6 = require("firebase-admin");
2740
2778
  var import_zod30 = require("zod");
2741
- var import_zod31 = require("zod");
2742
2779
  var import_firebase_admin7 = require("firebase-admin");
2780
+ var import_zod31 = require("zod");
2743
2781
  var import_zod32 = require("zod");
2744
2782
  var import_firebase_admin8 = require("firebase-admin");
2783
+ var import_zod33 = require("zod");
2745
2784
  var import_firebase_admin9 = require("firebase-admin");
2785
+ var import_firebase_admin10 = require("firebase-admin");
2746
2786
  var import_firestore3 = require("firebase-admin/firestore");
2747
2787
  var import_firestore4 = require("firebase-admin/firestore");
2748
2788
  var import_firestore5 = require("firebase-admin/firestore");
2749
2789
  var import_firestore6 = require("firebase-admin/firestore");
2750
- var import_zod33 = require("zod");
2751
- var import_firestore7 = require("firebase-admin/firestore");
2752
2790
  var import_zod34 = require("zod");
2791
+ var import_firestore7 = require("firebase-admin/firestore");
2792
+ var import_zod35 = require("zod");
2753
2793
  var import_firestore8 = require("firebase-admin/firestore");
2794
+ var import_meta = {};
2754
2795
  var RULE_NEGATIVES = {
2755
2796
  allowFaces: "no people, no faces, no hands",
2756
2797
  allowProductTransform: "no distorted products, no warped objects",
@@ -3011,6 +3052,74 @@ var planWriter = {
3011
3052
  save,
3012
3053
  updateField
3013
3054
  };
3055
+ var _localeCache = {};
3056
+ function _resolveLocaleFile(locale) {
3057
+ let baseDir;
3058
+ try {
3059
+ baseDir = typeof __dirname !== "undefined" ? __dirname : (0, import_path2.dirname)((0, import_url.fileURLToPath)(import_meta.url));
3060
+ } catch {
3061
+ baseDir = process.cwd();
3062
+ }
3063
+ const candidates = [
3064
+ (0, import_path2.join)(baseDir, "..", "..", "..", "..", "src", "i18n", "locales", `${locale}.json`),
3065
+ (0, import_path2.join)(baseDir, "..", "..", "..", "src", "i18n", "locales", `${locale}.json`)
3066
+ ];
3067
+ for (const candidate of candidates) {
3068
+ try {
3069
+ (0, import_fs3.readFileSync)(candidate, "utf-8");
3070
+ return candidate;
3071
+ } catch {
3072
+ continue;
3073
+ }
3074
+ }
3075
+ throw new Error(
3076
+ `getMessage: locale file '${locale}.json' not found. Tried:
3077
+ ` + candidates.map((c) => ` - ${c}`).join("\n")
3078
+ );
3079
+ }
3080
+ function _loadLocale(locale) {
3081
+ if (_localeCache[locale]) return _localeCache[locale];
3082
+ const path2 = _resolveLocaleFile(locale);
3083
+ const raw = (0, import_fs3.readFileSync)(path2, "utf-8");
3084
+ const data = JSON.parse(raw);
3085
+ _localeCache[locale] = data;
3086
+ return data;
3087
+ }
3088
+ function _resolvePath(obj, path2) {
3089
+ const parts = path2.split(".");
3090
+ let current = obj;
3091
+ for (const part of parts) {
3092
+ if (current === null || typeof current !== "object") return null;
3093
+ current = current[part];
3094
+ }
3095
+ return typeof current === "string" ? current : null;
3096
+ }
3097
+ function _interpolate(template, vars, context) {
3098
+ return template.replace(/\{(\w+)\}/g, (_match, key) => {
3099
+ if (!vars || !(key in vars)) {
3100
+ throw new Error(
3101
+ `getMessage: variable '${key}' in template '${context.path}' (${context.locale}) not provided in vars. Pass { ${key}: '...' } when calling getMessage.`
3102
+ );
3103
+ }
3104
+ return vars[key];
3105
+ });
3106
+ }
3107
+ var SUPPORTED_LOCALES = ["es", "en"];
3108
+ function getMessage(path2, locale, vars) {
3109
+ if (!SUPPORTED_LOCALES.includes(locale)) {
3110
+ throw new Error(
3111
+ `getMessage: locale '${locale}' not supported. Active locales: ${SUPPORTED_LOCALES.join(", ")}.`
3112
+ );
3113
+ }
3114
+ const data = _loadLocale(locale);
3115
+ const template = _resolvePath(data, path2);
3116
+ if (template === null) {
3117
+ throw new Error(
3118
+ `getMessage: path '${path2}' not found in locale '${locale}'. Add it to src/i18n/locales/${locale}.json.`
3119
+ );
3120
+ }
3121
+ return _interpolate(template, vars, { path: path2, locale });
3122
+ }
3014
3123
  var DisabledReasonCodeSchema = import_zod29.z.enum([
3015
3124
  "paywall",
3016
3125
  // Acción requiere upgrade de plan
@@ -3036,44 +3145,8 @@ var DisabledOutputSchema = import_zod29.z.object({
3036
3145
  */
3037
3146
  detail: import_zod29.z.record(import_zod29.z.string(), import_zod29.z.string()).optional()
3038
3147
  });
3039
- var TEMPLATES = {
3040
- es: {
3041
- paywall: "Esta acci\xF3n requiere actualizar tu plan.",
3042
- oauth_missing: "Necesitas conectar {service} primero.",
3043
- data_missing: "Falta informaci\xF3n: {detail}",
3044
- plan_not_includes: "Tu plan no incluye {feature}",
3045
- feature_disabled: "Esta funci\xF3n est\xE1 deshabilitada para tu cuenta.",
3046
- other: "{detail}"
3047
- },
3048
- en: {
3049
- paywall: "This action requires upgrading your plan.",
3050
- oauth_missing: "You need to connect {service} first.",
3051
- data_missing: "Missing information: {detail}",
3052
- plan_not_includes: "Your plan doesn't include {feature}",
3053
- feature_disabled: "This feature is disabled for your account.",
3054
- other: "{detail}"
3055
- }
3056
- };
3057
3148
  function getDisabledMessage(code, locale, vars) {
3058
- if (!(locale in TEMPLATES)) {
3059
- throw new Error(
3060
- `Locale '${locale}' no soportado en disabledMessages. Locales activos: ${Object.keys(TEMPLATES).join(", ")}.`
3061
- );
3062
- }
3063
- const template = TEMPLATES[locale][code];
3064
- if (!template) {
3065
- throw new Error(
3066
- `Disabled code '${code}' no tiene template definido para locale '${locale}'.`
3067
- );
3068
- }
3069
- return template.replace(/\{(\w+)\}/g, (_match, key) => {
3070
- if (!vars || !(key in vars)) {
3071
- throw new Error(
3072
- `getDisabledMessage: variable '${key}' del template '${code}' (${locale}) no provista en vars. Helper debe pasar { ${key}: '...' } en detail.`
3073
- );
3074
- }
3075
- return vars[key];
3076
- });
3149
+ return getMessage(`marketing.disabled.${code}`, locale, vars);
3077
3150
  }
3078
3151
  var SideEffectEnum = import_zod28.z.enum([
3079
3152
  "reads_firestore",
@@ -3105,6 +3178,10 @@ var ExtractTargetPathSchema = import_zod28.z.custom((val) => typeof val === "fun
3105
3178
  var ExtractChangesSchema = import_zod28.z.custom((val) => typeof val === "function", {
3106
3179
  message: "extractChanges debe ser funci\xF3n (input, output) => { before, after }"
3107
3180
  });
3181
+ var InputPredicateSchema = import_zod28.z.custom(
3182
+ (val) => typeof val === "function",
3183
+ { message: "predicado debe ser funci\xF3n (input) => boolean" }
3184
+ );
3108
3185
  var MartinContractSchema = import_zod28.z.object({
3109
3186
  // Schema versioning (Dim 4 backbone)
3110
3187
  schemaVersion: import_zod28.z.literal(1).default(1),
@@ -3120,6 +3197,16 @@ var MartinContractSchema = import_zod28.z.object({
3120
3197
  destructive: import_zod28.z.boolean(),
3121
3198
  affectsPublication: import_zod28.z.boolean(),
3122
3199
  affectsExternal: import_zod28.z.boolean(),
3200
+ /**
3201
+ * Override runtime del flag `destructive`. Si el predicado retorna
3202
+ * true para un input específico, el wrapper trata la invocación
3203
+ * como destructiva aun si `destructive: false`. Útil cuando una
3204
+ * misma tool tiene 2 modos según args (ej. update con
3205
+ * accionContenidoExistente='descartar').
3206
+ */
3207
+ isDestructiveForInput: InputPredicateSchema.optional(),
3208
+ /** Mismo patrón para affectsPublication (override runtime). */
3209
+ isPublicationForInput: InputPredicateSchema.optional(),
3123
3210
  // Presentación al usuario (i18n)
3124
3211
  martinSummaryTemplate: SummaryTemplateSchema,
3125
3212
  martinConfirmationTemplate: ConfirmationTemplateSchema.optional(),
@@ -3260,7 +3347,7 @@ var OutputSchema = import_zod27.z.discriminatedUnion("ok", [
3260
3347
  ]);
3261
3348
  var rawContract = {
3262
3349
  name: "save_marketing_plan",
3263
- description: "Guarda el plan de marketing de una brand. Si el plan incluye blogStrategy, la extrae al nivel brand (no dentro de plan).",
3350
+ 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).",
3264
3351
  paramsSchema: ParamsSchema,
3265
3352
  outputSchema: OutputSchema,
3266
3353
  // No es destructivo (sobrescribe plan completo, pero plan se regenera fácil),
@@ -3506,6 +3593,95 @@ async function contenidoUpdater(input) {
3506
3593
  camposActualizados: Object.keys(update).filter((k) => k !== "updatedAt")
3507
3594
  };
3508
3595
  }
3596
+ async function addCalendarSlot(input) {
3597
+ const { db, tenantId, brandId, mes, semana, slot } = input;
3598
+ const calQuery = await db.collection("tenants").doc(tenantId).collection("marketing_calendario").where("brandId", "==", brandId).where("mes", "==", mes).limit(1).get();
3599
+ if (calQuery.empty) {
3600
+ return { ok: false, error: `Calendario ${mes} no encontrado para brand ${brandId}` };
3601
+ }
3602
+ const calDocRef = calQuery.docs[0].ref;
3603
+ let resultSlotIndex = -1;
3604
+ await db.runTransaction(async (tx) => {
3605
+ const freshSnap = await tx.get(calDocRef);
3606
+ if (!freshSnap.exists) {
3607
+ throw new Error(`addCalendarSlot: calendario ${calDocRef.id} desapareci\xF3`);
3608
+ }
3609
+ const freshCal = freshSnap.data();
3610
+ const freshSemanas = freshCal.semanas ?? [];
3611
+ const freshSemana = freshSemanas[semana - 1];
3612
+ if (!freshSemana) {
3613
+ throw new Error(`Semana ${semana} no existe en el calendario ${mes}`);
3614
+ }
3615
+ const freshItems = freshSemana.items ?? [];
3616
+ freshItems.push({
3617
+ ...slot,
3618
+ estado: slot.estado ?? ESTADO_CALENDARIO_ITEM.PLANIFICADO
3619
+ });
3620
+ resultSlotIndex = freshItems.length - 1;
3621
+ freshSemana.items = freshItems;
3622
+ freshSemanas[semana - 1] = freshSemana;
3623
+ tx.update(calDocRef, {
3624
+ semanas: freshSemanas,
3625
+ updatedAt: import_firebase_admin6.firestore.FieldValue.serverTimestamp()
3626
+ });
3627
+ });
3628
+ return { ok: true, slotIndex: resultSlotIndex };
3629
+ }
3630
+ var SlotSchema = import_zod30.z.object({
3631
+ dia: import_zod30.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Formato YYYY-MM-DD"),
3632
+ plataforma: import_zod30.z.enum(["gbp", "shopify_blog", "instagram", "review"]),
3633
+ tipo: import_zod30.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]),
3634
+ keyword: import_zod30.z.string().min(1),
3635
+ tema: import_zod30.z.string().optional(),
3636
+ productoId: import_zod30.z.string().optional(),
3637
+ estado: import_zod30.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional(),
3638
+ locationId: import_zod30.z.string().optional(),
3639
+ locationNombre: import_zod30.z.string().optional()
3640
+ });
3641
+ var ParamsSchema2 = import_zod30.z.object({
3642
+ tenantId: import_zod30.z.string().min(1),
3643
+ brandId: import_zod30.z.string().min(1),
3644
+ mes: import_zod30.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM"),
3645
+ semana: import_zod30.z.number().int().min(1).max(5),
3646
+ slot: SlotSchema
3647
+ });
3648
+ var OutputSchema2 = import_zod30.z.discriminatedUnion("ok", [
3649
+ import_zod30.z.object({ ok: import_zod30.z.literal(true), slotIndex: import_zod30.z.number().int() }),
3650
+ import_zod30.z.object({ ok: import_zod30.z.literal(false), error: import_zod30.z.string() })
3651
+ ]);
3652
+ var rawContract2 = {
3653
+ name: "add_calendar_slot",
3654
+ description: "Add a new slot to the editorial calendar. Does NOT modify existing slots \u2014 use update_calendar_slot for that.",
3655
+ paramsSchema: ParamsSchema2,
3656
+ outputSchema: OutputSchema2,
3657
+ requiresConfirmation: false,
3658
+ destructive: false,
3659
+ affectsPublication: false,
3660
+ affectsExternal: false,
3661
+ martinSummaryTemplate: (input, output, locale) => {
3662
+ if (!output.ok) {
3663
+ return locale === "en" ? `I couldn't add the slot: ${output.error}` : `No pude agregar el slot: ${output.error}`;
3664
+ }
3665
+ 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}.`;
3666
+ },
3667
+ auditAction: "marketing.calendario.slot.agregar",
3668
+ extractTargetPath: (input, output) => {
3669
+ if (!output.ok) return "";
3670
+ return `tenants/${input.tenantId}/marketing_calendario/(brand=${input.brandId},mes=${input.mes}).semana=${input.semana}.slot=${output.slotIndex}`;
3671
+ },
3672
+ extractChanges: (input, output) => ({
3673
+ before: null,
3674
+ after: output.ok ? input.slot : null
3675
+ }),
3676
+ quotasConsumed: [],
3677
+ permissionScope: "module",
3678
+ permissionKey: "marketing",
3679
+ permissionAction: "editar",
3680
+ sideEffects: ["writes_firestore", "updates_calendar_slot"]
3681
+ };
3682
+ var addCalendarSlotContract = MartinContractSchema.parse(
3683
+ rawContract2
3684
+ );
3509
3685
  var CAMPOS_SEMANTICOS = ["keyword", "tema", "plataforma", "tipo"];
3510
3686
  function mapContenidoEstadoToSlotEstado(contenidoEstado) {
3511
3687
  switch (contenidoEstado) {
@@ -3542,26 +3718,24 @@ async function calendarSlotUpdater(input) {
3542
3718
  return { ok: false, error: `Semana ${semana} no existe` };
3543
3719
  }
3544
3720
  const items = semanaData.items ?? [];
3545
- const isNewSlot = slotIndex >= items.length;
3721
+ if (slotIndex >= items.length) {
3722
+ return {
3723
+ ok: false,
3724
+ error: `slotIndex=${slotIndex} >= items.length=${items.length} for week=${semana}`,
3725
+ code: "SLOT_NOT_FOUND"
3726
+ };
3727
+ }
3546
3728
  const tocaSemantica = CAMPOS_SEMANTICOS.some(
3547
3729
  (campo) => campo in cambios && cambios[campo] !== void 0
3548
3730
  );
3549
- if (isNewSlot) {
3550
- return handleAddSlot({ db, calDocRef, semana, cambios });
3551
- }
3552
3731
  const slotAnterior = items[slotIndex];
3553
3732
  const oldContenidoRef = slotAnterior.contenidoRef ?? null;
3554
3733
  if (oldContenidoRef && tocaSemantica && !accionContenidoExistente) {
3555
3734
  return {
3556
3735
  ok: false,
3557
- 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.`,
3736
+ error: `slot has contenidoRef=${oldContenidoRef} and cambios touch semantic fields=[${CAMPOS_SEMANTICOS.filter((f) => f in cambios).join(",")}]; require accionContenidoExistente`,
3558
3737
  code: "ACCION_CONTENIDO_EXISTENTE_REQUIRED",
3559
- opciones: [
3560
- `'descartar' \u2014 marcar "${oldContenidoRef}" como descartado y aplicar cambios en este slot`,
3561
- `'mover:semana:N:slot:M' \u2014 mover "${oldContenidoRef}" al slot destino N/M (target debe estar vacio) y aplicar cambios aqui`,
3562
- `'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)`,
3563
- `'mantener' \u2014 mantener "${oldContenidoRef}" aqui y aplicar cambios (caso typo fix, el contenido viejo sigue valido para el nuevo keyword/tema)`
3564
- ]
3738
+ opciones: ["descartar", "mover", "nuevo_slot", "mantener"]
3565
3739
  };
3566
3740
  }
3567
3741
  let contenidosADescartar = [];
@@ -3581,7 +3755,7 @@ async function calendarSlotUpdater(input) {
3581
3755
  if (!m) {
3582
3756
  return {
3583
3757
  ok: false,
3584
- error: `accionContenidoExistente "${accionContenidoExistente}" invalido. Formato: mover:semana:N:slot:M`,
3758
+ error: `accionContenidoExistente="${accionContenidoExistente}" invalid format (expected: mover:semana:N:slot:M)`,
3585
3759
  code: "MOVE_TARGET_INVALID"
3586
3760
  };
3587
3761
  }
@@ -3590,7 +3764,7 @@ async function calendarSlotUpdater(input) {
3590
3764
  if (targetSemanaNum === semana && targetSlotIdx === slotIndex) {
3591
3765
  return {
3592
3766
  ok: false,
3593
- error: "mover al mismo slot origen no tiene sentido. Usa 'mantener' si quieres conservar el contenido aqui.",
3767
+ error: "move target same as origin (semantic noop)",
3594
3768
  code: "MOVE_TARGET_INVALID"
3595
3769
  };
3596
3770
  }
@@ -3598,7 +3772,7 @@ async function calendarSlotUpdater(input) {
3598
3772
  if (!targetSemana) {
3599
3773
  return {
3600
3774
  ok: false,
3601
- error: `Target semana ${targetSemanaNum} no existe en el calendario`,
3775
+ error: `target semana=${targetSemanaNum} does not exist`,
3602
3776
  code: "MOVE_TARGET_INVALID"
3603
3777
  };
3604
3778
  }
@@ -3606,14 +3780,14 @@ async function calendarSlotUpdater(input) {
3606
3780
  if (!targetItems[targetSlotIdx]) {
3607
3781
  return {
3608
3782
  ok: false,
3609
- error: `Target slot ${targetSlotIdx} no existe en semana ${targetSemanaNum}`,
3783
+ error: `target slot=${targetSlotIdx} does not exist in semana=${targetSemanaNum}`,
3610
3784
  code: "MOVE_TARGET_INVALID"
3611
3785
  };
3612
3786
  }
3613
3787
  if (targetItems[targetSlotIdx].contenidoRef) {
3614
3788
  return {
3615
3789
  ok: false,
3616
- error: `Target slot ya tiene contenidoRef "${targetItems[targetSlotIdx].contenidoRef}". No se sobrescribe para evitar perder contenido ajeno. Elige otro slot o usa 'descartar'.`,
3790
+ error: `target slot already has contenidoRef=${targetItems[targetSlotIdx].contenidoRef}`,
3617
3791
  code: "MOVE_TARGET_OCCUPIED"
3618
3792
  };
3619
3793
  }
@@ -3694,7 +3868,7 @@ async function calendarSlotUpdater(input) {
3694
3868
  freshSemanas[semana - 1] = freshSemana;
3695
3869
  tx.update(calDocRef, {
3696
3870
  semanas: freshSemanas,
3697
- updatedAt: import_firebase_admin6.firestore.FieldValue.serverTimestamp()
3871
+ updatedAt: import_firebase_admin7.firestore.FieldValue.serverTimestamp()
3698
3872
  });
3699
3873
  });
3700
3874
  if (moveTarget && oldContenidoRef) {
@@ -3715,7 +3889,7 @@ async function calendarSlotUpdater(input) {
3715
3889
  await c.ref.update({
3716
3890
  estado: "descartado",
3717
3891
  rechazadoMotivo: "Slot del calendario fue modificado",
3718
- rechazadoAt: import_firebase_admin6.firestore.FieldValue.serverTimestamp()
3892
+ rechazadoAt: import_firebase_admin7.firestore.FieldValue.serverTimestamp()
3719
3893
  });
3720
3894
  descartados++;
3721
3895
  } catch (err) {
@@ -3733,35 +3907,6 @@ async function calendarSlotUpdater(input) {
3733
3907
  ...movedTo ? { movedTo } : {}
3734
3908
  };
3735
3909
  }
3736
- async function handleAddSlot(args) {
3737
- const { db, calDocRef, semana, cambios } = args;
3738
- let resultSlotIndex = -1;
3739
- await db.runTransaction(async (tx) => {
3740
- const freshSnap = await tx.get(calDocRef);
3741
- if (!freshSnap.exists) {
3742
- throw new Error(`handleAddSlot: calendario ${calDocRef.id} desaparecio`);
3743
- }
3744
- const freshCal = freshSnap.data();
3745
- const freshSemanas = freshCal.semanas ?? [];
3746
- const freshSemana = freshSemanas[semana - 1];
3747
- if (!freshSemana) {
3748
- throw new Error(`handleAddSlot: semana ${semana} desaparecio`);
3749
- }
3750
- const freshItems = freshSemana.items ?? [];
3751
- freshItems.push({
3752
- ...cambios,
3753
- estado: cambios.estado ?? ESTADO_CALENDARIO_ITEM.PLANIFICADO
3754
- });
3755
- resultSlotIndex = freshItems.length - 1;
3756
- freshSemana.items = freshItems;
3757
- freshSemanas[semana - 1] = freshSemana;
3758
- tx.update(calDocRef, {
3759
- semanas: freshSemanas,
3760
- updatedAt: import_firebase_admin6.firestore.FieldValue.serverTimestamp()
3761
- });
3762
- });
3763
- return { ok: true, action: "added", slotIndex: resultSlotIndex, descartados: 0 };
3764
- }
3765
3910
  async function handleNuevoSlot(args) {
3766
3911
  const { db, calDocRef, semana, cambios, currentSlot } = args;
3767
3912
  const dia = cambios.dia ?? currentSlot.dia ?? null;
@@ -3788,7 +3933,7 @@ async function handleNuevoSlot(args) {
3788
3933
  freshSemanas[semana - 1] = freshSemana;
3789
3934
  tx.update(calDocRef, {
3790
3935
  semanas: freshSemanas,
3791
- updatedAt: import_firebase_admin6.firestore.FieldValue.serverTimestamp()
3936
+ updatedAt: import_firebase_admin7.firestore.FieldValue.serverTimestamp()
3792
3937
  });
3793
3938
  });
3794
3939
  return {
@@ -3798,47 +3943,53 @@ async function handleNuevoSlot(args) {
3798
3943
  descartados: 0
3799
3944
  };
3800
3945
  }
3801
- var ParamsSchema2 = import_zod30.z.object({
3802
- tenantId: import_zod30.z.string().min(1),
3803
- brandId: import_zod30.z.string().min(1),
3804
- mes: import_zod30.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM"),
3805
- semana: import_zod30.z.number().int().min(1).max(5),
3806
- slotIndex: import_zod30.z.number().int().min(0),
3807
- cambios: import_zod30.z.record(import_zod30.z.string(), import_zod30.z.unknown()),
3808
- accionContenidoExistente: import_zod30.z.string().optional()
3946
+ var ParamsSchema3 = import_zod31.z.object({
3947
+ tenantId: import_zod31.z.string().min(1),
3948
+ brandId: import_zod31.z.string().min(1),
3949
+ mes: import_zod31.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM"),
3950
+ semana: import_zod31.z.number().int().min(1).max(5),
3951
+ slotIndex: import_zod31.z.number().int().min(0),
3952
+ cambios: import_zod31.z.record(import_zod31.z.string(), import_zod31.z.unknown()),
3953
+ accionContenidoExistente: import_zod31.z.string().optional()
3809
3954
  });
3810
- var OutputSchema2 = import_zod30.z.discriminatedUnion("ok", [
3811
- import_zod30.z.object({
3812
- ok: import_zod30.z.literal(true),
3813
- action: import_zod30.z.enum(["added", "updated", "nuevo_slot", "moved"]),
3814
- slotIndex: import_zod30.z.number().int(),
3815
- descartados: import_zod30.z.number().int(),
3816
- movedTo: import_zod30.z.string().optional()
3955
+ var OutputSchema3 = import_zod31.z.discriminatedUnion("ok", [
3956
+ import_zod31.z.object({
3957
+ ok: import_zod31.z.literal(true),
3958
+ action: import_zod31.z.enum(["updated", "nuevo_slot", "moved"]),
3959
+ slotIndex: import_zod31.z.number().int(),
3960
+ descartados: import_zod31.z.number().int(),
3961
+ movedTo: import_zod31.z.string().optional()
3817
3962
  }),
3818
- import_zod30.z.object({
3819
- ok: import_zod30.z.literal(false),
3820
- error: import_zod30.z.string(),
3821
- code: import_zod30.z.enum(["ACCION_CONTENIDO_EXISTENTE_REQUIRED", "MOVE_TARGET_OCCUPIED", "MOVE_TARGET_INVALID"]).optional(),
3822
- opciones: import_zod30.z.array(import_zod30.z.string()).optional()
3963
+ import_zod31.z.object({
3964
+ ok: import_zod31.z.literal(false),
3965
+ error: import_zod31.z.string(),
3966
+ code: import_zod31.z.enum(["SLOT_NOT_FOUND", "ACCION_CONTENIDO_EXISTENTE_REQUIRED", "MOVE_TARGET_OCCUPIED", "MOVE_TARGET_INVALID"]).optional(),
3967
+ opciones: import_zod31.z.array(import_zod31.z.string()).optional()
3823
3968
  })
3824
3969
  ]);
3825
- var rawContract2 = {
3970
+ var rawContract3 = {
3826
3971
  name: "update_calendar_slot",
3827
- description: "Modifica o agrega un slot del calendario editorial. Si slotIndex >= items.length agrega un slot nuevo. 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.",
3828
- paramsSchema: ParamsSchema2,
3829
- outputSchema: OutputSchema2,
3972
+ 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.",
3973
+ paramsSchema: ParamsSchema3,
3974
+ outputSchema: OutputSchema3,
3975
+ // Por default, modificar un slot es reversible (cambias campos cosméticos).
3976
+ // PERO si accionContenidoExistente='descartar', el helper marca el
3977
+ // contenido vinculado como descartado — eso SÍ es destructivo. El predicado
3978
+ // runtime `isDestructiveForInput` lo detecta y obliga al wrapper a pedir
3979
+ // confirmación en ese caso específico.
3830
3980
  requiresConfirmation: false,
3831
3981
  destructive: false,
3832
- // Mutación, pero reversible (puede deshacerse cambiando el slot).
3982
+ isDestructiveForInput: (input) => input.accionContenidoExistente === "descartar",
3833
3983
  affectsPublication: false,
3834
3984
  affectsExternal: false,
3835
3985
  martinSummaryTemplate: (input, output, locale) => {
3836
3986
  if (!output.ok) {
3837
- if (locale === "en") return `I couldn't update the slot: ${output.error}`;
3838
- return `No pude actualizar el slot: ${output.error}`;
3987
+ if (output.code) {
3988
+ return getMessage(`marketing.errors.${output.code}`, locale);
3989
+ }
3990
+ return getMessage("marketing.safeError.generic", locale);
3839
3991
  }
3840
3992
  const verb = {
3841
- added: locale === "en" ? "added" : "agregu\xE9",
3842
3993
  updated: locale === "en" ? "updated" : "actualic\xE9",
3843
3994
  nuevo_slot: locale === "en" ? "created a new slot" : "cre\xE9 un slot nuevo",
3844
3995
  moved: locale === "en" ? "moved" : "mov\xED"
@@ -3848,6 +3999,15 @@ var rawContract2 = {
3848
3999
  }
3849
4000
  return `${verb[0].toUpperCase() + verb.slice(1)} el slot en la semana ${input.semana} (${input.mes}).`;
3850
4001
  },
4002
+ // Confirmation message when isDestructiveForInput returns true
4003
+ // (accionContenidoExistente === 'descartar'). Only invoked if the wrapper
4004
+ // detects the destructive runtime case and asks the user for OK.
4005
+ martinConfirmationTemplate: (input, locale) => {
4006
+ if (locale === "en") {
4007
+ 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.`;
4008
+ }
4009
+ 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.`;
4010
+ },
3851
4011
  auditAction: "marketing.calendario.slot.actualizar",
3852
4012
  extractTargetPath: (input) => `tenants/${input.tenantId}/marketing_calendario/(brand=${input.brandId},mes=${input.mes}).semana=${input.semana}.slot=${input.slotIndex}`,
3853
4013
  extractChanges: (input, output) => ({
@@ -3868,7 +4028,7 @@ var rawContract2 = {
3868
4028
  sideEffects: ["writes_firestore", "updates_calendar_slot"]
3869
4029
  };
3870
4030
  var calendarSlotUpdaterContract = MartinContractSchema.parse(
3871
- rawContract2
4031
+ rawContract3
3872
4032
  );
3873
4033
  async function getCalendar(input) {
3874
4034
  const { db, tenantId, brandId, mes } = input;
@@ -3890,23 +4050,23 @@ async function getCalendar(input) {
3890
4050
  calendario: { id: doc.id, ...doc.data() }
3891
4051
  };
3892
4052
  }
3893
- var ParamsSchema3 = import_zod31.z.object({
3894
- tenantId: import_zod31.z.string().min(1),
3895
- brandId: import_zod31.z.string().min(1),
3896
- mes: import_zod31.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM")
4053
+ var ParamsSchema4 = import_zod32.z.object({
4054
+ tenantId: import_zod32.z.string().min(1),
4055
+ brandId: import_zod32.z.string().min(1),
4056
+ mes: import_zod32.z.string().regex(/^\d{4}-\d{2}$/, "Formato YYYY-MM")
3897
4057
  });
3898
- var OutputSchema3 = import_zod31.z.object({
3899
- ok: import_zod31.z.literal(true),
3900
- mes: import_zod31.z.string(),
3901
- brandId: import_zod31.z.string(),
3902
- calendario: import_zod31.z.record(import_zod31.z.string(), import_zod31.z.unknown()).nullable(),
3903
- mensaje: import_zod31.z.string().optional()
4058
+ var OutputSchema4 = import_zod32.z.object({
4059
+ ok: import_zod32.z.literal(true),
4060
+ mes: import_zod32.z.string(),
4061
+ brandId: import_zod32.z.string(),
4062
+ calendario: import_zod32.z.record(import_zod32.z.string(), import_zod32.z.unknown()).nullable(),
4063
+ mensaje: import_zod32.z.string().optional()
3904
4064
  });
3905
- var rawContract3 = {
4065
+ var rawContract4 = {
3906
4066
  name: "get_calendar",
3907
- description: "Lee el calendario editorial del mes para una brand. Retorna semanas con items planificados por plataforma.",
3908
- paramsSchema: ParamsSchema3,
3909
- outputSchema: OutputSchema3,
4067
+ description: "Read the editorial calendar for a given brand and month. Returns weeks with planned items per platform.",
4068
+ paramsSchema: ParamsSchema4,
4069
+ outputSchema: OutputSchema4,
3910
4070
  requiresConfirmation: false,
3911
4071
  destructive: false,
3912
4072
  affectsPublication: false,
@@ -3928,7 +4088,7 @@ var rawContract3 = {
3928
4088
  sideEffects: ["reads_firestore"]
3929
4089
  };
3930
4090
  var getCalendarContract = MartinContractSchema.parse(
3931
- rawContract3
4091
+ rawContract4
3932
4092
  );
3933
4093
  var PLATAFORMA_A_FORMATO = {
3934
4094
  gbp: "gbp_4_3",
@@ -3966,12 +4126,12 @@ async function photoAssigner(input) {
3966
4126
  mediaVariante: {
3967
4127
  url: varianteUrl,
3968
4128
  formato,
3969
- linkedAt: import_firebase_admin7.firestore.FieldValue.serverTimestamp(),
4129
+ linkedAt: import_firebase_admin8.firestore.FieldValue.serverTimestamp(),
3970
4130
  permalink: null,
3971
4131
  // lo llenara publishToInstagram/etc en el futuro
3972
4132
  mediaExternalId: null
3973
4133
  },
3974
- editadoAt: import_firebase_admin7.firestore.FieldValue.serverTimestamp()
4134
+ editadoAt: import_firebase_admin8.firestore.FieldValue.serverTimestamp()
3975
4135
  };
3976
4136
  let slotResolved = null;
3977
4137
  if (calendarioItemRef) {
@@ -4021,7 +4181,7 @@ async function photoAssigner(input) {
4021
4181
  tx.update(contenidoRefDoc, contenidoUpdatePayload);
4022
4182
  tx.update(calDocRef, {
4023
4183
  semanas,
4024
- updatedAt: import_firebase_admin7.firestore.FieldValue.serverTimestamp()
4184
+ updatedAt: import_firebase_admin8.firestore.FieldValue.serverTimestamp()
4025
4185
  });
4026
4186
  });
4027
4187
  } else {
@@ -4223,26 +4383,26 @@ var BRAND_BRIEF_SCHEMA_HINT = {
4223
4383
  escenas: [{ id: string, nombre: string, promptHint: string }]
4224
4384
  }`
4225
4385
  };
4226
- var ParamsSchema4 = import_zod32.z.object({
4227
- tenantId: import_zod32.z.string().min(1),
4228
- brandId: import_zod32.z.string().min(1)
4386
+ var ParamsSchema5 = import_zod33.z.object({
4387
+ tenantId: import_zod33.z.string().min(1),
4388
+ brandId: import_zod33.z.string().min(1)
4229
4389
  });
4230
- var OutputSchema4 = import_zod32.z.discriminatedUnion("ok", [
4231
- import_zod32.z.object({
4232
- ok: import_zod32.z.literal(true),
4390
+ var OutputSchema5 = import_zod33.z.discriminatedUnion("ok", [
4391
+ import_zod33.z.object({
4392
+ ok: import_zod33.z.literal(true),
4233
4393
  /** Payload con instrucción + datos del negocio para que Claude genere el brief. */
4234
- payload: import_zod32.z.record(import_zod32.z.string(), import_zod32.z.unknown())
4394
+ payload: import_zod33.z.record(import_zod33.z.string(), import_zod33.z.unknown())
4235
4395
  }),
4236
- import_zod32.z.object({
4237
- ok: import_zod32.z.literal(false),
4238
- error: import_zod32.z.string()
4396
+ import_zod33.z.object({
4397
+ ok: import_zod33.z.literal(false),
4398
+ error: import_zod33.z.string()
4239
4399
  })
4240
4400
  ]);
4241
- var rawContract4 = {
4401
+ var rawContract5 = {
4242
4402
  name: "generate_brand_brief",
4243
- 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.",
4244
- paramsSchema: ParamsSchema4,
4245
- outputSchema: OutputSchema4,
4403
+ 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.",
4404
+ paramsSchema: ParamsSchema5,
4405
+ outputSchema: OutputSchema5,
4246
4406
  // Lectura pura, sin side effects de escritura ni publicación.
4247
4407
  requiresConfirmation: false,
4248
4408
  destructive: false,
@@ -4265,7 +4425,7 @@ var rawContract4 = {
4265
4425
  sideEffects: ["reads_firestore"]
4266
4426
  };
4267
4427
  var brandBriefBuilderContract = MartinContractSchema.parse(
4268
- rawContract4
4428
+ rawContract5
4269
4429
  );
4270
4430
  async function resolveLastImportId(db, tenantId, brandId) {
4271
4431
  const configSnap = await db.collection("tenants").doc(tenantId).collection("marketing_config").doc(brandId).get();
@@ -4509,7 +4669,7 @@ async function contenidoWriter(input) {
4509
4669
  await db.doc(`tenants/${tenantId}/marketing_contenido/${existente.id}`).update({
4510
4670
  estado: "descartado",
4511
4671
  rechazadoMotivo: "Reemplazado por contenido nuevo para el mismo slot",
4512
- rechazadoAt: import_firebase_admin8.firestore.FieldValue.serverTimestamp()
4672
+ rechazadoAt: import_firebase_admin9.firestore.FieldValue.serverTimestamp()
4513
4673
  });
4514
4674
  descartados++;
4515
4675
  }
@@ -4568,7 +4728,7 @@ async function contenidoWriter(input) {
4568
4728
  mediaUrl: null,
4569
4729
  calendarioItemRef: calendarioItemRef ?? null,
4570
4730
  datos,
4571
- creadoAt: import_firebase_admin8.firestore.FieldValue.serverTimestamp(),
4731
+ creadoAt: import_firebase_admin9.firestore.FieldValue.serverTimestamp(),
4572
4732
  creadoPorId: "mcp-cowork",
4573
4733
  origen: "ai_assisted"
4574
4734
  });
@@ -4626,7 +4786,7 @@ async function contenidoWriter(input) {
4626
4786
  tx.create(contenidoRef, contenido);
4627
4787
  tx.update(calDocRef, {
4628
4788
  semanas,
4629
- updatedAt: import_firebase_admin8.firestore.FieldValue.serverTimestamp()
4789
+ updatedAt: import_firebase_admin9.firestore.FieldValue.serverTimestamp()
4630
4790
  });
4631
4791
  });
4632
4792
  } else {
@@ -4744,7 +4904,7 @@ async function weeklyContentBuilder(input) {
4744
4904
  brandId,
4745
4905
  mes: targetMes,
4746
4906
  semanas,
4747
- creadoAt: import_firebase_admin9.firestore.FieldValue.serverTimestamp(),
4907
+ creadoAt: import_firebase_admin10.firestore.FieldValue.serverTimestamp(),
4748
4908
  creadoPorId: "mcp-cowork",
4749
4909
  updatedAt: null
4750
4910
  };
@@ -4996,7 +5156,7 @@ function diversify(items, jaccardThreshold = 0.6) {
4996
5156
  }
4997
5157
  async function contentFinder(input) {
4998
5158
  const { db, tenantId, brandId, contexto, fecha, include, limit, diversidad, deps } = input;
4999
- const mode2 = input.mode ?? "text";
5159
+ const mode = input.mode ?? "text";
5000
5160
  const textThreshold = deps.thresholds?.text ?? DEFAULT_TEXT_THRESHOLD;
5001
5161
  const imageThreshold = deps.thresholds?.image ?? DEFAULT_IMAGE_THRESHOLD;
5002
5162
  const conflictThreshold = deps.thresholds?.conflictSimilarity ?? DEFAULT_CONFLICT_THRESHOLD;
@@ -5027,7 +5187,7 @@ async function contentFinder(input) {
5027
5187
  queryEmbedding: queryEmbedding ?? void 0,
5028
5188
  queryText
5029
5189
  };
5030
- if (mode2 === "text") {
5190
+ if (mode === "text") {
5031
5191
  return safeSearch(true, {
5032
5192
  ...baseParams,
5033
5193
  limit: lim,
@@ -6256,6 +6416,90 @@ async function checkQuota(tenantId, quotaName) {
6256
6416
  async function getCurrentUsage(_tenantId, _quotaName) {
6257
6417
  return 0;
6258
6418
  }
6419
+ var NIVELES_CANONICOS = [
6420
+ "ninguno",
6421
+ "ver",
6422
+ "editar",
6423
+ "completo"
6424
+ ];
6425
+ var CACHE_TTL_MS = 60 * 1e3;
6426
+ var _rolesCache = /* @__PURE__ */ new Map();
6427
+ var _userCache = /* @__PURE__ */ new Map();
6428
+ function _getCached(map, key) {
6429
+ const entry = map.get(key);
6430
+ if (!entry) return void 0;
6431
+ if (Date.now() - entry.t > CACHE_TTL_MS) {
6432
+ map.delete(key);
6433
+ return void 0;
6434
+ }
6435
+ return entry.data;
6436
+ }
6437
+ function _setCached(map, key, data) {
6438
+ map.set(key, { t: Date.now(), data });
6439
+ }
6440
+ async function _readRolesDoc(db, tenantId) {
6441
+ const key = `roles:${tenantId}`;
6442
+ const cached = _getCached(_rolesCache, key);
6443
+ if (cached !== void 0) return cached;
6444
+ const ref = db.collection("configuracion").doc(`${tenantId}_roles`);
6445
+ const snap = await ref.get();
6446
+ const data = snap.exists ? snap.data() : null;
6447
+ _setCached(_rolesCache, key, data);
6448
+ return data;
6449
+ }
6450
+ async function _readUserDoc(db, uid) {
6451
+ const key = `user:${uid}`;
6452
+ const cached = _getCached(_userCache, key);
6453
+ if (cached !== void 0) return cached;
6454
+ const ref = db.collection("usuarios").doc(uid);
6455
+ const snap = await ref.get();
6456
+ const data = snap.exists ? snap.data() : null;
6457
+ _setCached(_userCache, key, data);
6458
+ return data;
6459
+ }
6460
+ async function getUserContext({
6461
+ db,
6462
+ tenantId,
6463
+ uid
6464
+ }) {
6465
+ const userDoc = await _readUserDoc(db, uid);
6466
+ if (!userDoc) {
6467
+ throw new Error(`userContext: usuario ${uid} no encontrado.`);
6468
+ }
6469
+ const rolId = userDoc.rol ?? "empleado";
6470
+ const rolNombre = rolId === "super_admin" ? "Super Admin" : await _resolveRolNombre(db, tenantId, rolId);
6471
+ return {
6472
+ uid,
6473
+ nombre: userDoc.nombre ?? userDoc.email ?? "Usuario",
6474
+ email: userDoc.email ?? null,
6475
+ rolId,
6476
+ rolNombre,
6477
+ idiomaPreferido: userDoc.idiomaPreferido ?? "es"
6478
+ };
6479
+ }
6480
+ async function _resolveRolNombre(db, tenantId, rolId) {
6481
+ const rolesData = await _readRolesDoc(db, tenantId);
6482
+ if (!rolesData) return null;
6483
+ const rolData = rolesData[rolId];
6484
+ return rolData?.nombre ?? null;
6485
+ }
6486
+ async function getUserPermissionLevel({
6487
+ db,
6488
+ tenantId,
6489
+ userRol,
6490
+ modulo
6491
+ }) {
6492
+ if (userRol === "super_admin") return "completo";
6493
+ if (!userRol) return "ninguno";
6494
+ const rolesData = await _readRolesDoc(db, tenantId);
6495
+ if (!rolesData) return "ninguno";
6496
+ const rolData = rolesData[userRol];
6497
+ if (!rolData || rolData.activo === false) return "ninguno";
6498
+ const permisos = rolData.permisosPorModulo || {};
6499
+ const nivel = permisos[modulo];
6500
+ if (!nivel || !NIVELES_CANONICOS.includes(nivel)) return "ninguno";
6501
+ return nivel;
6502
+ }
6259
6503
  async function resolveTenantIdioma(tenantId) {
6260
6504
  const db = (0, import_firestore5.getFirestore)();
6261
6505
  const tenantSnap = await db.doc(`tenants/${tenantId}`).get();
@@ -6271,72 +6515,44 @@ async function resolveTenantIdioma(tenantId) {
6271
6515
  }
6272
6516
  return idioma;
6273
6517
  }
6274
- var MESSAGES = {
6275
- es: {
6276
- generic: "Tuve un problema. Intent\xE9moslo de nuevo en un momento.",
6277
- quota_exceeded: "Alcanzaste el l\xEDmite mensual.",
6278
- not_found: "No lo encontr\xE9. \xBFPodr\xEDas verificar el dato?",
6279
- permission: "No tengo permiso para hacer eso desde tu cuenta.",
6280
- timeout: "Esto est\xE1 tardando m\xE1s de lo normal. \xBFLo intento de nuevo?"
6281
- },
6282
- en: {
6283
- generic: "I had a problem. Let's try again in a moment.",
6284
- quota_exceeded: "You reached the monthly limit.",
6285
- not_found: "I couldn't find it. Could you verify the data?",
6286
- permission: "I don't have permission to do that from your account.",
6287
- timeout: "This is taking longer than usual. Should I try again?"
6288
- }
6289
- };
6290
6518
  function martinSafeError(err, locale) {
6291
- if (!(locale in MESSAGES)) {
6292
- throw new Error(
6293
- `Locale '${locale}' no soportado en martinSafeError. Locales activos: ${Object.keys(MESSAGES).join(", ")}. Agregar traducciones antes de usar.`
6294
- );
6295
- }
6296
- const msgs = MESSAGES[locale];
6297
6519
  const e = err;
6298
6520
  const message = e?.message ?? "";
6299
6521
  const code = e?.code ?? "";
6300
- if (/quota|límite|limit/i.test(message)) return msgs.quota_exceeded;
6301
- if (/not.found|no.encontrado|no existe/i.test(message)) return msgs.not_found;
6302
- if (/permission|permiso/i.test(message)) return msgs.permission;
6303
- if (code === "deadline-exceeded" || /timeout/i.test(message)) return msgs.timeout;
6304
- return msgs.generic;
6305
- }
6306
- var MESSAGES2 = {
6307
- es: {
6308
- denied_role: "No tengo permiso para hacer eso desde tu cuenta. P\xEDdelo al encargado o admin que te autorice.",
6309
- input_invalido: "Falta algo o est\xE1 mal. \xBFLo intentamos de nuevo?",
6310
- confirm_default: "\xBFConfirmas?"
6311
- },
6312
- en: {
6313
- denied_role: "I don't have permission to do that from your account. Ask the manager or admin to authorize it.",
6314
- input_invalido: "Something is missing or off. Could you try again?",
6315
- confirm_default: "Are you sure?"
6316
- }
6317
- };
6522
+ let key = "generic";
6523
+ if (/quota|límite|limit/i.test(message)) key = "quota_exceeded";
6524
+ else if (/not.found|no.encontrado|no existe/i.test(message)) key = "not_found";
6525
+ else if (/permission|permiso/i.test(message)) key = "permission";
6526
+ else if (code === "deadline-exceeded" || /timeout/i.test(message)) key = "timeout";
6527
+ return getMessage(`marketing.safeError.${key}`, locale);
6528
+ }
6318
6529
  function getWrapperMessage(key, locale) {
6319
- if (!(locale in MESSAGES2)) {
6320
- throw new Error(
6321
- `Locale '${locale}' no soportado en getWrapperMessage. Locales activos: ${Object.keys(MESSAGES2).join(", ")}. Agregar traducciones antes de usar.`
6322
- );
6323
- }
6324
- const msg = MESSAGES2[locale][key];
6325
- if (!msg) {
6326
- throw new Error(
6327
- `Mensaje '${key}' no definido en wrapperMessages para locale '${locale}'.`
6328
- );
6329
- }
6330
- return msg;
6530
+ return getMessage(`marketing.wrapper.${key}`, locale);
6331
6531
  }
6332
6532
  var NIVELES_ORDER = ["ninguno", "ver", "editar", "completo"];
6333
- function nivelAlcanza(nivel, accion) {
6533
+ function nivelAlcanza2(nivel, accion) {
6334
6534
  return NIVELES_ORDER.indexOf(nivel) >= NIVELES_ORDER.indexOf(accion);
6335
6535
  }
6336
6536
  function wrapWithContract(contract, helper, options = {}) {
6337
6537
  return async function wrappedTool(input, ctx) {
6338
6538
  const startMs = Date.now();
6339
6539
  const locale = ctx.user.idiomaPreferido;
6540
+ const actorType = ctx.user.clientType === "martin" ? "martin" : "mcp_client";
6541
+ const buildActor = () => {
6542
+ const base = {
6543
+ type: actorType,
6544
+ uid: ctx.user.uid,
6545
+ nombre: ctx.user.nombre
6546
+ };
6547
+ const cm = ctx.user.clientMetadata;
6548
+ if (cm && (cm.mcpClient || cm.mcpClientVersion)) {
6549
+ base.metadata = {
6550
+ ...cm.mcpClient ? { mcpClient: cm.mcpClient } : {},
6551
+ ...cm.mcpClientVersion ? { mcpClientVersion: cm.mcpClientVersion } : {}
6552
+ };
6553
+ }
6554
+ return base;
6555
+ };
6340
6556
  let accesoOk = false;
6341
6557
  let reqDesc = "";
6342
6558
  switch (contract.permissionScope) {
@@ -6361,7 +6577,7 @@ function wrapWithContract(contract, helper, options = {}) {
6361
6577
  userRol: ctx.user.rol,
6362
6578
  modulo: contract.permissionKey
6363
6579
  });
6364
- accesoOk = nivelAlcanza(nivel, contract.permissionAction);
6580
+ accesoOk = nivelAlcanza2(nivel, contract.permissionAction);
6365
6581
  break;
6366
6582
  }
6367
6583
  case "self": {
@@ -6385,7 +6601,7 @@ function wrapWithContract(contract, helper, options = {}) {
6385
6601
  await writeAuditLog({
6386
6602
  tenantId: ctx.tenantId,
6387
6603
  brandId: ctx.brandId ?? null,
6388
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6604
+ actor: buildActor(),
6389
6605
  action: contract.auditAction,
6390
6606
  motivo: `Acceso denegado \u2014 rol '${ctx.user.rol}' sin permiso para ${reqDesc}`,
6391
6607
  conversacionId: ctx.conversacionId ?? null,
@@ -6417,7 +6633,7 @@ function wrapWithContract(contract, helper, options = {}) {
6417
6633
  await writeAuditLog({
6418
6634
  tenantId: ctx.tenantId,
6419
6635
  brandId: ctx.brandId ?? null,
6420
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6636
+ actor: buildActor(),
6421
6637
  action: contract.auditAction,
6422
6638
  motivo: "Input inv\xE1lido \u2014 Zod parse failed",
6423
6639
  conversacionId: ctx.conversacionId ?? null,
@@ -6433,7 +6649,10 @@ function wrapWithContract(contract, helper, options = {}) {
6433
6649
  };
6434
6650
  }
6435
6651
  const parsedInput = parseResult.data;
6436
- if (contract.requiresConfirmation && !ctx.confirmationGranted) {
6652
+ const effectiveDestructive = contract.isDestructiveForInput?.(parsedInput) ?? contract.destructive;
6653
+ const effectivePublication = contract.isPublicationForInput?.(parsedInput) ?? contract.affectsPublication;
6654
+ const effectiveRequiresConfirmation = contract.requiresConfirmation || effectiveDestructive || effectivePublication;
6655
+ if (effectiveRequiresConfirmation && !ctx.confirmationGranted) {
6437
6656
  const confirmMsg = contract.martinConfirmationTemplate ? contract.martinConfirmationTemplate(parsedInput, locale) : getWrapperMessage("confirm_default", locale);
6438
6657
  return {
6439
6658
  text: confirmMsg,
@@ -6458,7 +6677,7 @@ function wrapWithContract(contract, helper, options = {}) {
6458
6677
  await writeAuditLog({
6459
6678
  tenantId: ctx.tenantId,
6460
6679
  brandId: ctx.brandId ?? null,
6461
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6680
+ actor: buildActor(),
6462
6681
  action: contract.auditAction,
6463
6682
  motivo: `Quota excedida \u2014 ${quotaName}: ${check.current}/${check.limit}`,
6464
6683
  conversacionId: ctx.conversacionId ?? null,
@@ -6480,9 +6699,9 @@ function wrapWithContract(contract, helper, options = {}) {
6480
6699
  await writeAuditLog({
6481
6700
  tenantId: ctx.tenantId,
6482
6701
  brandId: ctx.brandId ?? null,
6483
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6702
+ actor: buildActor(),
6484
6703
  action: contract.auditAction,
6485
- motivo: "Acci\xF3n solicitada v\xEDa Martin",
6704
+ motivo: `Acci\xF3n solicitada v\xEDa ${actorType}`,
6486
6705
  conversacionId: ctx.conversacionId ?? null,
6487
6706
  status: "error",
6488
6707
  errorMessage: err?.message ?? "unknown",
@@ -6509,7 +6728,7 @@ function wrapWithContract(contract, helper, options = {}) {
6509
6728
  await writeAuditLog({
6510
6729
  tenantId: ctx.tenantId,
6511
6730
  brandId: ctx.brandId ?? null,
6512
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6731
+ actor: buildActor(),
6513
6732
  action: contract.auditAction,
6514
6733
  motivo: `Output del helper inv\xE1lido \u2014 bug de "${contract.name}"`,
6515
6734
  conversacionId: ctx.conversacionId ?? null,
@@ -6542,7 +6761,7 @@ function wrapWithContract(contract, helper, options = {}) {
6542
6761
  await writeAuditLog({
6543
6762
  tenantId: ctx.tenantId,
6544
6763
  brandId: ctx.brandId ?? null,
6545
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6764
+ actor: buildActor(),
6546
6765
  action: contract.auditAction,
6547
6766
  motivo: `disabled \u2014 ${code}${detail ? `: ${JSON.stringify(detail)}` : ""}`,
6548
6767
  conversacionId: ctx.conversacionId ?? null,
@@ -6561,11 +6780,11 @@ function wrapWithContract(contract, helper, options = {}) {
6561
6780
  await writeAuditLog({
6562
6781
  tenantId: ctx.tenantId,
6563
6782
  brandId: ctx.brandId ?? null,
6564
- actor: { type: "martin", uid: ctx.user.uid, nombre: ctx.user.nombre },
6783
+ actor: buildActor(),
6565
6784
  action: contract.auditAction,
6566
6785
  targetPath,
6567
6786
  changes,
6568
- motivo: "Acci\xF3n solicitada v\xEDa Martin",
6787
+ motivo: `Acci\xF3n solicitada v\xEDa ${actorType}`,
6569
6788
  conversacionId: ctx.conversacionId ?? null,
6570
6789
  status: "success",
6571
6790
  durationMs: Date.now() - startMs
@@ -6578,100 +6797,113 @@ function wrapWithContract(contract, helper, options = {}) {
6578
6797
  };
6579
6798
  };
6580
6799
  }
6581
- var RecordarMemoriaParamsSchema = import_zod33.z.object({
6800
+ var RecordarMemoriaParamsSchema = import_zod34.z.object({
6582
6801
  tipo: TipoMemoriaEnum,
6583
6802
  categoria: CategoriaMemoriaEnum,
6584
- contenido: import_zod33.z.string().min(3).max(500)
6803
+ contenido: import_zod34.z.string().min(3).max(500)
6585
6804
  });
6586
- var RecordarMemoriaOutputSchema = import_zod33.z.object({
6587
- memoriaId: import_zod33.z.string(),
6588
- status: import_zod33.z.literal("creada")
6805
+ var RecordarMemoriaOutputSchema = import_zod34.z.object({
6806
+ memoriaId: import_zod34.z.string(),
6807
+ status: import_zod34.z.literal("creada")
6589
6808
  });
6590
- var OlvidarMemoriaParamsSchema = import_zod33.z.object({
6591
- memoriaId: import_zod33.z.string(),
6592
- motivo: import_zod33.z.string().optional()
6809
+ var OlvidarMemoriaParamsSchema = import_zod34.z.object({
6810
+ memoriaId: import_zod34.z.string(),
6811
+ motivo: import_zod34.z.string().optional()
6593
6812
  });
6594
- var OlvidarMemoriaOutputSchema = import_zod33.z.object({
6595
- status: import_zod33.z.literal("archivada")
6813
+ var OlvidarMemoriaOutputSchema = import_zod34.z.object({
6814
+ status: import_zod34.z.literal("archivada")
6596
6815
  });
6597
- var ConfigInputSchema = import_zod34.z.object({
6598
- diaSemana: import_zod34.z.number().int().min(0).max(6).nullable(),
6599
- diaMes: import_zod34.z.number().int().min(1).max(31).nullable(),
6600
- hora: import_zod34.z.string().regex(/^\d{2}:\d{2}$/),
6816
+ var ConfigInputSchema = import_zod35.z.object({
6817
+ diaSemana: import_zod35.z.number().int().min(0).max(6).nullable(),
6818
+ diaMes: import_zod35.z.number().int().min(1).max(31).nullable(),
6819
+ hora: import_zod35.z.string().regex(/^\d{2}:\d{2}$/),
6601
6820
  // Timezone NO viene en input — hereda de tenants/{tid}.zonaHoraria (audit fix 2).
6602
- fechaPuntual: import_zod34.z.string().datetime({ offset: true }).nullable()
6821
+ fechaPuntual: import_zod35.z.string().datetime({ offset: true }).nullable()
6603
6822
  }).strict();
6604
- var AccionInputSchema = import_zod34.z.object({
6605
- tool: import_zod34.z.string().min(1),
6606
- params: import_zod34.z.record(import_zod34.z.string(), import_zod34.z.unknown())
6823
+ var AccionInputSchema = import_zod35.z.object({
6824
+ tool: import_zod35.z.string().min(1),
6825
+ params: import_zod35.z.record(import_zod35.z.string(), import_zod35.z.unknown())
6607
6826
  }).strict();
6608
- var ProgramarRutinaParamsSchema = import_zod34.z.object({
6827
+ var ProgramarRutinaParamsSchema = import_zod35.z.object({
6609
6828
  tipo: TipoRutinaEnum,
6610
6829
  frecuencia: FrecuenciaRutinaEnum,
6611
6830
  config: ConfigInputSchema,
6612
6831
  accion: AccionInputSchema,
6613
- uidDestinatario: import_zod34.z.string()
6832
+ uidDestinatario: import_zod35.z.string()
6614
6833
  });
6615
- var ProgramarRutinaOutputSchema = import_zod34.z.object({
6616
- rutinaId: import_zod34.z.string(),
6617
- proximaEjecucionAt: import_zod34.z.string().datetime()
6834
+ var ProgramarRutinaOutputSchema = import_zod35.z.object({
6835
+ rutinaId: import_zod35.z.string(),
6836
+ proximaEjecucionAt: import_zod35.z.string().datetime()
6618
6837
  });
6619
- var PausarRutinaParamsSchema = import_zod34.z.object({
6620
- rutinaId: import_zod34.z.string(),
6621
- motivo: import_zod34.z.string().optional()
6838
+ var PausarRutinaParamsSchema = import_zod35.z.object({
6839
+ rutinaId: import_zod35.z.string(),
6840
+ motivo: import_zod35.z.string().optional()
6622
6841
  });
6623
- var PausarRutinaOutputSchema = import_zod34.z.object({
6624
- status: import_zod34.z.literal("pausada")
6842
+ var PausarRutinaOutputSchema = import_zod35.z.object({
6843
+ status: import_zod35.z.literal("pausada")
6625
6844
  });
6626
- var ArchivarRutinaParamsSchema = import_zod34.z.object({
6627
- rutinaId: import_zod34.z.string(),
6628
- motivo: import_zod34.z.string().optional()
6845
+ var ArchivarRutinaParamsSchema = import_zod35.z.object({
6846
+ rutinaId: import_zod35.z.string(),
6847
+ motivo: import_zod35.z.string().optional()
6629
6848
  });
6630
- var ArchivarRutinaOutputSchema = import_zod34.z.object({
6631
- status: import_zod34.z.literal("archivada")
6849
+ var ArchivarRutinaOutputSchema = import_zod35.z.object({
6850
+ status: import_zod35.z.literal("archivada")
6632
6851
  });
6633
- var ListarRutinasParamsSchema = import_zod34.z.object({
6634
- uid: import_zod34.z.string().optional()
6852
+ var ListarRutinasParamsSchema = import_zod35.z.object({
6853
+ uid: import_zod35.z.string().optional()
6635
6854
  });
6636
- var ListarRutinasOutputSchema = import_zod34.z.object({
6637
- rutinas: import_zod34.z.array(MartinRutinaSchema)
6855
+ var ListarRutinasOutputSchema = import_zod35.z.object({
6856
+ rutinas: import_zod35.z.array(MartinRutinaSchema)
6638
6857
  });
6639
6858
 
6640
- // src/tools/martinContext.ts
6641
- function buildMartinContext(session, brandId, opts = {}) {
6859
+ // src/tools/buildContext.ts
6860
+ async function buildContext(session, brandId, opts = {}) {
6861
+ const tenantId = session.requireTenant();
6862
+ const ci = session.mcpClientIdentity;
6863
+ let userCtx = null;
6864
+ if (session.userId && getSdkMode() === "admin") {
6865
+ try {
6866
+ userCtx = await getUserContext({
6867
+ db: getAdminDb(),
6868
+ tenantId,
6869
+ uid: session.userId
6870
+ });
6871
+ } catch {
6872
+ userCtx = null;
6873
+ }
6874
+ }
6642
6875
  return {
6643
- tenantId: session.requireTenant(),
6876
+ tenantId,
6644
6877
  brandId,
6645
6878
  user: {
6646
6879
  uid: session.userId ?? "cowork-admin",
6647
- nombre: session.userName ?? "Cowork Admin",
6648
- rol: session.rol ?? "super_admin",
6649
- idiomaPreferido: "es"
6650
- // TODO HITO 8: leer usuarios/{uid}.idiomaPreferido
6880
+ nombre: userCtx?.nombre ?? session.userName ?? "Cowork Admin",
6881
+ rol: userCtx?.rolId ?? session.rol ?? "super_admin",
6882
+ rolNombre: userCtx?.rolNombre ?? null,
6883
+ idiomaPreferido: userCtx?.idiomaPreferido ?? "es",
6884
+ // Any caller entering through this factory is an MCP client (Claude
6885
+ // Desktop, Cursor, custom LLM scripts), NOT the Martin product app.
6886
+ // When Martin product app ships (211.3+), its server-side caller will
6887
+ // override clientType to 'martin' via the same `_martinContext` channel.
6888
+ clientType: "mcp_client",
6889
+ clientMetadata: {
6890
+ mcpClient: ci.mcpClient,
6891
+ mcpClientVersion: ci.mcpClientVersion
6892
+ }
6651
6893
  },
6652
6894
  conversacionId: opts.conversacionId ?? null,
6653
- confirmationGranted: opts.confirmationGranted ?? true,
6895
+ // HITO 6 A7.8: default false — secure default para acciones destructivas.
6896
+ // Tools sin requiresConfirmation lo ignoran; tools con
6897
+ // requiresConfirmation: true necesitan el flag explícito para ejecutar.
6898
+ confirmationGranted: opts.confirmationGranted ?? false,
6654
6899
  doubleConfirmationGranted: opts.doubleConfirmationGranted ?? false
6655
6900
  };
6656
6901
  }
6657
6902
  async function dispatchWithContract(args) {
6658
6903
  const { contract, helper, callable, input, ctx } = args;
6659
- if (getMode() === "admin") {
6904
+ if (getSdkMode() === "admin") {
6660
6905
  const db = getAdminDb();
6661
- const permissionResolver = async (args2) => {
6662
- if (args2.userRol === "super_admin") return "completo";
6663
- const ref = db.collection("configuracion").doc(`${args2.tenantId}_roles`);
6664
- const snap = await ref.get();
6665
- if (!snap.exists) return "ninguno";
6666
- const rolesData = snap.data();
6667
- const rolData = rolesData[args2.userRol];
6668
- if (!rolData || rolData.activo === false) return "ninguno";
6669
- const nivel = rolData.permisosPorModulo?.[args2.modulo];
6670
- if (nivel === "completo" || nivel === "editar" || nivel === "ver" || nivel === "ninguno") {
6671
- return nivel;
6672
- }
6673
- return "ninguno";
6674
- };
6906
+ const permissionResolver = (args2) => getUserPermissionLevel({ db, ...args2 });
6675
6907
  const wrapped = wrapWithContract(
6676
6908
  contract,
6677
6909
  async (i) => helper({ ...i, db }),
@@ -6679,15 +6911,20 @@ async function dispatchWithContract(args) {
6679
6911
  );
6680
6912
  return wrapped(input, ctx);
6681
6913
  }
6682
- return callable({
6914
+ const result = await callable({
6683
6915
  ...input,
6684
6916
  _martinContext: {
6685
6917
  conversacionId: ctx.conversacionId ?? null,
6686
6918
  confirmationGranted: ctx.confirmationGranted === true,
6687
6919
  doubleConfirmationGranted: ctx.doubleConfirmationGranted === true,
6688
- userIdiomaPreferido: ctx.user.idiomaPreferido
6920
+ userIdiomaPreferido: ctx.user.idiomaPreferido,
6921
+ // HITO 6 A7.5: propagar clientType + identidad concreta para que
6922
+ // el factory CF arme actor.type + actor.metadata en el audit log.
6923
+ clientType: ctx.user.clientType ?? "mcp_client",
6924
+ clientMetadata: ctx.user.clientMetadata ?? null
6689
6925
  }
6690
6926
  });
6927
+ return result;
6691
6928
  }
6692
6929
 
6693
6930
  // src/services/marketingHelperCallables.ts
@@ -6711,6 +6948,9 @@ async function callCF(name, input) {
6711
6948
  function callGetCalendar(input) {
6712
6949
  return callCF("marketingGetCalendarCallable", input);
6713
6950
  }
6951
+ function callAddCalendarSlot(input) {
6952
+ return callCF("marketingAddCalendarSlotCallable", input);
6953
+ }
6714
6954
  function callBrandBriefWriter(input) {
6715
6955
  return callCF("marketingBrandBriefWriterCallable", input);
6716
6956
  }
@@ -6761,7 +7001,7 @@ function callPhotoDirectorExecute(input) {
6761
7001
  }
6762
7002
 
6763
7003
  // src/tools/marketing/photos.ts
6764
- var import_zod36 = require("zod");
7004
+ var import_zod37 = require("zod");
6765
7005
 
6766
7006
  // src/services/marketingEmbeddings.ts
6767
7007
  var import_google_auth_library = require("google-auth-library");
@@ -6816,7 +7056,7 @@ async function generateTextEmbedding(text, session) {
6816
7056
  if (!trimmed) {
6817
7057
  throw new Error("generateTextEmbedding: text no puede estar vacio");
6818
7058
  }
6819
- if (getMode() === "client") {
7059
+ if (getSdkMode() === "client") {
6820
7060
  return null;
6821
7061
  }
6822
7062
  return callEmbeddingCF({ text: trimmed }, session);
@@ -6910,7 +7150,7 @@ async function findNearestInCollection(params) {
6910
7150
  `findNearestInCollection: vectorField obligatorio (embeddingText | embeddingImage), recibido ${vectorField}`
6911
7151
  );
6912
7152
  }
6913
- if (getMode() === "client") {
7153
+ if (getSdkMode() === "client") {
6914
7154
  if (!queryText || typeof queryText !== "string") {
6915
7155
  throw new Error(
6916
7156
  "findNearestInCollection en Modo B (client) requiere queryText. El MCP cliente no puede generar embeddings localmente; la CF puente marketingVectorSearchCallable los genera server-side."
@@ -7029,7 +7269,7 @@ async function findNearestInCollectionWithOverride(params) {
7029
7269
  }
7030
7270
 
7031
7271
  // src/tools/marketing/content.ts
7032
- var import_zod35 = require("zod");
7272
+ var import_zod36 = require("zod");
7033
7273
  var _logOverride = null;
7034
7274
  async function logToMcpLogs(entry) {
7035
7275
  if (_logOverride) return _logOverride(entry);
@@ -7042,28 +7282,28 @@ async function logToMcpLogs(entry) {
7042
7282
  } catch {
7043
7283
  }
7044
7284
  }
7045
- var IncludeSchema = import_zod35.z.object({
7046
- products: import_zod35.z.boolean().default(true),
7047
- collections: import_zod35.z.boolean().default(true),
7048
- articles: import_zod35.z.boolean().default(true),
7049
- pages: import_zod35.z.boolean().default(false)
7285
+ var IncludeSchema = import_zod36.z.object({
7286
+ products: import_zod36.z.boolean().default(true),
7287
+ collections: import_zod36.z.boolean().default(true),
7288
+ articles: import_zod36.z.boolean().default(true),
7289
+ pages: import_zod36.z.boolean().default(false)
7050
7290
  }).default({
7051
7291
  products: true,
7052
7292
  collections: true,
7053
7293
  articles: true,
7054
7294
  pages: false
7055
7295
  });
7056
- var LimitSchema = import_zod35.z.object({
7057
- products: import_zod35.z.number().int().min(0).max(20).default(5),
7058
- collections: import_zod35.z.number().int().min(0).max(10).default(3),
7059
- articles: import_zod35.z.number().int().min(0).max(20).default(5),
7060
- pages: import_zod35.z.number().int().min(0).max(10).default(2)
7296
+ var LimitSchema = import_zod36.z.object({
7297
+ products: import_zod36.z.number().int().min(0).max(20).default(5),
7298
+ collections: import_zod36.z.number().int().min(0).max(10).default(3),
7299
+ articles: import_zod36.z.number().int().min(0).max(20).default(5),
7300
+ pages: import_zod36.z.number().int().min(0).max(10).default(2)
7061
7301
  }).default({ products: 5, collections: 3, articles: 5, pages: 2 });
7062
7302
  async function findContentForTopicHandler(input, session) {
7063
7303
  const tenantId = session.requireTenant();
7064
7304
  const brandId = input.brandId;
7065
- const mode2 = getMode();
7066
- const r = mode2 === "admin" ? await contentFinder({
7305
+ const mode = getSdkMode();
7306
+ const r = mode === "admin" ? await contentFinder({
7067
7307
  db: getAdminDb(),
7068
7308
  tenantId,
7069
7309
  brandId,
@@ -7143,15 +7383,15 @@ MODOS (parametro mode):
7143
7383
 
7144
7384
  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.`,
7145
7385
  {
7146
- brandId: import_zod35.z.string().optional().describe("ID de la brand"),
7147
- contexto: import_zod35.z.string().min(1).describe("Parrafo, keyword o intencion"),
7148
- fecha: import_zod35.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
7386
+ brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7387
+ contexto: import_zod36.z.string().min(1).describe("Parrafo, keyword o intencion"),
7388
+ fecha: import_zod36.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
7149
7389
  include: IncludeSchema.optional(),
7150
7390
  limit: LimitSchema.optional(),
7151
- diversidad: import_zod35.z.boolean().default(true),
7152
- mode: import_zod35.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)")
7391
+ diversidad: import_zod36.z.boolean().default(true),
7392
+ 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)")
7153
7393
  },
7154
- async ({ brandId: inputBrandId, contexto, fecha, include, limit, diversidad, mode: mode2 }) => {
7394
+ async ({ brandId: inputBrandId, contexto, fecha, include, limit, diversidad, mode }) => {
7155
7395
  const brandId = inputBrandId ?? session.requireBrand();
7156
7396
  const resolvedInclude = include || {
7157
7397
  products: true,
@@ -7174,7 +7414,7 @@ Cuando uses hybrid: inspecciona el campo \`modesFound\` en los resultados. 2 = e
7174
7414
  include: resolvedInclude,
7175
7415
  limit: resolvedLimit,
7176
7416
  diversidad,
7177
- mode: mode2
7417
+ mode
7178
7418
  },
7179
7419
  session
7180
7420
  );
@@ -7253,16 +7493,16 @@ REGLAS:
7253
7493
 
7254
7494
  USAR: antes de generar contenido de cualquier slot del calendario.`,
7255
7495
  {
7256
- brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7257
- keyword: import_zod36.z.string().describe("Keyword del slot"),
7258
- plataforma: import_zod36.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino del slot"),
7259
- fecha: import_zod36.z.string().describe("Fecha del slot en ISO YYYY-MM-DD"),
7260
- limit: import_zod36.z.number().int().min(1).max(10).default(5)
7496
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7497
+ keyword: import_zod37.z.string().describe("Keyword del slot"),
7498
+ plataforma: import_zod37.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino del slot"),
7499
+ fecha: import_zod37.z.string().describe("Fecha del slot en ISO YYYY-MM-DD"),
7500
+ limit: import_zod37.z.number().int().min(1).max(10).default(5)
7261
7501
  },
7262
7502
  async ({ brandId: inputBrandId, keyword, plataforma, fecha, limit }) => {
7263
7503
  const tenantId = session.requireTenant();
7264
7504
  const brandId = inputBrandId ?? session.requireBrand();
7265
- const result = getMode() === "admin" ? await slotAssetFinder({
7505
+ const result = getSdkMode() === "admin" ? await slotAssetFinder({
7266
7506
  db: getAdminDb(),
7267
7507
  tenantId,
7268
7508
  brandId,
@@ -7298,12 +7538,12 @@ DESPUES de ver la foto, decide:
7298
7538
 
7299
7539
  Luego llama execute_photo_edit con tu analisis y prompt.`,
7300
7540
  {
7301
- fotoId: import_zod36.z.string().describe("ID de la foto")
7541
+ fotoId: import_zod37.z.string().describe("ID de la foto")
7302
7542
  },
7303
7543
  async ({ fotoId }) => {
7304
7544
  const tenantId = session.requireTenant();
7305
7545
  const lang = await resolveTenantIdioma(tenantId);
7306
- const result = getMode() === "admin" ? await photoDirectorPlan({
7546
+ const result = getSdkMode() === "admin" ? await photoDirectorPlan({
7307
7547
  db: getAdminDb(),
7308
7548
  tenantId,
7309
7549
  fotoId,
@@ -7331,19 +7571,19 @@ Retorna la foto editada para que la revises. Si no te gusta:
7331
7571
 
7332
7572
  Si estrategia era 'tal_cual', pasa acciones=['none'] \u2014 no se edita pero si se genera thumbnail y embedding con tus tags.`,
7333
7573
  {
7334
- fotoId: import_zod36.z.string(),
7335
- prompt: import_zod36.z.string().nullable().describe("Prompt en ingles para Gemini Image Edit. null si tal_cual"),
7336
- acciones: import_zod36.z.array(import_zod36.z.enum(["edit_background", "none"])),
7337
- descripcion: import_zod36.z.string().describe("Descripcion semantica en espanol"),
7338
- tipo: import_zod36.z.string().nullable().describe("Tipo del catalogoVisual del tenant"),
7339
- tagsPrimarios: import_zod36.z.array(import_zod36.z.string()),
7340
- tagsSecundarios: import_zod36.z.array(import_zod36.z.string()),
7341
- tagsContexto: import_zod36.z.array(import_zod36.z.string())
7574
+ fotoId: import_zod37.z.string(),
7575
+ prompt: import_zod37.z.string().nullable().describe("Prompt en ingles para Gemini Image Edit. null si tal_cual"),
7576
+ acciones: import_zod37.z.array(import_zod37.z.enum(["edit_background", "none"])),
7577
+ descripcion: import_zod37.z.string().describe("Descripcion semantica en espanol"),
7578
+ tipo: import_zod37.z.string().nullable().describe("Tipo del catalogoVisual del tenant"),
7579
+ tagsPrimarios: import_zod37.z.array(import_zod37.z.string()),
7580
+ tagsSecundarios: import_zod37.z.array(import_zod37.z.string()),
7581
+ tagsContexto: import_zod37.z.array(import_zod37.z.string())
7342
7582
  },
7343
7583
  async ({ fotoId, prompt, acciones, descripcion, tipo, tagsPrimarios, tagsSecundarios, tagsContexto }) => {
7344
7584
  const tenantId = session.requireTenant();
7345
7585
  const lang = await resolveTenantIdioma(tenantId);
7346
- if (getMode() === "admin") {
7586
+ if (getSdkMode() === "admin") {
7347
7587
  const executePhotoEditAdapter = async (payload) => {
7348
7588
  const cfUrl = process.env.MARKETING_PHOTO_EDIT_CF_URL || "https://us-central1-atteyo-ops.cloudfunctions.net/marketingExecutePhotoEdit";
7349
7589
  const { GoogleAuth: GoogleAuth2 } = await import("google-auth-library");
@@ -7415,7 +7655,7 @@ Retorna:
7415
7655
  - creditos: { balance, planId, periodEnd, costoPhotoEdit }
7416
7656
  - _instrucciones: que hacer segun el estado`,
7417
7657
  {
7418
- fotoId: import_zod36.z.string().describe("ID de la foto")
7658
+ fotoId: import_zod37.z.string().describe("ID de la foto")
7419
7659
  },
7420
7660
  async ({ fotoId }) => {
7421
7661
  const tenantId = session.requireTenant();
@@ -7515,11 +7755,11 @@ Retorna:
7515
7755
  "find_products_for_content",
7516
7756
  `[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.`,
7517
7757
  {
7518
- brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7519
- contexto: import_zod36.z.string().describe("Parrafo, keyword o intencion del contenido"),
7520
- fecha: import_zod36.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
7521
- limit: import_zod36.z.number().int().min(1).max(10).default(5),
7522
- diversidad: import_zod36.z.boolean().default(true)
7758
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7759
+ contexto: import_zod37.z.string().describe("Parrafo, keyword o intencion del contenido"),
7760
+ fecha: import_zod37.z.string().describe("Fecha del contenido en ISO YYYY-MM-DD"),
7761
+ limit: import_zod37.z.number().int().min(1).max(10).default(5),
7762
+ diversidad: import_zod37.z.boolean().default(true)
7523
7763
  },
7524
7764
  async ({ brandId: inputBrandId, contexto, fecha, limit, diversidad }) => {
7525
7765
  console.warn(
@@ -7572,15 +7812,15 @@ RETORNA { plantillaId, titulo, thumbnailUrl } o { plantillaId: null, motivo: 'no
7572
7812
 
7573
7813
  USAR: solo si tenant tiene Canva conectado (tenants/{tenantId}/marketing_config/{brandId}.canva.connected=true).`,
7574
7814
  {
7575
- brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7576
- plataforma: import_zod36.z.string().describe("gbp | instagram | shopify_blog"),
7577
- tipoContenido: import_zod36.z.string().describe("post | carousel | story | blog"),
7578
- keyword: import_zod36.z.string().describe("Keyword del slot")
7815
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7816
+ plataforma: import_zod37.z.string().describe("gbp | instagram | shopify_blog"),
7817
+ tipoContenido: import_zod37.z.string().describe("post | carousel | story | blog"),
7818
+ keyword: import_zod37.z.string().describe("Keyword del slot")
7579
7819
  },
7580
7820
  async ({ brandId: inputBrandId, plataforma, tipoContenido, keyword }) => {
7581
7821
  const tenantId = session.requireTenant();
7582
7822
  const brandId = inputBrandId ?? session.requireBrand();
7583
- const result = getMode() === "admin" ? await canvaTemplateSelector({
7823
+ const result = getSdkMode() === "admin" ? await canvaTemplateSelector({
7584
7824
  db: getAdminDb(),
7585
7825
  tenantId,
7586
7826
  brandId,
@@ -7597,7 +7837,7 @@ USAR: solo si tenant tiene Canva conectado (tenants/{tenantId}/marketing_config/
7597
7837
  );
7598
7838
  server.tool(
7599
7839
  "request_photo_shoot",
7600
- `Agrega necesidades al FotoBriefing semanal del tenant. Lo usa Claude cuando detecta gaps fotograficos mientras planifica contenido (get_photos_for_slot retorna < 3 fotos).
7840
+ `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).
7601
7841
 
7602
7842
  EFECTO: merge de necesidades en marketing_fotobriefings/{tenantId}_{brandId}_{semana}. Si el doc no existe, lo crea. Idempotente por tema.
7603
7843
 
@@ -7605,15 +7845,15 @@ USAR: cuando get_photos_for_slot retorna pocas fotos y el slot aun no esta cubie
7605
7845
 
7606
7846
  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.`,
7607
7847
  {
7608
- brandId: import_zod36.z.string().optional().describe("ID de la brand"),
7609
- semana: import_zod36.z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "semana debe ser ISO YYYY-MM-DD").describe("Semana en ISO del lunes"),
7610
- necesidades: import_zod36.z.array(
7611
- import_zod36.z.object({
7612
- tema: import_zod36.z.string(),
7613
- keyword: import_zod36.z.string(),
7614
- cantidadSugerida: import_zod36.z.number().int().positive(),
7615
- razon: import_zod36.z.string(),
7616
- slotsAfectados: import_zod36.z.array(import_zod36.z.string()).optional().describe('Refs de slots afectados (formato "semana:N:slot:M")')
7848
+ brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7849
+ 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"),
7850
+ necesidades: import_zod37.z.array(
7851
+ import_zod37.z.object({
7852
+ tema: import_zod37.z.string(),
7853
+ keyword: import_zod37.z.string(),
7854
+ cantidadSugerida: import_zod37.z.number().int().positive(),
7855
+ razon: import_zod37.z.string(),
7856
+ slotsAfectados: import_zod37.z.array(import_zod37.z.string()).optional().describe('Refs de slots afectados (formato "semana:N:slot:M")')
7617
7857
  })
7618
7858
  ).min(1)
7619
7859
  },
@@ -7759,14 +7999,14 @@ function registerMarketingTools(server, session) {
7759
7999
  "get_calendar",
7760
8000
  "Lee el calendario editorial del mes. Muestra semanas con items planificados por plataforma.",
7761
8001
  {
7762
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7763
- mes: import_zod37.z.string().optional().describe("Mes en formato YYYY-MM (default: mes actual)")
8002
+ brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8003
+ mes: import_zod38.z.string().optional().describe("Mes en formato YYYY-MM (default: mes actual)")
7764
8004
  },
7765
8005
  async ({ brandId: inputBrandId, mes }) => {
7766
8006
  const tenantId = session.requireTenant();
7767
8007
  const brandId = inputBrandId ?? session.requireBrand();
7768
8008
  const targetMes = mes ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 7);
7769
- const ctx = buildMartinContext(session, brandId);
8009
+ const ctx = await buildContext(session, brandId);
7770
8010
  const result = await dispatchWithContract({
7771
8011
  contract: getCalendarContract,
7772
8012
  helper: getCalendar,
@@ -7793,7 +8033,7 @@ function registerMarketingTools(server, session) {
7793
8033
  "get_seo_snapshot",
7794
8034
  "Lee el snapshot SEO de la brand: rank, keywords, oportunidades, competidores.",
7795
8035
  {
7796
- brandId: import_zod37.z.string().optional().describe("ID de la brand")
8036
+ brandId: import_zod38.z.string().optional().describe("ID de la brand")
7797
8037
  },
7798
8038
  async ({ brandId: inputBrandId }) => {
7799
8039
  const tenantId = session.requireTenant();
@@ -7812,8 +8052,8 @@ function registerMarketingTools(server, session) {
7812
8052
  "get_photo_gallery",
7813
8053
  "Fotos disponibles filtradas por estado. Muestra fotos con metadata y conteo.",
7814
8054
  {
7815
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7816
- estado: import_zod37.z.string().optional().describe("Filtrar por estado (nueva, procesando, editada, usada, descartada, error)")
8055
+ brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8056
+ estado: import_zod38.z.string().optional().describe("Filtrar por estado (nueva, procesando, editada, usada, descartada, error)")
7817
8057
  },
7818
8058
  async ({ brandId: inputBrandId, estado }) => {
7819
8059
  session.requireTenant();
@@ -7851,32 +8091,32 @@ function registerMarketingTools(server, session) {
7851
8091
  );
7852
8092
  server.tool(
7853
8093
  "generate_marketing_plan",
7854
- "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.",
8094
+ "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.",
7855
8095
  {
7856
- brandId: import_zod37.z.string().optional().describe("ID de la brand")
8096
+ brandId: import_zod38.z.string().optional().describe("ID de la brand")
7857
8097
  },
7858
8098
  async ({ brandId: inputBrandId }) => {
7859
8099
  const tenantId = session.requireTenant();
7860
8100
  const brandId = inputBrandId ?? session.requireBrand();
7861
- const result = getMode() === "admin" ? await marketingPlanBuilder({ db: getAdminDb(), tenantId, brandId }) : await callMarketingPlanBuilder({ tenantId, brandId });
8101
+ const result = getSdkMode() === "admin" ? await marketingPlanBuilder({ db: getAdminDb(), tenantId, brandId }) : await callMarketingPlanBuilder({ tenantId, brandId });
7862
8102
  const payload = result.ok ? result.payload : result;
7863
8103
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
7864
8104
  }
7865
8105
  );
7866
8106
  server.tool(
7867
8107
  "save_marketing_plan",
7868
- "Guarda el plan de marketing generado por Claude en la configuracion de la brand. Escribe en tenants/{tenantId}/marketing_config/{brandId}.plan.",
8108
+ "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).",
7869
8109
  {
7870
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7871
- plan: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown()).describe("Plan de marketing generado (keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, etc.)")
8110
+ brandId: import_zod38.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context."),
8111
+ plan: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Full marketing plan object. Common fields: keywordsPrioritarios, gapsCompetencia, temporadas, tonoMarca, quickWins, coleccionesPriorizadas, blogStrategy.")
7872
8112
  },
7873
8113
  async ({ brandId: inputBrandId, plan }) => {
7874
8114
  const tenantId = session.requireTenant();
7875
8115
  const brandId = inputBrandId ?? session.requireBrand();
7876
- const ctx = buildMartinContext(session, brandId);
8116
+ const ctx = await buildContext(session, brandId);
7877
8117
  const result = await dispatchWithContract({
7878
8118
  contract: planWriterSaveContract,
7879
- helper: planWriter.save,
8119
+ helper: ({ db, ...rest }) => planWriter.save({ db, ...rest }),
7880
8120
  callable: callPlanWriterSave,
7881
8121
  input: { tenantId, brandId, plan },
7882
8122
  ctx
@@ -7885,8 +8125,8 @@ function registerMarketingTools(server, session) {
7885
8125
  ok: false,
7886
8126
  state: result.state,
7887
8127
  mensaje: result.text,
7888
- // HITO 6 A6.7: incluir detalles Zod estructurados para que
7889
- // Claude (LLM) pueda auto-recuperarse en el siguiente intento.
8128
+ // HITO 6 A6.7: include structured Zod details so
8129
+ // the LLM can self-recover on the next attempt.
7890
8130
  ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
7891
8131
  };
7892
8132
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
@@ -7896,27 +8136,27 @@ function registerMarketingTools(server, session) {
7896
8136
  "update_marketing_plan_field",
7897
8137
  "Actualiza UN campo del plan de marketing sin sobreescribir el resto. Merge parcial. Ideal para agregar coleccionesPriorizadas, actualizar quickWins, etc.",
7898
8138
  {
7899
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7900
- field: import_zod37.z.string().describe('Nombre del campo a actualizar (ej: "coleccionesPriorizadas", "quickWins", "temporadas")'),
7901
- value: import_zod37.z.unknown().describe("Valor del campo")
8139
+ brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8140
+ field: import_zod38.z.string().describe('Nombre del campo a actualizar (ej: "coleccionesPriorizadas", "quickWins", "temporadas")'),
8141
+ value: import_zod38.z.unknown().describe("Valor del campo")
7902
8142
  },
7903
8143
  async ({ brandId: inputBrandId, field, value }) => {
7904
8144
  const tenantId = session.requireTenant();
7905
8145
  const brandId = inputBrandId ?? session.requireBrand();
7906
- const result = getMode() === "admin" ? await planWriter.updateField({ db: getAdminDb(), tenantId, brandId, field, value }) : await callPlanWriterUpdateField({ tenantId, brandId, field, value });
8146
+ const result = getSdkMode() === "admin" ? await planWriter.updateField({ db: getAdminDb(), tenantId, brandId, field, value }) : await callPlanWriterUpdateField({ tenantId, brandId, field, value });
7907
8147
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
7908
8148
  }
7909
8149
  );
7910
8150
  server.tool(
7911
8151
  "generate_brand_brief",
7912
- "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.",
8152
+ "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.",
7913
8153
  {
7914
- brandId: import_zod37.z.string().optional().describe("ID de la brand")
8154
+ brandId: import_zod38.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context.")
7915
8155
  },
7916
8156
  async ({ brandId: inputBrandId }) => {
7917
8157
  const tenantId = session.requireTenant();
7918
8158
  const brandId = inputBrandId ?? session.requireBrand();
7919
- const ctx = buildMartinContext(session, brandId);
8159
+ const ctx = await buildContext(session, brandId);
7920
8160
  const result = await dispatchWithContract({
7921
8161
  contract: brandBriefBuilderContract,
7922
8162
  helper: brandBriefBuilder,
@@ -7937,54 +8177,54 @@ function registerMarketingTools(server, session) {
7937
8177
  );
7938
8178
  server.tool(
7939
8179
  "save_brand_brief",
7940
- "Guarda el Brand Brief generado por Claude en tenants/{tenantId}/marketing_config/{brandId}.brandBrief. Escribe solo ese campo (merge parcial, no rebuild del doc).",
8180
+ "Save the Brand Brief at tenants/{tenantId}/marketing_config/{brandId}.brandBrief. Partial merge \u2014 only the brandBrief field is overwritten.",
7941
8181
  {
7942
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7943
- brandBrief: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown()).describe("Brand Brief completo generado por Claude")
8182
+ brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8183
+ brandBrief: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Full Brand Brief object.")
7944
8184
  },
7945
8185
  async ({ brandId: inputBrandId, brandBrief }) => {
7946
8186
  const tenantId = session.requireTenant();
7947
8187
  const brandId = inputBrandId ?? session.requireBrand();
7948
- const result = getMode() === "admin" ? await brandBriefWriter({ db: getAdminDb(), tenantId, brandId, brandBrief }) : await callBrandBriefWriter({ tenantId, brandId, brandBrief });
8188
+ const result = getSdkMode() === "admin" ? await brandBriefWriter({ db: getAdminDb(), tenantId, brandId, brandBrief }) : await callBrandBriefWriter({ tenantId, brandId, brandBrief });
7949
8189
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
7950
8190
  }
7951
8191
  );
7952
8192
  server.tool(
7953
8193
  "generate_weekly_content",
7954
- "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.",
8194
+ "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.",
7955
8195
  {
7956
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7957
- semana: import_zod37.z.number().optional().describe("Numero de semana (1-5). Default: semana actual del mes."),
7958
- modo: import_zod37.z.enum(["planificar", "generar"]).optional().describe("'planificar' = proponer distribucion (plataforma+keyword por dia). 'generar' = generar contenido real para slots ya planificados. Default: 'planificar'.")
8196
+ brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8197
+ semana: import_zod38.z.number().optional().describe("Numero de semana (1-5). Default: semana actual del mes."),
8198
+ 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'.")
7959
8199
  },
7960
8200
  async ({ brandId: inputBrandId, semana, modo }) => {
7961
8201
  const tenantId = session.requireTenant();
7962
8202
  const brandId = inputBrandId ?? session.requireBrand();
7963
- const result = getMode() === "admin" ? await weeklyContentBuilder({ db: getAdminDb(), tenantId, brandId, semana, modo }) : await callWeeklyContentBuilder({ tenantId, brandId, semana, modo });
8203
+ const result = getSdkMode() === "admin" ? await weeklyContentBuilder({ db: getAdminDb(), tenantId, brandId, semana, modo }) : await callWeeklyContentBuilder({ tenantId, brandId, semana, modo });
7964
8204
  const payload = result.ok ? result.payload : result;
7965
8205
  return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
7966
8206
  }
7967
8207
  );
7968
8208
  server.tool(
7969
8209
  "save_generated_content",
7970
- `Guarda contenido generado por Claude en Firestore. Usa buildContenido para validar la estructura.
8210
+ `Save generated content to Firestore. Uses buildContenido to validate the structure.
7971
8211
 
7972
- IMPORTANTE: Si seleccionaste una foto con get_photos_for_slot, SIEMPRE pasa fotoId aqu\xED. Sin fotoId el post se publica sin imagen.`,
8212
+ 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.`,
7973
8213
  {
7974
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
7975
- plataforma: import_zod37.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
7976
- tipo: import_zod37.z.string().optional().describe("Tipo de contenido (post, blog, carousel, etc.)"),
7977
- keyword: import_zod37.z.string().optional().describe("Keyword target"),
7978
- languageCode: import_zod37.z.string().optional().describe("Idioma (es/en)"),
7979
- fotoId: import_zod37.z.string().optional().describe("ID de la foto a asociar"),
7980
- datos: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown()).describe("Datos especificos de la plataforma (output de buildDatos*)"),
7981
- calendarioItemRef: import_zod37.z.string().optional().describe("Referencia al item del calendario")
8214
+ brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8215
+ plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Plataforma destino"),
8216
+ tipo: import_zod38.z.string().optional().describe("Tipo de contenido (post, blog, carousel, etc.)"),
8217
+ keyword: import_zod38.z.string().optional().describe("Keyword target"),
8218
+ languageCode: import_zod38.z.string().optional().describe("Idioma (es/en)"),
8219
+ fotoId: import_zod38.z.string().optional().describe("ID de la foto a asociar"),
8220
+ datos: import_zod38.z.record(import_zod38.z.string(), import_zod38.z.unknown()).describe("Datos especificos de la plataforma (output de buildDatos*)"),
8221
+ calendarioItemRef: import_zod38.z.string().optional().describe("Referencia al item del calendario")
7982
8222
  },
7983
8223
  async ({ brandId: inputBrandId, plataforma, tipo, keyword, languageCode, fotoId, datos, calendarioItemRef }) => {
7984
8224
  const tenantId = session.requireTenant();
7985
8225
  const brandId = inputBrandId ?? session.requireBrand();
7986
8226
  try {
7987
- const result = getMode() === "admin" ? await contenidoWriter({
8227
+ const result = getSdkMode() === "admin" ? await contenidoWriter({
7988
8228
  db: getAdminDb(),
7989
8229
  tenantId,
7990
8230
  brandId,
@@ -8023,17 +8263,17 @@ Usa para: corregir body, metaTitle, tags, fotoId, o cualquier campo sin tener qu
8023
8263
  NO puede cambiar: tenantId, brandId, id (inmutables).
8024
8264
  Si pasas campos dentro de "datos", se hace merge con los datos existentes (no los reemplaza entero).`,
8025
8265
  {
8026
- contenidoId: import_zod37.z.string().describe("ID del doc en marketing_contenido"),
8027
- datos: import_zod37.z.record(import_zod37.z.string(), import_zod37.z.unknown()).optional().describe('Campos de datos a actualizar (merge parcial sobre datos existentes). Ej: { body: "...", metaTitle: "..." }'),
8028
- fotoId: import_zod37.z.string().nullable().optional().describe("Actualizar foto asociada"),
8029
- keyword: import_zod37.z.string().nullable().optional().describe("Actualizar keyword"),
8030
- languageCode: import_zod37.z.string().optional().describe("Actualizar idioma"),
8031
- estado: import_zod37.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe("Cambiar estado manualmente"),
8032
- calendarioItemRef: import_zod37.z.string().nullable().optional().describe("Vincular a un slot del calendario")
8266
+ contenidoId: import_zod38.z.string().describe("ID del doc en marketing_contenido"),
8267
+ 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: "..." }'),
8268
+ fotoId: import_zod38.z.string().nullable().optional().describe("Actualizar foto asociada"),
8269
+ keyword: import_zod38.z.string().nullable().optional().describe("Actualizar keyword"),
8270
+ languageCode: import_zod38.z.string().optional().describe("Actualizar idioma"),
8271
+ estado: import_zod38.z.enum(["borrador", "generado", "pendiente_aprobacion", "aprobado", "rechazado"]).optional().describe("Cambiar estado manualmente"),
8272
+ calendarioItemRef: import_zod38.z.string().nullable().optional().describe("Vincular a un slot del calendario")
8033
8273
  },
8034
8274
  async ({ contenidoId, datos: newDatos, fotoId, keyword, languageCode, estado, calendarioItemRef }) => {
8035
8275
  const tenantId = session.requireTenant();
8036
- const result = getMode() === "admin" ? await contenidoUpdater({
8276
+ const result = getSdkMode() === "admin" ? await contenidoUpdater({
8037
8277
  db: getAdminDb(),
8038
8278
  tenantId,
8039
8279
  contenidoId,
@@ -8056,42 +8296,88 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
8056
8296
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
8057
8297
  }
8058
8298
  );
8299
+ server.tool(
8300
+ "add_calendar_slot",
8301
+ "Add a NEW slot to the editorial calendar. To modify an existing slot use update_calendar_slot instead.",
8302
+ {
8303
+ brandId: import_zod38.z.string().describe('Brand ID (e.g. "ponch", "teleglobos").'),
8304
+ mes: import_zod38.z.string().describe('Calendar month in YYYY-MM format (e.g. "2026-05").'),
8305
+ semana: import_zod38.z.number().describe("Week number within the month (1-5)."),
8306
+ slot: import_zod38.z.object({
8307
+ dia: import_zod38.z.string().describe("Slot date in YYYY-MM-DD format. Must fall within the week's fechaInicio/fechaFin range."),
8308
+ plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).describe("Target publishing platform."),
8309
+ tipo: import_zod38.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).describe("Content type."),
8310
+ keyword: import_zod38.z.string().describe("Primary keyword for the content."),
8311
+ tema: import_zod38.z.string().optional().describe("Content topic/theme. OMIT this field if it does not apply. Do NOT send empty string or null."),
8312
+ productoId: import_zod38.z.string().optional().describe("Linked product ID. OMIT this field if no product is linked."),
8313
+ estado: import_zod38.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe('Initial status. OMIT to use default "planificado".'),
8314
+ locationId: import_zod38.z.string().optional().describe("GBP location ID \u2014 only when plataforma=gbp AND tenant is multi-location. OMIT otherwise."),
8315
+ locationNombre: import_zod38.z.string().optional().describe("Human-readable GBP location name. OMIT if locationId is not provided.")
8316
+ }).describe("New slot data.")
8317
+ },
8318
+ async ({ brandId, mes, semana, slot }) => {
8319
+ const tenantId = session.requireTenant();
8320
+ const ctx = await buildContext(session, brandId);
8321
+ const result = await dispatchWithContract({
8322
+ contract: addCalendarSlotContract,
8323
+ helper: addCalendarSlot,
8324
+ callable: callAddCalendarSlot,
8325
+ input: { tenantId, brandId, mes, semana, slot },
8326
+ ctx
8327
+ });
8328
+ const payload = result.state === "success" ? result.structuredOutput : {
8329
+ ok: false,
8330
+ state: result.state,
8331
+ mensaje: result.text,
8332
+ ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
8333
+ };
8334
+ return { content: [{ type: "text", text: JSON.stringify(payload) }] };
8335
+ }
8336
+ );
8059
8337
  server.tool(
8060
8338
  "update_calendar_slot",
8061
- "Modifica o agrega un slot del calendario editorial. Si slotIndex >= items.length, agrega un slot nuevo.",
8339
+ "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.",
8062
8340
  {
8063
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
8064
- mes: import_zod37.z.string().describe("Mes del calendario (YYYY-MM)"),
8065
- semana: import_zod37.z.number().describe("Numero de semana (1-5)"),
8066
- slotIndex: import_zod37.z.number().describe("Indice del slot (0-based). Si >= items.length, agrega nuevo slot al final."),
8067
- cambios: import_zod37.z.object({
8068
- dia: import_zod37.z.string().nullable().optional(),
8069
- plataforma: import_zod37.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional(),
8070
- tipo: import_zod37.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional(),
8071
- keyword: import_zod37.z.string().nullable().optional(),
8072
- tema: import_zod37.z.string().nullable().optional(),
8073
- productoId: import_zod37.z.string().nullable().optional(),
8074
- estado: import_zod37.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional(),
8075
- contenidoRef: import_zod37.z.string().nullable().optional(),
8076
- fotoIdAsignada: import_zod37.z.string().nullable().optional(),
8077
- notas: import_zod37.z.array(NotaCalendarioSchema).optional(),
8078
- locationId: import_zod37.z.string().nullable().optional().describe("GBP location ID \u2014 a qu\xE9 sucursal publicar el post"),
8079
- locationNombre: import_zod37.z.string().nullable().optional().describe("Nombre de la sucursal GBP (para UI)")
8080
- }).strict().describe("Campos del slot. Solo acepta campos validos del schema. Campos no reconocidos seran rechazados."),
8081
- accionContenidoExistente: import_zod37.z.union([
8082
- import_zod37.z.enum(["descartar", "nuevo_slot", "mantener"]),
8083
- import_zod37.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Formato: mover:semana:N:slot:M")
8341
+ brandId: import_zod38.z.string().optional().describe("Brand ID. If omitted, uses the active brand from session context."),
8342
+ mes: import_zod38.z.string().describe("Calendar month in YYYY-MM format."),
8343
+ semana: import_zod38.z.number().describe("Week number within the month (1-5)."),
8344
+ slotIndex: import_zod38.z.number().describe("Slot index (0-based). Must point to an existing slot \u2014 to add new slots use add_calendar_slot."),
8345
+ cambios: import_zod38.z.object({
8346
+ dia: import_zod38.z.string().nullable().optional().describe("Slot date in YYYY-MM-DD. OMIT if not changing date. Use null to explicitly clear."),
8347
+ plataforma: import_zod38.z.enum(["gbp", "shopify_blog", "instagram", "review"]).nullable().optional().describe("OMIT if not changing platform."),
8348
+ tipo: import_zod38.z.enum(["post", "blog", "carousel", "reel", "story", "review_response"]).nullable().optional().describe("OMIT if not changing content type."),
8349
+ keyword: import_zod38.z.string().nullable().optional().describe("OMIT if not changing keyword."),
8350
+ tema: import_zod38.z.string().nullable().optional().describe("OMIT if not changing topic."),
8351
+ productoId: import_zod38.z.string().nullable().optional().describe("OMIT if not changing linked product. Use null to unlink."),
8352
+ estado: import_zod38.z.enum(["planificado", "pre_aprobado", "generado", "revisar", "aprobado", "publicado", "rechazado"]).optional().describe("OMIT if not changing status manually."),
8353
+ contenidoRef: import_zod38.z.string().nullable().optional().describe("OMIT \u2014 managed by the system, not by callers."),
8354
+ fotoIdAsignada: import_zod38.z.string().nullable().optional().describe("Use assign_photo_to_content for photos. OMIT here."),
8355
+ notas: import_zod38.z.array(NotaCalendarioSchema).optional().describe("Append-only notes for the slot."),
8356
+ locationId: import_zod38.z.string().nullable().optional().describe("GBP location ID \u2014 only when plataforma=gbp."),
8357
+ locationNombre: import_zod38.z.string().nullable().optional().describe("Human-readable GBP location name (for UI).")
8358
+ }).strict().describe("Fields to update on the slot. Only declared fields accepted; unknown fields are rejected. OMIT any field you are not changing."),
8359
+ accionContenidoExistente: import_zod38.z.union([
8360
+ import_zod38.z.enum(["descartar", "nuevo_slot", "mantener"]),
8361
+ import_zod38.z.string().regex(/^mover:semana:\d+:slot:\d+$/, "Format: mover:semana:N:slot:M")
8084
8362
  ]).optional().describe(
8085
- "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)."
8363
+ '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).'
8086
8364
  )
8087
8365
  },
8088
8366
  async ({ brandId: inputBrandId, mes, semana, slotIndex, cambios, accionContenidoExistente }) => {
8089
8367
  const tenantId = session.requireTenant();
8090
8368
  const brandId = inputBrandId ?? session.requireBrand();
8091
- const ctx = buildMartinContext(session, brandId);
8369
+ const ctx = await buildContext(session, brandId);
8092
8370
  const result = await dispatchWithContract({
8093
8371
  contract: calendarSlotUpdaterContract,
8094
- helper: calendarSlotUpdater,
8372
+ // Wrap en arrow para que TS infiera el tipo del input desde el
8373
+ // schema del contract (acepta `cambios: Record<string, unknown>`),
8374
+ // no desde la interface estricta CalendarSlotUpdaterInput.
8375
+ // El helper internamente acepta el shape — Zod ya valido en el wrapper.
8376
+ helper: (input) => calendarSlotUpdater({
8377
+ ...input,
8378
+ cambios: input.cambios,
8379
+ accionContenidoExistente: input.accionContenidoExistente
8380
+ }),
8095
8381
  callable: callCalendarSlotUpdater,
8096
8382
  input: { tenantId, brandId, mes, semana, slotIndex, cambios, accionContenidoExistente },
8097
8383
  ctx
@@ -8100,8 +8386,8 @@ Si pasas campos dentro de "datos", se hace merge con los datos existentes (no lo
8100
8386
  ok: false,
8101
8387
  state: result.state,
8102
8388
  mensaje: result.text,
8103
- // HITO 6 A6.7: incluir detalles Zod estructurados para que
8104
- // Claude (LLM) pueda auto-recuperarse en el siguiente intento.
8389
+ // HITO 6 A6.7: include structured Zod details so
8390
+ // the LLM can self-recover on the next attempt.
8105
8391
  ...result.validationErrors && result.validationErrors.length > 0 ? { validationErrors: result.validationErrors } : {}
8106
8392
  };
8107
8393
  return { content: [{ type: "text", text: JSON.stringify(payload) }] };
@@ -8117,14 +8403,14 @@ ESCRIBE EN DOS LUGARES:
8117
8403
 
8118
8404
  NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agenda, no en el post.`,
8119
8405
  {
8120
- contenidoRef: import_zod37.z.string().describe("ID del doc en marketing_contenido"),
8121
- fotoId: import_zod37.z.string().describe("ID de la foto en marketing_fotos (estado editada)"),
8122
- calendarioItemRef: import_zod37.z.string().optional().describe('Ref del slot (formato "semana:N:slot:M") para actualizar fotoIdAsignada')
8406
+ contenidoRef: import_zod38.z.string().describe("ID del doc en marketing_contenido"),
8407
+ fotoId: import_zod38.z.string().describe("ID de la foto en marketing_fotos (estado editada)"),
8408
+ calendarioItemRef: import_zod38.z.string().optional().describe('Ref del slot (formato "semana:N:slot:M") para actualizar fotoIdAsignada')
8123
8409
  },
8124
8410
  async ({ contenidoRef, fotoId, calendarioItemRef }) => {
8125
8411
  const tenantId = session.requireTenant();
8126
8412
  const brandId = session.requireBrand();
8127
- const result = getMode() === "admin" ? await photoAssigner({ db: getAdminDb(), tenantId, brandId, contenidoRef, fotoId, calendarioItemRef }) : await callPhotoAssigner({ tenantId, brandId, contenidoRef, fotoId, calendarioItemRef });
8413
+ const result = getSdkMode() === "admin" ? await photoAssigner({ db: getAdminDb(), tenantId, brandId, contenidoRef, fotoId, calendarioItemRef }) : await callPhotoAssigner({ tenantId, brandId, contenidoRef, fotoId, calendarioItemRef });
8128
8414
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
8129
8415
  }
8130
8416
  );
@@ -8132,7 +8418,7 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8132
8418
  "approve_content",
8133
8419
  "Aprueba contenido para publicacion. Valida transicion de estado.",
8134
8420
  {
8135
- contenidoId: import_zod37.z.string().describe("ID del contenido a aprobar")
8421
+ contenidoId: import_zod38.z.string().describe("ID del contenido a aprobar")
8136
8422
  },
8137
8423
  async ({ contenidoId }) => {
8138
8424
  session.requireTenant();
@@ -8156,8 +8442,8 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8156
8442
  "reject_content",
8157
8443
  "Rechaza contenido con motivo. Valida transicion de estado.",
8158
8444
  {
8159
- contenidoId: import_zod37.z.string().describe("ID del contenido a rechazar"),
8160
- motivo: import_zod37.z.string().describe("Motivo del rechazo")
8445
+ contenidoId: import_zod38.z.string().describe("ID del contenido a rechazar"),
8446
+ motivo: import_zod38.z.string().describe("Motivo del rechazo")
8161
8447
  },
8162
8448
  async ({ contenidoId, motivo }) => {
8163
8449
  session.requireTenant();
@@ -8180,7 +8466,7 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8180
8466
  "get_collections",
8181
8467
  "Lee todas las colecciones canonicas (Shopify/WordPress/webonly) con sus 6 campos SEO actuales. Incluye best practices 2026.",
8182
8468
  {
8183
- brandId: import_zod37.z.string().optional().describe("ID de la brand")
8469
+ brandId: import_zod38.z.string().optional().describe("ID de la brand")
8184
8470
  },
8185
8471
  async ({ brandId: inputBrandId }) => {
8186
8472
  const tenantId = session.requireTenant();
@@ -8267,13 +8553,13 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8267
8553
  "save_collection_suggestions",
8268
8554
  "Guarda sugerencias SEO para colecciones de Shopify. El tenant las aprueba en la UI.",
8269
8555
  {
8270
- brandId: import_zod37.z.string().optional().describe("ID de la brand"),
8556
+ brandId: import_zod38.z.string().optional().describe("ID de la brand"),
8271
8557
  suggestions: CollectionSuggestionsInputArraySchema.describe("Array de sugerencias SEO. Cada una con max 60 chars en metaTitle, max 158 en metaDescription, max 125 en imageAlt")
8272
8558
  },
8273
8559
  async ({ brandId: inputBrandId, suggestions }) => {
8274
8560
  const tenantId = session.requireTenant();
8275
8561
  const brandId = inputBrandId ?? session.requireBrand();
8276
- const result = getMode() === "admin" ? await collectionSuggestionsWriter({ db: getAdminDb(), tenantId, brandId, suggestions }) : await callCollectionSuggestionsWriter({ tenantId, brandId, suggestions });
8562
+ const result = getSdkMode() === "admin" ? await collectionSuggestionsWriter({ db: getAdminDb(), tenantId, brandId, suggestions }) : await callCollectionSuggestionsWriter({ tenantId, brandId, suggestions });
8277
8563
  return { content: [{ type: "text", text: JSON.stringify(result) }] };
8278
8564
  }
8279
8565
  );
@@ -8281,8 +8567,8 @@ NUNCA uses update_calendar_slot para asignar fotos \u2014 eso escribe en la agen
8281
8567
 
8282
8568
  // src/index.ts
8283
8569
  async function buildSystemPrompt2(session) {
8284
- const mode2 = getMode();
8285
- if (mode2 === "admin") {
8570
+ const mode = getSdkMode();
8571
+ if (mode === "admin") {
8286
8572
  const db = getAdminDb();
8287
8573
  return buildSystemPrompt({
8288
8574
  db,
@@ -8299,7 +8585,9 @@ async function buildSystemPrompt2(session) {
8299
8585
  async function main() {
8300
8586
  const authContext = resolveAuth();
8301
8587
  const session = new Session(authContext);
8302
- console.error(`[ponch-mcp] Modo: ${authContext.mode}`);
8588
+ console.error(
8589
+ `[ponch-mcp] Auth resuelto. canSwitchTenant=${authContext.canSwitchTenant} tenantId=${authContext.tenantId ?? "(none)"} rol=${authContext.rol ?? "(none)"}`
8590
+ );
8303
8591
  let firebaseReady = false;
8304
8592
  if (authContext.serviceAccountPath) {
8305
8593
  initFirebaseAdmin(authContext.serviceAccountPath);
@@ -8311,7 +8599,7 @@ async function main() {
8311
8599
  console.error(`[ponch-mcp] Firebase Client autenticado. Tenant: ${authContext.tenantId}`);
8312
8600
  } else {
8313
8601
  console.error("[ponch-mcp] Token expirado. Usa connect_account para reconectar.");
8314
- session.forceMode("tenant");
8602
+ session.revokeCrossTenant();
8315
8603
  }
8316
8604
  } else {
8317
8605
  console.error("[ponch-mcp] Sin conexion. Usa connect_account para conectar.");
@@ -8320,6 +8608,13 @@ async function main() {
8320
8608
  name: "ponch",
8321
8609
  version: "1.0.1"
8322
8610
  });
8611
+ server.server.oninitialized = () => {
8612
+ const ci = server.server.getClientVersion();
8613
+ if (ci) {
8614
+ session.setMcpClientIdentity(ci.name ?? null, ci.version ?? null);
8615
+ console.error(`[ponch-mcp] Cliente MCP: ${ci.name ?? "(sin name)"} v${ci.version ?? "(sin version)"}`);
8616
+ }
8617
+ };
8323
8618
  server.resource(
8324
8619
  "system-prompt",
8325
8620
  "ponch://system-prompt",