@hubfluencer/mcp 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -11982,7 +11982,9 @@ var require_dist = __commonJS((exports, module) => {
11982
11982
  });
11983
11983
 
11984
11984
  // src/index.ts
11985
- import { writeFile as writeFile2 } from "node:fs/promises";
11985
+ import { randomUUID } from "node:crypto";
11986
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "node:fs/promises";
11987
+ import { dirname as dirname2 } from "node:path";
11986
11988
 
11987
11989
  // ../../node_modules/@modelcontextprotocol/sdk/node_modules/zod/v3/helpers/util.js
11988
11990
  var util;
@@ -29336,33 +29338,85 @@ function isPrivateOrMetadataHost(hostname) {
29336
29338
  const zone = host.indexOf("%");
29337
29339
  if (zone !== -1)
29338
29340
  host = host.slice(0, zone);
29339
- const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
29340
- if (v4) {
29341
- const o = v4.slice(1).map(Number);
29342
- if (o.some((n) => n > 255))
29343
- return false;
29344
- return isPrivateV4(o[0], o[1]);
29345
- }
29346
- if (host.includes(":")) {
29347
- const mapped = /(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/.exec(host);
29348
- if (mapped) {
29349
- const o = mapped[1].split(".").map(Number);
29350
- if (!o.some((n) => n > 255) && isPrivateV4(o[0], o[1])) {
29351
- return true;
29352
- }
29353
- }
29354
- if (host === "::1" || host === "::")
29355
- return true;
29356
- if (host === "::ffff:0:0")
29357
- return true;
29358
- if (/^f[cd]/.test(host))
29359
- return true;
29360
- if (/^fe[89ab]/.test(host))
29361
- return true;
29341
+ const v4 = parseV4Octets(host);
29342
+ if (v4)
29343
+ return isPrivateV4(v4[0], v4[1]);
29344
+ if (!host.includes(":"))
29362
29345
  return false;
29346
+ const v6 = parseIPv6Words(host);
29347
+ if (!v6)
29348
+ return false;
29349
+ const embeddedV4 = embeddedIPv4Octets(v6);
29350
+ if (embeddedV4 && isPrivateV4(embeddedV4[0], embeddedV4[1]))
29351
+ return true;
29352
+ if (v6.every((word) => word === 0))
29353
+ return true;
29354
+ if (v6.slice(0, 7).every((word) => word === 0) && v6[7] === 1) {
29355
+ return true;
29363
29356
  }
29357
+ if ((v6[0] & 65024) === 64512)
29358
+ return true;
29359
+ if ((v6[0] & 65472) === 65152)
29360
+ return true;
29364
29361
  return false;
29365
29362
  }
29363
+ function parseV4Octets(host) {
29364
+ const v4 = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
29365
+ if (!v4)
29366
+ return;
29367
+ const octets = v4.slice(1).map(Number);
29368
+ return octets.some((n) => n > 255) ? undefined : octets;
29369
+ }
29370
+ function parseIPv6Words(host) {
29371
+ if (host.includes(".")) {
29372
+ const lastColon = host.lastIndexOf(":");
29373
+ if (lastColon === -1)
29374
+ return;
29375
+ const v4 = parseV4Octets(host.slice(lastColon + 1));
29376
+ if (!v4)
29377
+ return;
29378
+ host = `${host.slice(0, lastColon)}:${(v4[0] << 8 | v4[1]).toString(16)}:${(v4[2] << 8 | v4[3]).toString(16)}`;
29379
+ }
29380
+ const halves = host.split("::");
29381
+ if (halves.length > 2)
29382
+ return;
29383
+ const head = splitIPv6Half(halves[0]);
29384
+ const tail = halves.length === 2 ? splitIPv6Half(halves[1]) : [];
29385
+ if (!head || !tail)
29386
+ return;
29387
+ if (halves.length === 1)
29388
+ return head.length === 8 ? head : undefined;
29389
+ const missing = 8 - head.length - tail.length;
29390
+ if (missing < 1)
29391
+ return;
29392
+ return [...head, ...Array(missing).fill(0), ...tail];
29393
+ }
29394
+ function splitIPv6Half(half) {
29395
+ if (half === "")
29396
+ return [];
29397
+ const words = half.split(":");
29398
+ const parsed = [];
29399
+ for (const word of words) {
29400
+ if (!/^[0-9a-f]{1,4}$/.test(word))
29401
+ return;
29402
+ parsed.push(Number.parseInt(word, 16));
29403
+ }
29404
+ return parsed;
29405
+ }
29406
+ function embeddedIPv4Octets(v6) {
29407
+ const firstFiveZero = v6.slice(0, 5).every((word) => word === 0);
29408
+ if (firstFiveZero && v6[5] === 65535)
29409
+ return wordsToV4(v6[6], v6[7]);
29410
+ const firstSixZero = v6.slice(0, 6).every((word) => word === 0);
29411
+ if (firstSixZero && (v6[6] !== 0 || v6[7] !== 0)) {
29412
+ return wordsToV4(v6[6], v6[7]);
29413
+ }
29414
+ const isNat64WellKnown = v6[0] === 100 && v6[1] === 65435 && v6.slice(2, 6).every((word) => word === 0);
29415
+ return isNat64WellKnown ? wordsToV4(v6[6], v6[7]) : undefined;
29416
+ }
29417
+ function wordsToV4(hi, lo) {
29418
+ return [hi >> 8, hi & 255, lo >> 8, lo & 255];
29419
+ }
29366
29420
  function isPrivateV4(a, b) {
29367
29421
  if (a === 127)
29368
29422
  return true;
@@ -29397,6 +29451,35 @@ function assertSafeFetchUrl(url, opts = {}) {
29397
29451
  return u;
29398
29452
  throw new Error(`Refusing to use an insecure Hubfluencer URL (${u.protocol}//${host}). ` + "Use an https URL (or localhost for local development).");
29399
29453
  }
29454
+ var DEFAULT_RETRY = {
29455
+ attempts: 3,
29456
+ baseMs: 300,
29457
+ maxMs: 2000
29458
+ };
29459
+ function isRetryableStatus(status) {
29460
+ return status === 429 || status >= 500 && status <= 599;
29461
+ }
29462
+ function isRetryableNetworkError(err) {
29463
+ if (!(err instanceof Error))
29464
+ return false;
29465
+ if (err.retryable === true)
29466
+ return true;
29467
+ return err.name === "TypeError" || err.name === "TimeoutError" || err.name === "AbortError";
29468
+ }
29469
+ function backoffDelayMs(attempt, opts, rand = Math.random) {
29470
+ const capped = Math.min(opts.maxMs, opts.baseMs * 2 ** (attempt - 1));
29471
+ return Math.round(capped / 2 + rand() * (capped / 2));
29472
+ }
29473
+ function formatApiErrorMessage(f) {
29474
+ const base = `API error (HTTP ${f.status}${f.code ? `, ${f.code}` : ""}): ${f.message}`;
29475
+ const body = asRecord(f.body);
29476
+ const req = body.required_credits ?? body.required;
29477
+ const have = body.available_credits ?? body.available;
29478
+ if (req != null || have != null) {
29479
+ return `${base} (needs ${req ?? "?"} credits, have ${have ?? "?"}).`;
29480
+ }
29481
+ return base;
29482
+ }
29400
29483
  function asRecord(v) {
29401
29484
  return v && typeof v === "object" ? v : {};
29402
29485
  }
@@ -29416,6 +29499,33 @@ function normalizeStatus(kind, slug, data) {
29416
29499
  error: d.error_message ?? null
29417
29500
  };
29418
29501
  }
29502
+ if (kind === "tracking") {
29503
+ const result = asRecord(d.result);
29504
+ const renderStatus2 = result.status ?? null;
29505
+ const trackingUrl = result.video_url ?? null;
29506
+ const ready2 = renderStatus2 === "completed" && Boolean(trackingUrl);
29507
+ return {
29508
+ kind,
29509
+ slug,
29510
+ stage: renderStatus2 ?? d.status ?? "unknown",
29511
+ terminal: ready2 || renderStatus2 === "failed",
29512
+ ready: ready2,
29513
+ video_url: trackingUrl,
29514
+ error: result.error_message ?? null
29515
+ };
29516
+ }
29517
+ if (kind === "slider") {
29518
+ const stage = d.status ?? "unknown";
29519
+ return {
29520
+ kind,
29521
+ slug,
29522
+ stage,
29523
+ terminal: stage === "completed" || stage === "failed",
29524
+ ready: stage === "completed",
29525
+ video_url: null,
29526
+ error: d.error_message ?? null
29527
+ };
29528
+ }
29419
29529
  const autopilot = d.autopilot_status ?? "unknown";
29420
29530
  const renderStatus = latest.status ?? null;
29421
29531
  const ready = renderStatus === "completed" && Boolean(videoUrl);
@@ -29430,9 +29540,44 @@ function normalizeStatus(kind, slug, data) {
29430
29540
  error: d.autopilot_error_message ?? null
29431
29541
  };
29432
29542
  }
29543
+ function startStateDiscriminator(kind, data) {
29544
+ const d = asRecord(data);
29545
+ const latest = asRecord(d.latest_render);
29546
+ const renderSig = `${latest.id ?? ""}:${latest.version ?? ""}`;
29547
+ if (kind === "short") {
29548
+ const stage = d.stage ?? "";
29549
+ if (stage !== "failed")
29550
+ return "start";
29551
+ return `failed:${d.failed_stage ?? ""}:${renderSig}`;
29552
+ }
29553
+ const autopilot = d.autopilot_status ?? "";
29554
+ const renderStatus = latest.status ?? "";
29555
+ const failed = autopilot === "failed" || autopilot === "cancelled" || renderStatus === "failed";
29556
+ if (!failed)
29557
+ return "start";
29558
+ return `failed:${autopilot}:${d.autopilot_error_step ?? ""}:${renderSig}`;
29559
+ }
29433
29560
  function idemKey(...parts) {
29434
29561
  return createHash("sha256").update(parts.join("|")).digest("hex").slice(0, 32);
29435
29562
  }
29563
+ function makeVideoEditorCreateKey(a) {
29564
+ return idemKey("make-editor", a.prompt, a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.aspect ?? "", a.voice_id ?? "", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "");
29565
+ }
29566
+ function makeVideoAutopilotKey(slug, a, startState) {
29567
+ return idemKey("autopilot", slug, a.prompt, a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.aspect ?? "", a.voice_id ?? "", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", startState);
29568
+ }
29569
+ function editorAdCreateKey(a) {
29570
+ return idemKey("create-editor", a.product_prompt ?? "", a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", a.voice_id ?? "", a.export_aspect_ratio ?? "");
29571
+ }
29572
+ function editorAdAutopilotKey(slug, a) {
29573
+ return idemKey("autopilot", slug, a.product_prompt ?? "", a.product_subject ?? "", a.project_intent ?? "", a.language ?? "en", a.creative_format ?? "", a.visual_language ?? "", a.theme ?? "", a.voice_id ?? "", a.export_aspect_ratio ?? "");
29574
+ }
29575
+ function sliderCreateKey(a) {
29576
+ return idemKey("create-slider", a.prompt, a.mode ?? "", a.template ?? "", a.slide_count === undefined ? "" : String(a.slide_count), a.aspect_ratio ?? "", a.accent_color ?? "", a.text_position ?? "");
29577
+ }
29578
+ function addSegmentKey(slug, index, nonce) {
29579
+ return idemKey("add-segment", slug, String(index), nonce);
29580
+ }
29436
29581
  function resolveSavePath(savePath) {
29437
29582
  const base = resolve(process.env.HUBFLUENCER_OUTPUT_DIR || process.cwd());
29438
29583
  const target = isAbsolute(savePath) ? resolve(savePath) : resolve(base, savePath);
@@ -29452,6 +29597,499 @@ function inferKind(prompt) {
29452
29597
  const wantsEditor = /\b(ads?|advert|advertisement|commercial|promo|campaign|launch|brand|story|stories|multi[- ]?scene|scenes?|narrat|explainer|chapters?|episodes?|testimonial|showcase|walkthrough|demo|spot)\b/.test(p) || /(pub(licit[ée])?|annonce|campagne|histoire|multi[- ]?sc[eè]ne|sc[eè]nes?|raconte|chapitres?|[eé]pisodes?|explicat|lancement|t[eé]moignage|d[eé]mo|vitrine)/.test(p);
29453
29598
  return wantsEditor ? "editor" : "short";
29454
29599
  }
29600
+ function validateLanguage(language) {
29601
+ if (language === undefined)
29602
+ return null;
29603
+ const len = language.trim().length;
29604
+ if (len < 2 || len > 10) {
29605
+ return 'language must be a 2–10 character code (e.g. "en", "fr").';
29606
+ }
29607
+ return null;
29608
+ }
29609
+ function validateProductPrompt(prompt) {
29610
+ if (prompt === undefined)
29611
+ return null;
29612
+ const len = prompt.trim().length;
29613
+ if (len === 0)
29614
+ return null;
29615
+ if (len < 10 || len > 5000) {
29616
+ return "product_prompt, when provided, must be 10–5000 characters.";
29617
+ }
29618
+ return null;
29619
+ }
29620
+ function validateKind(kind) {
29621
+ if (kind === undefined || kind === "short" || kind === "editor" || kind === "auto") {
29622
+ return null;
29623
+ }
29624
+ return `kind must be one of: short, editor, auto (got "${kind}"). ` + "Use create_slider for carousels or create_tracking_video for tracking overlays.";
29625
+ }
29626
+ function decideTrackingPlan(existing, args, freshNonce) {
29627
+ const idemBase = args.slug ? idemKey("tracking", args.slug) : idemKey("tracking", args.video_path ?? "", freshNonce);
29628
+ const hasLiveSource = existing?.source != null && existing.source.status !== "failed";
29629
+ const resultStatus = existing?.result?.status;
29630
+ const configSupplied = args.classes !== undefined || args.color !== undefined;
29631
+ const reRenderCompletedWithNewConfig = resultStatus === "completed" && configSupplied;
29632
+ const shouldRender = !existing?.result || resultStatus === "failed" || reRenderCompletedWithNewConfig;
29633
+ const renderAttempt = resultStatus === "failed" ? `retry:${existing?.result?.id ?? "unknown"}` : reRenderCompletedWithNewConfig ? `rerender:${existing?.result?.id ?? "unknown"}` : "new";
29634
+ const renderKey = idemKey(idemBase, "render", renderAttempt, JSON.stringify(args.classes ?? null), JSON.stringify(args.color ?? null));
29635
+ return {
29636
+ idemBase,
29637
+ hasLiveSource,
29638
+ configSupplied,
29639
+ reRenderCompletedWithNewConfig,
29640
+ shouldRender,
29641
+ renderAttempt,
29642
+ renderKey
29643
+ };
29644
+ }
29645
+
29646
+ // src/campaign.ts
29647
+ function statusOf(e) {
29648
+ const s = e?.status;
29649
+ return typeof s === "number" ? s : undefined;
29650
+ }
29651
+ var PRODUCT_PROFILE_FIELDS = [
29652
+ "name",
29653
+ "source_url",
29654
+ "description",
29655
+ "benefits",
29656
+ "offer",
29657
+ "proof_points",
29658
+ "audience",
29659
+ "cta",
29660
+ "banned_claims",
29661
+ "brand_tone",
29662
+ "metadata"
29663
+ ];
29664
+ function extractedProductToProfileAttrs(product, sourceUrl) {
29665
+ const p = product ?? {};
29666
+ const attrs = {};
29667
+ const str = (v) => {
29668
+ if (typeof v !== "string")
29669
+ return;
29670
+ const t = v.trim();
29671
+ return t === "" ? undefined : t;
29672
+ };
29673
+ const list = (v) => {
29674
+ if (!Array.isArray(v))
29675
+ return;
29676
+ const arr = v.filter((x) => typeof x === "string" && x.trim() !== "");
29677
+ return arr.length > 0 ? arr : undefined;
29678
+ };
29679
+ const name = str(p.name);
29680
+ if (name !== undefined)
29681
+ attrs.name = name;
29682
+ const description = str(p.description);
29683
+ if (description !== undefined)
29684
+ attrs.description = description;
29685
+ const benefits = list(p.benefits);
29686
+ if (benefits !== undefined)
29687
+ attrs.benefits = benefits;
29688
+ const offer = str(p.offer);
29689
+ if (offer !== undefined)
29690
+ attrs.offer = offer;
29691
+ const proof = list(p.proof_points);
29692
+ if (proof !== undefined)
29693
+ attrs.proof_points = proof;
29694
+ const audience = str(p.audience);
29695
+ if (audience !== undefined)
29696
+ attrs.audience = audience;
29697
+ const cta = str(p.cta);
29698
+ if (cta !== undefined)
29699
+ attrs.cta = cta;
29700
+ const banned = list(p.banned_claims);
29701
+ if (banned !== undefined)
29702
+ attrs.banned_claims = banned;
29703
+ const tone = str(p.brand_tone);
29704
+ if (tone !== undefined)
29705
+ attrs.brand_tone = tone;
29706
+ const src = str(sourceUrl);
29707
+ if (src !== undefined)
29708
+ attrs.source_url = src;
29709
+ return attrs;
29710
+ }
29711
+ function pickProductProfileFields(args) {
29712
+ const out = {};
29713
+ for (const k of PRODUCT_PROFILE_FIELDS) {
29714
+ if (args[k] !== undefined)
29715
+ out[k] = args[k];
29716
+ }
29717
+ return out;
29718
+ }
29719
+ var BRAND_PROFILE_FIELDS = [
29720
+ "name",
29721
+ "logo_s3_key",
29722
+ "primary_color",
29723
+ "secondary_color",
29724
+ "font_family",
29725
+ "tone_words",
29726
+ "default_cta",
29727
+ "default_audience",
29728
+ "banned_claims",
29729
+ "preferred_visual_language",
29730
+ "preferred_creative_format",
29731
+ "metadata"
29732
+ ];
29733
+ function pickBrandProfileFields(args) {
29734
+ const out = {};
29735
+ for (const k of BRAND_PROFILE_FIELDS) {
29736
+ if (args[k] !== undefined)
29737
+ out[k] = args[k];
29738
+ }
29739
+ return out;
29740
+ }
29741
+ function validatePlanSource(productProfileId, product) {
29742
+ const hasProfile = typeof productProfileId === "number" && Number.isInteger(productProfileId) && productProfileId > 0;
29743
+ const hasProduct = product != null && typeof product === "object" && typeof product.name === "string" && product.name.trim() !== "";
29744
+ if (hasProfile || hasProduct)
29745
+ return null;
29746
+ return "Provide either product_profile_id (a saved profile) or a product object with a non-empty name.";
29747
+ }
29748
+ function buildPlanBody(args) {
29749
+ const body = {};
29750
+ if (args.product_profile_id !== undefined)
29751
+ body.product_profile_id = args.product_profile_id;
29752
+ if (args.product !== undefined)
29753
+ body.product = args.product;
29754
+ const str = (v) => typeof v === "string" && v.trim() !== "" ? v.trim() : undefined;
29755
+ const goal = str(args.goal);
29756
+ if (goal !== undefined)
29757
+ body.goal = goal;
29758
+ const platform = str(args.platform);
29759
+ if (platform !== undefined)
29760
+ body.platform = platform;
29761
+ const language = str(args.language);
29762
+ if (language !== undefined)
29763
+ body.language = language;
29764
+ if (args.brand_profile_id !== undefined)
29765
+ body.brand_profile_id = args.brand_profile_id;
29766
+ if (args.count !== undefined)
29767
+ body.count = args.count;
29768
+ return body;
29769
+ }
29770
+ var MAX_VARIATIONS_BATCH = 12;
29771
+ function validateVariationsBatch(variations) {
29772
+ if (!Array.isArray(variations) || variations.length === 0) {
29773
+ return "Provide at least one variation.";
29774
+ }
29775
+ if (variations.length > MAX_VARIATIONS_BATCH) {
29776
+ return `Provide at most ${MAX_VARIATIONS_BATCH} variations per request (got ${variations.length}).`;
29777
+ }
29778
+ return null;
29779
+ }
29780
+ function draftRouteFor(kind, slug) {
29781
+ if (slug === null)
29782
+ return null;
29783
+ if (kind === "carousel")
29784
+ return `/slider/${slug}`;
29785
+ if (kind === "short")
29786
+ return `/short/${slug}`;
29787
+ if (kind === "editor_ad")
29788
+ return `/editor/${slug}`;
29789
+ return null;
29790
+ }
29791
+ function summarizePackItem(item) {
29792
+ const it = asRecord(item);
29793
+ const meta2 = asRecord(it.metadata);
29794
+ const factorySlug = typeof it.video_factory_slug === "string" ? it.video_factory_slug : null;
29795
+ const sliderSlug = typeof it.slider_slug === "string" ? it.slider_slug : null;
29796
+ const draftSlug = factorySlug ?? sliderSlug;
29797
+ return {
29798
+ id: it.id,
29799
+ kind: it.kind,
29800
+ status: it.status,
29801
+ hook: it.hook ?? null,
29802
+ video_factory_slug: factorySlug,
29803
+ slider_slug: sliderSlug,
29804
+ draft_slug: draftSlug,
29805
+ draft_route: draftRouteFor(it.kind, draftSlug),
29806
+ credit_estimate: meta2.credit_estimate ?? null,
29807
+ preview_url: it.preview_url ?? null,
29808
+ video_result_id: it.video_result_id ?? null
29809
+ };
29810
+ }
29811
+ function summarizePack(pack) {
29812
+ const p = asRecord(pack);
29813
+ const items = Array.isArray(p.items) ? p.items.map(summarizePackItem) : [];
29814
+ return {
29815
+ id: p.id,
29816
+ title: p.title ?? null,
29817
+ status: p.status,
29818
+ source_type: p.source_type ?? null,
29819
+ source_url: p.source_url ?? null,
29820
+ brief: p.brief ?? null,
29821
+ product_profile_id: p.product_profile_id ?? null,
29822
+ brand_profile_id: p.brand_profile_id ?? null,
29823
+ item_count: items.length,
29824
+ items
29825
+ };
29826
+ }
29827
+ function summarizePackListEntry(pack) {
29828
+ const p = asRecord(pack);
29829
+ return {
29830
+ id: p.id,
29831
+ title: p.title ?? null,
29832
+ status: p.status,
29833
+ source_type: p.source_type ?? null,
29834
+ source_url: p.source_url ?? null,
29835
+ brief: p.brief ?? null,
29836
+ product_profile_id: p.product_profile_id ?? null,
29837
+ brand_profile_id: p.brand_profile_id ?? null
29838
+ };
29839
+ }
29840
+ var HOOK_MIN = 3;
29841
+ var HOOK_MAX = 6;
29842
+ async function runCreateCampaign(client, args, log = () => {}) {
29843
+ const steps = [];
29844
+ const record3 = (step, ok, detail) => {
29845
+ steps.push({ step, ok, ...detail ? { detail } : {} });
29846
+ log(`${ok ? "ok" : "skip"}: ${step}${detail ? ` — ${detail}` : ""}`);
29847
+ };
29848
+ let productProfileId = args.product_profile_id ?? null;
29849
+ let extracted = null;
29850
+ const imported = { primary_image: false, logo: false };
29851
+ let extractedProduct;
29852
+ if (productProfileId === null) {
29853
+ if (!args.url) {
29854
+ throw new Error("Provide either url or product_profile_id.");
29855
+ }
29856
+ const res = await client.post("/product-sources/extract", {
29857
+ url: args.url
29858
+ });
29859
+ const data = asRecord(asRecord(res).data);
29860
+ extractedProduct = asRecord(data.product);
29861
+ extracted = {
29862
+ source_url: data.source_url ?? args.url,
29863
+ confidence: data.confidence ?? null,
29864
+ name: extractedProduct.name ?? null
29865
+ };
29866
+ record3("extract_product_page", true, `confidence=${data.confidence ?? "?"}`);
29867
+ const attrs = extractedProductToProfileAttrs(extractedProduct, extracted.source_url ?? args.url);
29868
+ if (typeof attrs.name !== "string" || attrs.name.trim() === "") {
29869
+ attrs.name = fallbackNameFromUrl(args.url);
29870
+ }
29871
+ const created = await client.post("/product-profiles", attrs);
29872
+ productProfileId = asRecord(asRecord(created).data).id;
29873
+ record3("create_product_profile", true, `id=${productProfileId}`);
29874
+ const primaryUrl = firstString(extractedProduct.images);
29875
+ if (primaryUrl) {
29876
+ try {
29877
+ await client.post("/product-sources/import-image", {
29878
+ image_url: primaryUrl,
29879
+ product_profile_id: productProfileId,
29880
+ link_as: "primary_image"
29881
+ });
29882
+ imported.primary_image = true;
29883
+ record3("import_primary_image", true);
29884
+ } catch (e) {
29885
+ record3("import_primary_image", false, describeErr(e));
29886
+ }
29887
+ }
29888
+ const logoUrl = typeof extractedProduct.logo === "string" ? extractedProduct.logo : undefined;
29889
+ if (logoUrl && logoUrl.trim() !== "") {
29890
+ try {
29891
+ await client.post("/product-sources/import-image", {
29892
+ image_url: logoUrl,
29893
+ product_profile_id: productProfileId,
29894
+ link_as: "logo"
29895
+ });
29896
+ imported.logo = true;
29897
+ record3("import_logo", true);
29898
+ } catch (e) {
29899
+ record3("import_logo", false, describeErr(e));
29900
+ }
29901
+ }
29902
+ } else {
29903
+ record3("use_product_profile", true, `id=${productProfileId}`);
29904
+ }
29905
+ let positioning = null;
29906
+ try {
29907
+ const planRes = await client.post("/campaign-packs/plan", buildPlanBody({
29908
+ product_profile_id: productProfileId,
29909
+ goal: args.goal,
29910
+ platform: args.platform,
29911
+ language: args.language
29912
+ }));
29913
+ const planData = asRecord(asRecord(planRes).data);
29914
+ positioning = typeof planData.positioning === "string" ? planData.positioning : null;
29915
+ record3("plan_campaign", true);
29916
+ } catch (e) {
29917
+ if (statusOf(e) === 429) {
29918
+ record3("plan_campaign", false, "ai_assist_quota_exceeded (unlock_ai_assists to add +10, or continue)");
29919
+ } else {
29920
+ record3("plan_campaign", false, describeErr(e));
29921
+ }
29922
+ }
29923
+ const count = clampCount(args.item_count);
29924
+ let variations = [];
29925
+ try {
29926
+ const hookRes = await client.post("/campaign-packs/hook-variations", buildPlanBody({
29927
+ product_profile_id: productProfileId,
29928
+ brand_profile_id: args.brand_profile_id,
29929
+ count,
29930
+ goal: args.goal,
29931
+ platform: args.platform,
29932
+ language: args.language
29933
+ }));
29934
+ const hookData = asRecord(asRecord(hookRes).data);
29935
+ variations = Array.isArray(hookData.variations) ? hookData.variations : [];
29936
+ record3("generate_hook_variations", true, `${variations.length} variations`);
29937
+ } catch (e) {
29938
+ const quota = statusOf(e) === 429;
29939
+ record3("generate_hook_variations", false, quota ? "ai_assist_quota_exceeded" : describeErr(e));
29940
+ return partialResult({
29941
+ productProfileId,
29942
+ campaignPackId: null,
29943
+ brandProfileId: args.brand_profile_id ?? null,
29944
+ extracted,
29945
+ imported,
29946
+ items: [],
29947
+ positioning,
29948
+ steps,
29949
+ next: quota ? `generate_hook_variations({ product_profile_id: ${productProfileId} }) once the daily AI-assist quota resets, or unlock_ai_assists first, then create_campaign_pack + add_pack_variations.` : `generate_hook_variations({ product_profile_id: ${productProfileId} }), then create_campaign_pack + add_pack_variations.`,
29950
+ note: quota ? "Stopped at hook variations: the free daily AI-assist quota is exhausted. The product profile is saved — resume once it resets (or unlock more)." : "Stopped at hook variations. The product profile is saved — resume with the granular tools."
29951
+ });
29952
+ }
29953
+ if (variations.length === 0) {
29954
+ return partialResult({
29955
+ productProfileId,
29956
+ campaignPackId: null,
29957
+ brandProfileId: args.brand_profile_id ?? null,
29958
+ extracted,
29959
+ imported,
29960
+ items: [],
29961
+ positioning,
29962
+ steps,
29963
+ next: `generate_hook_variations({ product_profile_id: ${productProfileId} }) to retry, then create_campaign_pack + add_pack_variations.`,
29964
+ note: "Hook variations came back empty — retry generate_hook_variations, then build the pack."
29965
+ });
29966
+ }
29967
+ const filtered = filterVariationsByFormat(variations, args.formats);
29968
+ const chosen = (filtered.length > 0 ? filtered : variations).slice(0, MAX_VARIATIONS_BATCH);
29969
+ const packBody = {
29970
+ product_profile_id: productProfileId
29971
+ };
29972
+ if (args.brand_profile_id !== undefined)
29973
+ packBody.brand_profile_id = args.brand_profile_id;
29974
+ if (args.language !== undefined && args.language.trim() !== "")
29975
+ packBody.language = args.language;
29976
+ if (extracted?.source_url) {
29977
+ packBody.source_type = "url";
29978
+ packBody.source_url = extracted.source_url;
29979
+ }
29980
+ if (typeof extractedProduct?.name === "string")
29981
+ packBody.title = extractedProduct.name;
29982
+ const packRes = await client.post("/campaign-packs", packBody);
29983
+ const campaignPackId = asRecord(asRecord(packRes).data).id;
29984
+ record3("create_campaign_pack", true, `id=${campaignPackId}`);
29985
+ let items = [];
29986
+ let summary = { created: 0, materialized: 0, planned: 0 };
29987
+ try {
29988
+ const varRes = await client.post(`/campaign-packs/${campaignPackId}/variations`, { variations: chosen, materialize: true });
29989
+ const varData = asRecord(asRecord(varRes).data);
29990
+ items = Array.isArray(varData.items) ? varData.items.map(summarizePackItem) : [];
29991
+ const s = asRecord(varData.summary);
29992
+ summary = {
29993
+ created: numberOr(s.created, items.length),
29994
+ materialized: numberOr(s.materialized, 0),
29995
+ planned: numberOr(s.planned, 0)
29996
+ };
29997
+ record3("add_pack_variations", true, `${summary.materialized}/${summary.created} materialized`);
29998
+ } catch (e) {
29999
+ record3("add_pack_variations", false, describeErr(e));
30000
+ return partialResult({
30001
+ productProfileId,
30002
+ campaignPackId,
30003
+ brandProfileId: args.brand_profile_id ?? null,
30004
+ extracted,
30005
+ imported,
30006
+ items: [],
30007
+ positioning,
30008
+ steps,
30009
+ next: `add_pack_variations({ pack_id: ${campaignPackId}, variations: [...], materialize: true }) to populate the pack (up to ${MAX_VARIATIONS_BATCH} per call), or get_campaign_pack({ id: ${campaignPackId} }) to inspect it.`,
30010
+ note: "The pack was created but adding its variations failed — resume with add_pack_variations."
30011
+ });
30012
+ }
30013
+ const draftable = summary.materialized;
30014
+ return {
30015
+ product_profile_id: productProfileId,
30016
+ campaign_pack_id: campaignPackId,
30017
+ brand_profile_id: args.brand_profile_id ?? null,
30018
+ extracted,
30019
+ imported,
30020
+ items,
30021
+ summary,
30022
+ steps,
30023
+ next: `get_campaign_pack({ id: ${campaignPackId} }) to review the drafts; then, for EACH draft you want to publish, generate it EXPLICITLY (a SEPARATE, PAID step): editor_ad/short → start_autopilot({ slug }) or the granular generate/render tools; carousel → generate_slider({ slug }).`,
30024
+ note: `Campaign pack ${campaignPackId} created with ${summary.created} item(s), ${draftable} materialized into editable DRAFTS. ` + (positioning ? `Positioning: ${positioning}. ` : "") + "$0 so far (free AI-assist quota + 0-credit drafts). Generation/render is a separate, explicit, PAID step per draft — nothing was generated."
30025
+ };
30026
+ }
30027
+ function partialResult(p) {
30028
+ const materialized = p.items.filter((i) => i.draft_slug !== null).length;
30029
+ return {
30030
+ product_profile_id: p.productProfileId,
30031
+ campaign_pack_id: p.campaignPackId,
30032
+ brand_profile_id: p.brandProfileId,
30033
+ extracted: p.extracted,
30034
+ imported: p.imported,
30035
+ items: p.items,
30036
+ summary: {
30037
+ created: p.items.length,
30038
+ materialized,
30039
+ planned: p.items.length - materialized
30040
+ },
30041
+ steps: p.steps,
30042
+ next: p.next,
30043
+ note: p.note
30044
+ };
30045
+ }
30046
+ function clampCount(n) {
30047
+ if (typeof n !== "number" || !Number.isFinite(n))
30048
+ return HOOK_MAX;
30049
+ return Math.min(HOOK_MAX, Math.max(HOOK_MIN, Math.round(n)));
30050
+ }
30051
+ function filterVariationsByFormat(variations, formats) {
30052
+ if (!Array.isArray(formats) || formats.length === 0)
30053
+ return variations;
30054
+ const wanted = new Set(formats.map(normalizeFormat));
30055
+ return variations.filter((v) => wanted.has(normalizeFormat(asRecord(v).suggested_format)));
30056
+ }
30057
+ function normalizeFormat(v) {
30058
+ if (typeof v !== "string")
30059
+ return "";
30060
+ return v.trim().toLowerCase().replace(/[\s-]+/g, "_");
30061
+ }
30062
+ function firstString(arr) {
30063
+ if (!Array.isArray(arr))
30064
+ return;
30065
+ for (const v of arr) {
30066
+ if (typeof v === "string" && v.trim() !== "")
30067
+ return v;
30068
+ }
30069
+ return;
30070
+ }
30071
+ function numberOr(v, fallback) {
30072
+ return typeof v === "number" && Number.isFinite(v) ? v : fallback;
30073
+ }
30074
+ function describeErr(e) {
30075
+ const code = e?.code;
30076
+ if (typeof code === "string" && code !== "")
30077
+ return code;
30078
+ const status = statusOf(e);
30079
+ if (e instanceof Error && e.message)
30080
+ return status ? `HTTP ${status}: ${e.message}` : e.message;
30081
+ return status ? `HTTP ${status}` : "failed";
30082
+ }
30083
+ function fallbackNameFromUrl(url) {
30084
+ if (typeof url !== "string" || url.trim() === "")
30085
+ return "Imported product";
30086
+ try {
30087
+ const host = new URL(url).hostname.replace(/^www\./, "");
30088
+ return host || "Imported product";
30089
+ } catch {
30090
+ return "Imported product";
30091
+ }
30092
+ }
29455
30093
 
29456
30094
  // src/credentials.ts
29457
30095
  import { readFileSync, statSync } from "node:fs";
@@ -29479,8 +30117,85 @@ function readStoredCredentials() {
29479
30117
  return {};
29480
30118
  }
29481
30119
  }
30120
+ // package.json
30121
+ var package_default = {
30122
+ name: "@hubfluencer/mcp",
30123
+ version: "0.6.0",
30124
+ description: "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
30125
+ license: "MIT",
30126
+ author: "Monocursive <contact@monocursive.com>",
30127
+ repository: {
30128
+ type: "git",
30129
+ url: "git+https://github.com/monocursive/monohub.git",
30130
+ directory: "packages/mcp"
30131
+ },
30132
+ homepage: "https://github.com/monocursive/monohub/tree/master/packages/mcp#readme",
30133
+ bugs: "https://github.com/monocursive/monohub/issues",
30134
+ private: false,
30135
+ type: "module",
30136
+ bin: {
30137
+ "hubfluencer-mcp": "./dist/index.js"
30138
+ },
30139
+ main: "./dist/index.js",
30140
+ files: [
30141
+ "dist",
30142
+ "src",
30143
+ "tsconfig.json",
30144
+ "README.md",
30145
+ "LICENSE"
30146
+ ],
30147
+ scripts: {
30148
+ dev: "bun run src/index.ts",
30149
+ build: "rm -rf dist && bun build src/index.ts --target node --outdir dist",
30150
+ prepublishOnly: "bun run build",
30151
+ start: "node dist/index.js",
30152
+ test: "bun test",
30153
+ typecheck: "tsc -p tsconfig.json --noEmit",
30154
+ lint: "biome check src",
30155
+ release: "bun run typecheck && bun run lint && bun test && npm publish --access public"
30156
+ },
30157
+ dependencies: {
30158
+ "@modelcontextprotocol/sdk": "^1.29.0",
30159
+ zod: "^3.23.8"
30160
+ },
30161
+ devDependencies: {
30162
+ "@biomejs/biome": "^2.3.8",
30163
+ "@types/node": "^22.10.0",
30164
+ typescript: "^5.9.3"
30165
+ },
30166
+ engines: {
30167
+ node: ">=18"
30168
+ },
30169
+ keywords: [
30170
+ "mcp",
30171
+ "model-context-protocol",
30172
+ "hubfluencer",
30173
+ "ai",
30174
+ "video",
30175
+ "ads",
30176
+ "claude"
30177
+ ]
30178
+ };
30179
+
30180
+ // src/version.ts
30181
+ var VERSION = package_default.version;
30182
+ var USER_AGENT = `hubfluencer-mcp/${VERSION}`;
29482
30183
 
29483
30184
  // src/client.ts
30185
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30186
+ var deadlinePassed = (deadline) => deadline !== undefined && Date.now() >= deadline;
30187
+ function composeChangesetMessage(errors4) {
30188
+ const parts = [];
30189
+ for (const field of Object.keys(errors4).sort()) {
30190
+ const val = errors4[field];
30191
+ const msgs = Array.isArray(val) ? val.map((m) => String(m)).filter((m) => m.length > 0) : val !== undefined && val !== null && String(val).length > 0 ? [String(val)] : [];
30192
+ if (msgs.length > 0)
30193
+ parts.push(`${field}: ${msgs.join(", ")}`);
30194
+ }
30195
+ if (parts.length === 0)
30196
+ return null;
30197
+ return `validation failed — ${parts.join("; ")}`;
30198
+ }
29484
30199
  function makeError(status, body) {
29485
30200
  let message = `Hubfluencer API error (HTTP ${status})`;
29486
30201
  let code;
@@ -29500,6 +30215,12 @@ function makeError(status, body) {
29500
30215
  message = e.message || message;
29501
30216
  } else if (typeof b.message === "string") {
29502
30217
  message = b.message;
30218
+ } else if (b.errors && typeof b.errors === "object") {
30219
+ const composed = composeChangesetMessage(b.errors);
30220
+ if (composed) {
30221
+ message = composed;
30222
+ code = "validation_failed";
30223
+ }
29503
30224
  }
29504
30225
  }
29505
30226
  const err = new Error(message);
@@ -29511,6 +30232,8 @@ function makeError(status, body) {
29511
30232
  function assertSafeBaseUrl(baseUrl) {
29512
30233
  assertSafeFetchUrl(baseUrl, { allowLoopback: true });
29513
30234
  }
30235
+ var MIN_ATTEMPT_TIMEOUT_MS = 1000;
30236
+ var DEFAULT_ATTEMPT_TIMEOUT_MS = 60000;
29514
30237
 
29515
30238
  class HubfluencerClient {
29516
30239
  baseUrl;
@@ -29531,43 +30254,63 @@ class HubfluencerClient {
29531
30254
  }
29532
30255
  const headers = {
29533
30256
  authorization: `Bearer ${this.token}`,
29534
- accept: "application/json"
30257
+ accept: "application/json",
30258
+ "user-agent": USER_AGENT
29535
30259
  };
29536
30260
  if (opts.body !== undefined)
29537
30261
  headers["content-type"] = "application/json";
29538
30262
  if (opts.idempotencyKey)
29539
30263
  headers["idempotency-key"] = opts.idempotencyKey;
29540
- let res;
29541
- try {
29542
- res = await fetch(url, {
29543
- method,
29544
- headers,
29545
- body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
29546
- signal: AbortSignal.timeout(60000)
29547
- });
29548
- } catch (e) {
29549
- if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
29550
- throw new Error(`Request to ${method} ${path} timed out after 60s.`);
29551
- }
29552
- throw e instanceof Error ? new Error(`Network error on ${method} ${path}: ${e.message}`) : e;
29553
- }
29554
- if (res.status === 204)
29555
- return;
29556
- const text = await res.text();
29557
- let parsed;
29558
- if (text) {
30264
+ const canRetry = method === "GET";
30265
+ const attempts = canRetry ? DEFAULT_RETRY.attempts : 1;
30266
+ const deadline = canRetry ? opts.deadline : undefined;
30267
+ for (let attempt = 1;; attempt++) {
30268
+ let attemptTimeoutMs = DEFAULT_ATTEMPT_TIMEOUT_MS;
30269
+ if (deadline !== undefined) {
30270
+ const remaining = deadline - Date.now();
30271
+ attemptTimeoutMs = Math.max(MIN_ATTEMPT_TIMEOUT_MS, Math.min(DEFAULT_ATTEMPT_TIMEOUT_MS, remaining));
30272
+ }
30273
+ let res;
29559
30274
  try {
29560
- parsed = JSON.parse(text);
29561
- } catch {
29562
- parsed = text;
30275
+ res = await fetch(url, {
30276
+ method,
30277
+ headers,
30278
+ body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
30279
+ signal: AbortSignal.timeout(attemptTimeoutMs)
30280
+ });
30281
+ } catch (e) {
30282
+ if (attempt < attempts && isRetryableNetworkError(e) && !deadlinePassed(deadline)) {
30283
+ await sleep(backoffDelayMs(attempt, DEFAULT_RETRY));
30284
+ continue;
30285
+ }
30286
+ if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
30287
+ throw new Error(`Request to ${method} ${path} timed out after ${attemptTimeoutMs}ms.`);
30288
+ }
30289
+ throw e instanceof Error ? new Error(`Network error on ${method} ${path}: ${e.message}`) : e;
30290
+ }
30291
+ if (attempt < attempts && isRetryableStatus(res.status) && !deadlinePassed(deadline)) {
30292
+ await res.text().catch(() => {});
30293
+ await sleep(backoffDelayMs(attempt, DEFAULT_RETRY));
30294
+ continue;
29563
30295
  }
30296
+ if (res.status === 204)
30297
+ return;
30298
+ const text = await res.text();
30299
+ let parsed;
30300
+ if (text) {
30301
+ try {
30302
+ parsed = JSON.parse(text);
30303
+ } catch {
30304
+ parsed = text;
30305
+ }
30306
+ }
30307
+ if (!res.ok)
30308
+ throw makeError(res.status, parsed);
30309
+ return parsed;
29564
30310
  }
29565
- if (!res.ok)
29566
- throw makeError(res.status, parsed);
29567
- return parsed;
29568
30311
  }
29569
- get(path, query) {
29570
- return this.request("GET", path, { query });
30312
+ get(path, query, deadline) {
30313
+ return this.request("GET", path, { query, deadline });
29571
30314
  }
29572
30315
  post(path, body, idempotencyKey) {
29573
30316
  return this.request("POST", path, { body, idempotencyKey });
@@ -29581,14 +30324,22 @@ class HubfluencerClient {
29581
30324
  del(path) {
29582
30325
  return this.request("DELETE", path);
29583
30326
  }
30327
+ get origin() {
30328
+ return this.baseUrl;
30329
+ }
29584
30330
  }
29585
30331
  function clientFromEnv() {
29586
30332
  const stored = readStoredCredentials();
29587
- const token = process.env.HUBFLUENCER_API_TOKEN || stored.token;
29588
- const baseUrl = process.env.HUBFLUENCER_BASE_URL || stored.base_url || "https://hubfluencer.com";
30333
+ const envToken = process.env.HUBFLUENCER_API_TOKEN;
30334
+ const envBaseUrl = process.env.HUBFLUENCER_BASE_URL;
30335
+ const token = envToken || stored.token;
30336
+ const baseUrl = envBaseUrl || stored.base_url || "https://hubfluencer.com";
29589
30337
  if (!token) {
29590
30338
  throw new Error("Not connected to Hubfluencer. Run `npx -y @hubfluencer/mcp login` to connect, or set " + "HUBFLUENCER_API_TOKEN (create one in the app: Settings → Access tokens).");
29591
30339
  }
30340
+ if (envToken && !envBaseUrl && stored.base_url) {
30341
+ console.error(`Warning: using HUBFLUENCER_API_TOKEN from the environment but base URL ${stored.base_url} ` + "from the stored credentials file. Set HUBFLUENCER_BASE_URL to override.");
30342
+ }
29592
30343
  return new HubfluencerClient(baseUrl, token);
29593
30344
  }
29594
30345
 
@@ -29596,7 +30347,7 @@ function clientFromEnv() {
29596
30347
  import { chmod, mkdir, writeFile } from "node:fs/promises";
29597
30348
  import { dirname } from "node:path";
29598
30349
  var MAX_POLLS = 120;
29599
- var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
30350
+ var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
29600
30351
  async function runLogin(clientName = "Claude Code") {
29601
30352
  const BASE = (process.env.HUBFLUENCER_BASE_URL || "https://hubfluencer.com").replace(/\/+$/, "").replace(/\/api$/, "");
29602
30353
  assertSafeBaseUrl(BASE);
@@ -29625,7 +30376,7 @@ Connect this agent to Hubfluencer:
29625
30376
  let intervalMs = Math.max(2, start.interval ?? 5) * 1000;
29626
30377
  const MAX_INTERVAL_MS = 30000;
29627
30378
  for (let i = 0;i < MAX_POLLS; i++) {
29628
- await sleep(intervalMs);
30379
+ await sleep2(intervalMs);
29629
30380
  const pollRes = await fetch(`${BASE}/api/agent-link/poll`, {
29630
30381
  method: "POST",
29631
30382
  headers: {
@@ -29669,14 +30420,557 @@ async function storeToken(token, base) {
29669
30420
  } catch {}
29670
30421
  }
29671
30422
 
30423
+ // src/output-schemas.ts
30424
+ var urlish = exports_external.unknown().optional();
30425
+ var getStatusOutput = exports_external.object({
30426
+ kind: exports_external.string().optional(),
30427
+ slug: exports_external.string().optional(),
30428
+ stage: exports_external.string().optional(),
30429
+ terminal: exports_external.boolean().optional(),
30430
+ ready: exports_external.boolean().optional(),
30431
+ video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30432
+ error: exports_external.union([exports_external.string(), exports_external.null()]).optional()
30433
+ }).passthrough();
30434
+ var waitForCompletionOutput = exports_external.object({
30435
+ kind: exports_external.string().optional(),
30436
+ slug: exports_external.string().optional(),
30437
+ stage: exports_external.string().optional(),
30438
+ terminal: exports_external.boolean().optional(),
30439
+ ready: exports_external.boolean().optional(),
30440
+ video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30441
+ error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30442
+ saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30443
+ timed_out: exports_external.boolean().optional()
30444
+ }).passthrough();
30445
+ var getCreditsOutput = exports_external.object({
30446
+ credits: exports_external.unknown().optional(),
30447
+ spendable_credits: exports_external.unknown().optional(),
30448
+ reserved_credits: exports_external.unknown().optional(),
30449
+ remaining_reserved_credits: exports_external.unknown().optional(),
30450
+ message: exports_external.unknown().optional()
30451
+ }).passthrough();
30452
+ var downloadResultOutput = exports_external.object({
30453
+ video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30454
+ saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30455
+ bytes: exports_external.number().optional(),
30456
+ note: exports_external.string().optional()
30457
+ }).passthrough();
30458
+ var sliderSlide = exports_external.object({
30459
+ position: exports_external.unknown().optional(),
30460
+ image_url: urlish,
30461
+ headline: exports_external.unknown().optional(),
30462
+ body: exports_external.unknown().optional(),
30463
+ kicker: exports_external.unknown().optional(),
30464
+ status: exports_external.unknown().optional()
30465
+ }).passthrough();
30466
+ var getSliderOutput = exports_external.object({
30467
+ slug: exports_external.string().optional(),
30468
+ kind: exports_external.string().optional(),
30469
+ status: exports_external.string().optional(),
30470
+ stage: exports_external.string().optional(),
30471
+ terminal: exports_external.boolean().optional(),
30472
+ completed: exports_external.boolean().optional(),
30473
+ error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30474
+ caption: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30475
+ hashtags: exports_external.array(exports_external.unknown()).optional(),
30476
+ slides: exports_external.array(sliderSlide).optional()
30477
+ }).passthrough();
30478
+ var projectRow = exports_external.object({
30479
+ kind: exports_external.string().optional(),
30480
+ slug: exports_external.string().optional(),
30481
+ stage: exports_external.string().optional(),
30482
+ terminal: exports_external.boolean().optional(),
30483
+ ready: exports_external.boolean().optional(),
30484
+ video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30485
+ error: exports_external.union([exports_external.string(), exports_external.null()]).optional()
30486
+ }).passthrough();
30487
+ var listProjectsOutput = exports_external.object({
30488
+ shorts: exports_external.array(projectRow).optional(),
30489
+ projects: exports_external.array(projectRow).optional(),
30490
+ note: exports_external.string().optional()
30491
+ }).passthrough();
30492
+ var createDraftOutput = exports_external.object({
30493
+ slug: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30494
+ kind: exports_external.string().optional(),
30495
+ next: exports_external.string().optional()
30496
+ }).passthrough();
30497
+ var createEditorOutput = exports_external.object({
30498
+ slug: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30499
+ kind: exports_external.string().optional(),
30500
+ status: projectRow.optional(),
30501
+ next: exports_external.string().optional()
30502
+ }).passthrough();
30503
+ var makeVideoOutput = exports_external.object({
30504
+ slug: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30505
+ kind: exports_external.string().optional(),
30506
+ kind_inferred: exports_external.boolean().optional(),
30507
+ stage: exports_external.string().optional(),
30508
+ terminal: exports_external.boolean().optional(),
30509
+ ready: exports_external.boolean().optional(),
30510
+ video_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30511
+ error: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30512
+ estimated_credits: exports_external.unknown().optional(),
30513
+ available_credits: exports_external.unknown().optional(),
30514
+ affordable: exports_external.boolean().optional(),
30515
+ charged: exports_external.boolean().optional(),
30516
+ saved_to: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30517
+ timed_out: exports_external.boolean().optional(),
30518
+ note: exports_external.string().optional()
30519
+ }).passthrough();
30520
+ var packItem = exports_external.object({
30521
+ id: exports_external.unknown().optional(),
30522
+ kind: exports_external.unknown().optional(),
30523
+ status: exports_external.unknown().optional(),
30524
+ hook: exports_external.unknown().optional(),
30525
+ video_factory_slug: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30526
+ slider_slug: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30527
+ draft_slug: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30528
+ draft_route: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30529
+ credit_estimate: exports_external.unknown().optional(),
30530
+ preview_url: exports_external.unknown().optional(),
30531
+ video_result_id: exports_external.unknown().optional()
30532
+ }).passthrough();
30533
+ var getCampaignPackOutput = exports_external.object({
30534
+ id: exports_external.unknown().optional(),
30535
+ title: exports_external.unknown().optional(),
30536
+ status: exports_external.unknown().optional(),
30537
+ source_type: exports_external.unknown().optional(),
30538
+ source_url: exports_external.unknown().optional(),
30539
+ brief: exports_external.unknown().optional(),
30540
+ product_profile_id: exports_external.unknown().optional(),
30541
+ brand_profile_id: exports_external.unknown().optional(),
30542
+ item_count: exports_external.number().optional(),
30543
+ items: exports_external.array(packItem).optional()
30544
+ }).passthrough();
30545
+ var createCampaignOutput = exports_external.object({
30546
+ product_profile_id: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
30547
+ campaign_pack_id: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
30548
+ brand_profile_id: exports_external.union([exports_external.number(), exports_external.null()]).optional(),
30549
+ extracted: exports_external.union([
30550
+ exports_external.object({
30551
+ source_url: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30552
+ confidence: exports_external.unknown().optional(),
30553
+ name: exports_external.unknown().optional()
30554
+ }).passthrough(),
30555
+ exports_external.null()
30556
+ ]).optional(),
30557
+ imported: exports_external.object({
30558
+ primary_image: exports_external.boolean().optional(),
30559
+ logo: exports_external.boolean().optional()
30560
+ }).passthrough().optional(),
30561
+ items: exports_external.array(packItem).optional(),
30562
+ summary: exports_external.object({
30563
+ created: exports_external.number().optional(),
30564
+ materialized: exports_external.number().optional(),
30565
+ planned: exports_external.number().optional()
30566
+ }).passthrough().optional(),
30567
+ steps: exports_external.array(exports_external.object({
30568
+ step: exports_external.string().optional(),
30569
+ ok: exports_external.boolean().optional(),
30570
+ detail: exports_external.string().optional()
30571
+ }).passthrough()).optional(),
30572
+ next: exports_external.string().optional(),
30573
+ note: exports_external.string().optional()
30574
+ }).passthrough();
30575
+ var episodeRow = exports_external.object({
30576
+ id: exports_external.unknown().optional(),
30577
+ status: exports_external.unknown().optional(),
30578
+ draft_slug: exports_external.union([exports_external.string(), exports_external.null()]).optional(),
30579
+ draft_route: exports_external.union([exports_external.string(), exports_external.null()]).optional()
30580
+ }).passthrough();
30581
+ var getSeriesDashboardOutput = exports_external.object({
30582
+ series: exports_external.record(exports_external.string(), exports_external.unknown()).optional(),
30583
+ upcoming: exports_external.array(episodeRow).optional(),
30584
+ drafted: exports_external.array(episodeRow).optional(),
30585
+ published: exports_external.array(episodeRow).optional(),
30586
+ performance_notes: exports_external.array(exports_external.unknown()).optional()
30587
+ }).passthrough();
30588
+ var scheduledPost = exports_external.object({
30589
+ id: exports_external.unknown().optional(),
30590
+ platform: exports_external.unknown().optional(),
30591
+ status: exports_external.unknown().optional(),
30592
+ video_result_id: exports_external.unknown().optional(),
30593
+ video_url: exports_external.unknown().optional(),
30594
+ video_factory_kind: exports_external.unknown().optional(),
30595
+ campaign_pack_item_id: exports_external.unknown().optional(),
30596
+ caption: exports_external.unknown().optional(),
30597
+ hashtags: exports_external.unknown().optional(),
30598
+ scheduled_at: exports_external.unknown().optional(),
30599
+ timezone: exports_external.unknown().optional(),
30600
+ posted_at: exports_external.unknown().optional(),
30601
+ published_at: exports_external.unknown().optional(),
30602
+ error_message: exports_external.unknown().optional()
30603
+ }).passthrough();
30604
+ var listScheduledPostsOutput = exports_external.object({
30605
+ posts: exports_external.array(scheduledPost).optional()
30606
+ }).passthrough();
30607
+ var getRecommendationsOutput = exports_external.object({
30608
+ confidence: exports_external.unknown().optional(),
30609
+ note: exports_external.unknown().optional()
30610
+ }).passthrough();
30611
+
30612
+ // src/perf-review-assets.ts
30613
+ var PERFORMANCE_PLATFORMS = ["tiktok", "instagram", "other"];
30614
+ var PERFORMANCE_SOURCES = [
30615
+ "manual",
30616
+ "tiktok_api",
30617
+ "instagram_api"
30618
+ ];
30619
+ var PERFORMANCE_COUNT_FIELDS = [
30620
+ "views",
30621
+ "likes",
30622
+ "comments",
30623
+ "shares",
30624
+ "clicks",
30625
+ "conversions",
30626
+ "watch_time_seconds"
30627
+ ];
30628
+ var MAX_PERFORMANCE_REVENUE = 999999999999.99;
30629
+ function validatePerformanceMetrics(args) {
30630
+ for (const field of PERFORMANCE_COUNT_FIELDS) {
30631
+ const v = args[field];
30632
+ if (v === undefined)
30633
+ continue;
30634
+ if (!Number.isFinite(v) || !Number.isInteger(v) || v < 0) {
30635
+ return `${field} must be a non-negative integer.`;
30636
+ }
30637
+ }
30638
+ if (args.revenue !== undefined) {
30639
+ if (!Number.isFinite(args.revenue) || args.revenue < 0) {
30640
+ return "revenue must be a non-negative number.";
30641
+ }
30642
+ if (args.revenue > MAX_PERFORMANCE_REVENUE) {
30643
+ return `revenue must be at most ${MAX_PERFORMANCE_REVENUE}.`;
30644
+ }
30645
+ }
30646
+ return null;
30647
+ }
30648
+ function buildPerformanceBody(args, now = () => new Date) {
30649
+ const body = { platform: args.platform };
30650
+ for (const field of PERFORMANCE_COUNT_FIELDS) {
30651
+ if (args[field] !== undefined)
30652
+ body[field] = args[field];
30653
+ }
30654
+ if (args.revenue !== undefined)
30655
+ body.revenue = args.revenue;
30656
+ if (typeof args.source === "string" && args.source.trim() !== "")
30657
+ body.source = args.source;
30658
+ body.captured_at = typeof args.captured_at === "string" && args.captured_at.trim() !== "" ? args.captured_at : now().toISOString();
30659
+ if (args.metadata !== undefined)
30660
+ body.metadata = args.metadata;
30661
+ return body;
30662
+ }
30663
+ function summarizePerformance(snapshot) {
30664
+ const s = asRecord(snapshot);
30665
+ return {
30666
+ id: s.id,
30667
+ campaign_pack_item_id: s.campaign_pack_item_id,
30668
+ platform: s.platform,
30669
+ views: s.views ?? null,
30670
+ likes: s.likes ?? null,
30671
+ comments: s.comments ?? null,
30672
+ shares: s.shares ?? null,
30673
+ clicks: s.clicks ?? null,
30674
+ conversions: s.conversions ?? null,
30675
+ revenue: s.revenue ?? null,
30676
+ watch_time_seconds: s.watch_time_seconds ?? null,
30677
+ source: s.source ?? null,
30678
+ captured_at: s.captured_at ?? null
30679
+ };
30680
+ }
30681
+ var CREATIVE_ATTRIBUTE_FIELDS = [
30682
+ "hook_type",
30683
+ "creative_format",
30684
+ "visual_language",
30685
+ "product_category",
30686
+ "cta",
30687
+ "length",
30688
+ "caption_style",
30689
+ "metadata"
30690
+ ];
30691
+ function pickCreativeAttributeFields(args) {
30692
+ const out = {};
30693
+ for (const k of CREATIVE_ATTRIBUTE_FIELDS) {
30694
+ if (args[k] !== undefined)
30695
+ out[k] = args[k];
30696
+ }
30697
+ return out;
30698
+ }
30699
+ var REVIEW_LINK_MIN_EXPIRES_IN_HOURS = 1;
30700
+ var REVIEW_LINK_MAX_EXPIRES_IN_HOURS = 720;
30701
+ function validateReviewOutput(videoResultId, sliderId) {
30702
+ const hasVideo = typeof videoResultId === "number";
30703
+ const hasSlider = typeof sliderId === "number";
30704
+ if (hasVideo && hasSlider) {
30705
+ return "Provide exactly one of video_result_id or slider_id, not both.";
30706
+ }
30707
+ if (!hasVideo && !hasSlider) {
30708
+ return "Provide exactly one of video_result_id (a completed render) or slider_id (a completed carousel).";
30709
+ }
30710
+ return null;
30711
+ }
30712
+ function buildReviewLinkBody(args) {
30713
+ const body = {};
30714
+ if (args.video_result_id !== undefined)
30715
+ body.video_result_id = args.video_result_id;
30716
+ if (args.slider_id !== undefined)
30717
+ body.slider_id = args.slider_id;
30718
+ if (args.campaign_pack_id !== undefined)
30719
+ body.campaign_pack_id = args.campaign_pack_id;
30720
+ if (args.expires_in_hours !== undefined)
30721
+ body.expires_in_hours = args.expires_in_hours;
30722
+ if (args.download_enabled !== undefined)
30723
+ body.download_enabled = args.download_enabled;
30724
+ if (typeof args.version_label === "string" && args.version_label.trim() !== "")
30725
+ body.version_label = args.version_label;
30726
+ return body;
30727
+ }
30728
+ function summarizeReviewLink(link) {
30729
+ const l = asRecord(link);
30730
+ return {
30731
+ id: l.id,
30732
+ status: l.status,
30733
+ output_kind: l.output_kind ?? null,
30734
+ video_result_id: l.video_result_id ?? null,
30735
+ slider_id: l.slider_id ?? null,
30736
+ campaign_pack_id: l.campaign_pack_id ?? null,
30737
+ expires_at: l.expires_at ?? null,
30738
+ download_enabled: l.download_enabled ?? null,
30739
+ version_label: l.version_label ?? null,
30740
+ view_count: l.view_count ?? null,
30741
+ viewed_at: l.viewed_at ?? null,
30742
+ comment_count: l.comment_count ?? null
30743
+ };
30744
+ }
30745
+ var DEFAULT_WEB_APP_ORIGIN = "https://app.hubfluencer.com";
30746
+ function reviewWebOrigin(env = process.env) {
30747
+ const configured = env.HUBFLUENCER_WEB_URL;
30748
+ if (typeof configured === "string" && configured.trim() !== "") {
30749
+ return configured.trim().replace(/\/+$/, "");
30750
+ }
30751
+ return DEFAULT_WEB_APP_ORIGIN;
30752
+ }
30753
+ function shapeCreateReviewLink(data, webOrigin) {
30754
+ const d = asRecord(data);
30755
+ const token = d.token ?? null;
30756
+ const reviewPath = typeof d.review_path === "string" ? d.review_path : null;
30757
+ const reviewUrl = absoluteReviewUrl(reviewPath, webOrigin);
30758
+ return {
30759
+ review_link: summarizeReviewLink(d.review_link),
30760
+ token,
30761
+ review_path: reviewPath,
30762
+ review_url: reviewUrl,
30763
+ important: "SAVE THE TOKEN NOW — it is shown only ONCE and is NOT retrievable again. " + "Only its hash is stored server-side; list_review_links and revoke_review_link never return it. " + `Share the review URL${reviewUrl ? ` (${reviewUrl})` : ""} with your reviewer — they open it with no account.`,
30764
+ next: "list_review_links to track approval/comment activity; revoke_review_link({ id }) to disable the URL."
30765
+ };
30766
+ }
30767
+ function absoluteReviewUrl(reviewPath, webOrigin) {
30768
+ if (!reviewPath)
30769
+ return null;
30770
+ if (typeof webOrigin !== "string" || webOrigin.trim() === "")
30771
+ return reviewPath;
30772
+ try {
30773
+ return new URL(reviewPath, webOrigin).toString();
30774
+ } catch {
30775
+ return reviewPath;
30776
+ }
30777
+ }
30778
+ function summarizeCatalogAsset(asset) {
30779
+ const a = asRecord(asset);
30780
+ return {
30781
+ id: a.id,
30782
+ filename: a.filename ?? null,
30783
+ media_type: a.media_type ?? null,
30784
+ mime_type: a.mime_type ?? null,
30785
+ size_bytes: a.size_bytes ?? null,
30786
+ description: a.description ?? null,
30787
+ status: a.status ?? null,
30788
+ asset_url: a.asset_url ?? null,
30789
+ inserted_at: a.inserted_at ?? null
30790
+ };
30791
+ }
30792
+ function summarizeAssetQuota(quota) {
30793
+ const q = asRecord(quota);
30794
+ return {
30795
+ limit_bytes: q.limit_bytes ?? null,
30796
+ used_bytes: q.used_bytes ?? null,
30797
+ pending_bytes: q.pending_bytes ?? null,
30798
+ reserved_bytes: q.reserved_bytes ?? null,
30799
+ remaining_bytes: q.remaining_bytes ?? null
30800
+ };
30801
+ }
30802
+ function assetSubscriptionErrorText(status, code, message) {
30803
+ if (status !== 402 || code !== "subscription_required")
30804
+ return message;
30805
+ return `${message} The reusable asset catalog requires an ACTIVE SUBSCRIPTION; ` + "this is a billing gate, not a scope or credit issue, so retrying won't help — " + "subscribe in the app first. (Generating videos/carousels does not require this.)";
30806
+ }
30807
+
30808
+ // src/series-calendar.ts
30809
+ var SERIES_FIELDS = [
30810
+ "brand_profile_id",
30811
+ "product_profile_id",
30812
+ "name",
30813
+ "template",
30814
+ "cadence",
30815
+ "tone",
30816
+ "default_format",
30817
+ "status",
30818
+ "metadata"
30819
+ ];
30820
+ function pickSeriesFields(args) {
30821
+ const out = {};
30822
+ for (const k of SERIES_FIELDS) {
30823
+ if (args[k] !== undefined)
30824
+ out[k] = args[k];
30825
+ }
30826
+ return out;
30827
+ }
30828
+ function episodeDraftRoute(format, factorySlug, sliderSlug) {
30829
+ if (format === "carousel")
30830
+ return sliderSlug ? `/slider/${sliderSlug}` : null;
30831
+ if (format === "short")
30832
+ return factorySlug ? `/short/${factorySlug}` : null;
30833
+ if (format === "editor_ad")
30834
+ return factorySlug ? `/editor/${factorySlug}` : null;
30835
+ return null;
30836
+ }
30837
+ function summarizeEpisode(episode) {
30838
+ const e = asRecord(episode);
30839
+ const factorySlug = typeof e.video_factory_slug === "string" ? e.video_factory_slug : null;
30840
+ const sliderSlug = typeof e.slider_slug === "string" ? e.slider_slug : null;
30841
+ const draftSlug = factorySlug ?? sliderSlug;
30842
+ return {
30843
+ id: e.id,
30844
+ series_id: e.series_id,
30845
+ position: e.position,
30846
+ status: e.status,
30847
+ hook: e.hook ?? null,
30848
+ brief: e.brief ?? null,
30849
+ caption: e.caption ?? null,
30850
+ suggested_format: e.suggested_format ?? null,
30851
+ hashtags: e.hashtags ?? [],
30852
+ posted_at: e.posted_at ?? null,
30853
+ campaign_pack_item_id: e.campaign_pack_item_id ?? null,
30854
+ video_factory_slug: factorySlug,
30855
+ slider_slug: sliderSlug,
30856
+ draft_slug: draftSlug,
30857
+ draft_route: episodeDraftRoute(e.suggested_format, factorySlug, sliderSlug)
30858
+ };
30859
+ }
30860
+ function summarizeSeries(series) {
30861
+ const s = asRecord(series);
30862
+ return {
30863
+ id: s.id,
30864
+ name: s.name ?? null,
30865
+ template: s.template ?? null,
30866
+ cadence: s.cadence ?? null,
30867
+ tone: s.tone ?? null,
30868
+ default_format: s.default_format ?? null,
30869
+ status: s.status ?? null,
30870
+ brand_profile_id: s.brand_profile_id ?? null,
30871
+ product_profile_id: s.product_profile_id ?? null,
30872
+ campaign_pack_id: s.campaign_pack_id ?? null
30873
+ };
30874
+ }
30875
+ function summarizeDashboard(dashboard) {
30876
+ const d = asRecord(dashboard);
30877
+ const lane = (v) => Array.isArray(v) ? v.map(summarizeEpisode) : [];
30878
+ return {
30879
+ series: summarizeSeries(d.series),
30880
+ upcoming: lane(d.upcoming),
30881
+ drafted: lane(d.drafted),
30882
+ published: lane(d.published),
30883
+ performance_notes: Array.isArray(d.performance_notes) ? d.performance_notes : []
30884
+ };
30885
+ }
30886
+ var SCHEDULED_POST_STATUSES = [
30887
+ "scheduled",
30888
+ "reminded",
30889
+ "posted",
30890
+ "publishing",
30891
+ "published",
30892
+ "failed",
30893
+ "canceled"
30894
+ ];
30895
+ var SCHEDULED_POST_PLATFORMS = [
30896
+ "tiktok",
30897
+ "instagram",
30898
+ "other"
30899
+ ];
30900
+ var MAX_CAPTION_LENGTH = 5000;
30901
+ var MAX_HASHTAGS = 30;
30902
+ var MAX_HASHTAG_LENGTH = 100;
30903
+ var MAX_PENDING_SCHEDULED_POSTS = 500;
30904
+ function validateScheduledPostCopy(args) {
30905
+ if (typeof args.caption === "string" && args.caption.length > MAX_CAPTION_LENGTH) {
30906
+ return `caption must be at most ${MAX_CAPTION_LENGTH} characters.`;
30907
+ }
30908
+ if (args.hashtags !== undefined) {
30909
+ if (args.hashtags.length > MAX_HASHTAGS) {
30910
+ return `Provide at most ${MAX_HASHTAGS} hashtags (got ${args.hashtags.length}).`;
30911
+ }
30912
+ const tooLong = args.hashtags.find((h) => h.length > MAX_HASHTAG_LENGTH);
30913
+ if (tooLong !== undefined) {
30914
+ return `each hashtag must be at most ${MAX_HASHTAG_LENGTH} characters.`;
30915
+ }
30916
+ }
30917
+ return null;
30918
+ }
30919
+ function buildScheduledPostBody(args) {
30920
+ const body = {
30921
+ video_result_id: args.video_result_id,
30922
+ platform: args.platform,
30923
+ scheduled_at: args.scheduled_at
30924
+ };
30925
+ const str = (v) => typeof v === "string" && v.trim() !== "" ? v : undefined;
30926
+ const caption = str(args.caption);
30927
+ if (caption !== undefined)
30928
+ body.caption = caption;
30929
+ if (args.hashtags !== undefined)
30930
+ body.hashtags = args.hashtags;
30931
+ const timezone = str(args.timezone);
30932
+ if (timezone !== undefined)
30933
+ body.timezone = timezone;
30934
+ if (args.campaign_pack_item_id !== undefined)
30935
+ body.campaign_pack_item_id = args.campaign_pack_item_id;
30936
+ return body;
30937
+ }
30938
+ function summarizeScheduledPost(post) {
30939
+ const p = asRecord(post);
30940
+ return {
30941
+ id: p.id,
30942
+ platform: p.platform,
30943
+ status: p.status,
30944
+ video_result_id: p.video_result_id ?? null,
30945
+ video_url: p.video_url ?? null,
30946
+ video_factory_kind: p.video_factory_kind ?? null,
30947
+ campaign_pack_item_id: p.campaign_pack_item_id ?? null,
30948
+ caption: p.caption ?? null,
30949
+ hashtags: p.hashtags ?? [],
30950
+ scheduled_at: p.scheduled_at ?? null,
30951
+ timezone: p.timezone ?? null,
30952
+ posted_at: p.posted_at ?? null,
30953
+ published_at: p.published_at ?? null,
30954
+ error_message: p.error_message ?? null
30955
+ };
30956
+ }
30957
+ function calendarScopeErrorText(status, code, message) {
30958
+ if (status !== 403 || code !== "insufficient_scope")
30959
+ return message;
30960
+ return `${message} This calendar tool only creates manual-posting REMINDERS and never attaches a social ` + "account, so this scope refusal means either the targeted post already references a social account " + "(an app-only auto-publish that needs account:admin) or the token predates the reminder-calendar " + "permission. Publishing stays in the app; reschedule/cancel/mark a reminder-only post instead.";
30961
+ }
30962
+
29672
30963
  // src/uploads.ts
30964
+ import { createReadStream } from "node:fs";
29673
30965
  import { open, readFile, stat } from "node:fs/promises";
29674
30966
  import { basename, extname, isAbsolute as isAbsolute2, resolve as resolve2, sep as sep2 } from "node:path";
30967
+ var sleep3 = (ms) => new Promise((r) => setTimeout(r, ms));
30968
+ var PART_RETRIES = 2;
29675
30969
  var ALLOW_LOOPBACK_FETCH = /^http:\/\/(localhost|127\.0\.0\.1|\[?::1\]?)/.test(process.env.HUBFLUENCER_BASE_URL || "");
29676
30970
  var MAX_VIDEO_BYTES = 500000000;
29677
30971
  var MAX_IMAGE_BYTES = 20 * 1024 * 1024;
29678
- var MAX_LOGO_BYTES = 20 * 1024 * 1024;
29679
30972
  var MULTIPART_THRESHOLD = 50 * 1024 * 1024;
30973
+ var MAX_CATALOG_VIDEO_DURATION_SECONDS = 60;
29680
30974
  var VIDEO_EXT_MIME = {
29681
30975
  mp4: "video/mp4",
29682
30976
  mov: "video/quicktime",
@@ -29690,6 +30984,14 @@ var IMAGE_EXT_MIME = {
29690
30984
  };
29691
30985
  var VIDEO_EXTS = Object.keys(VIDEO_EXT_MIME);
29692
30986
  var IMAGE_EXTS = Object.keys(IMAGE_EXT_MIME);
30987
+ var CATALOG_EXT_MIME = {
30988
+ ...IMAGE_EXT_MIME,
30989
+ ...VIDEO_EXT_MIME
30990
+ };
30991
+ var CATALOG_ASSET_EXTS = Object.keys(CATALOG_EXT_MIME);
30992
+ function catalogMaxBytes(mime) {
30993
+ return mime.startsWith("video/") ? MAX_VIDEO_BYTES : MAX_IMAGE_BYTES;
30994
+ }
29693
30995
  function planMultipartParts(size, partSize) {
29694
30996
  if (!Number.isFinite(size) || size <= 0) {
29695
30997
  throw new Error(`size must be a positive number, got ${size}.`);
@@ -29707,6 +31009,11 @@ function planMultipartParts(size, partSize) {
29707
31009
  }
29708
31010
  return parts;
29709
31011
  }
31012
+ function assertFullRead(bytesRead, len, partNumber, offset) {
31013
+ if (bytesRead !== len) {
31014
+ throw new Error(`Short read on part ${partNumber}: got ${bytesRead} of ${len} bytes at offset ${offset} (file changed under us?).`);
31015
+ }
31016
+ }
29710
31017
  async function resolveReadPath(filePath, extToMime, maxBytes) {
29711
31018
  if (!filePath || typeof filePath !== "string") {
29712
31019
  throw new Error("file_path is required.");
@@ -29738,25 +31045,40 @@ async function resolveReadPath(filePath, extToMime, maxBytes) {
29738
31045
  }
29739
31046
  return { path: target, ext, mime, size };
29740
31047
  }
29741
- async function putToPresignedUrl(url, body, contentType, timeoutMs = 300000) {
31048
+ async function resolveVideoReadPath(filePath) {
31049
+ return resolveReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
31050
+ }
31051
+ async function putToPresignedUrl(url, body, contentType, timeoutMs = 300000, contentLength) {
29742
31052
  assertSafeFetchUrl(url, { allowLoopback: ALLOW_LOOPBACK_FETCH });
29743
- const headers = {};
31053
+ const headers = { "user-agent": USER_AGENT };
29744
31054
  if (contentType)
29745
31055
  headers["content-type"] = contentType;
29746
- const ab = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
29747
- let resp;
29748
- try {
29749
- resp = await fetch(url, {
31056
+ let fetchBody;
31057
+ let fetchInit;
31058
+ let stream;
31059
+ if (Buffer.isBuffer(body)) {
31060
+ fetchBody = body.buffer.slice(body.byteOffset, body.byteOffset + body.byteLength);
31061
+ fetchInit = { method: "PUT", headers, body: fetchBody };
31062
+ } else {
31063
+ stream = createReadStream(body.filePath);
31064
+ headers["content-length"] = String(contentLength ?? body.size);
31065
+ fetchInit = {
29750
31066
  method: "PUT",
29751
31067
  headers,
29752
- body: ab,
29753
- signal: AbortSignal.timeout(timeoutMs)
29754
- });
31068
+ body: stream,
31069
+ duplex: "half"
31070
+ };
31071
+ }
31072
+ fetchInit.signal = AbortSignal.timeout(timeoutMs);
31073
+ let resp;
31074
+ try {
31075
+ resp = await fetch(url, fetchInit);
29755
31076
  } catch (e) {
31077
+ stream?.destroy();
29756
31078
  if (e instanceof Error && (e.name === "TimeoutError" || e.name === "AbortError")) {
29757
- throw new Error(`Upload PUT timed out after ${timeoutMs / 1000}s.`);
31079
+ throw markRetryable(`Upload PUT timed out after ${timeoutMs / 1000}s.`);
29758
31080
  }
29759
- throw e instanceof Error ? new Error(`Upload PUT failed: ${e.message}`) : e;
31081
+ throw e instanceof Error ? markRetryable(`Upload PUT failed: ${e.message}`) : e;
29760
31082
  }
29761
31083
  if (!resp.ok) {
29762
31084
  const text = await resp.text().catch(() => "");
@@ -29764,6 +31086,9 @@ async function putToPresignedUrl(url, body, contentType, timeoutMs = 300000) {
29764
31086
  }
29765
31087
  return resp.headers.get("etag") ?? undefined;
29766
31088
  }
31089
+ function markRetryable(message) {
31090
+ return Object.assign(new Error(message), { retryable: true });
31091
+ }
29767
31092
  async function uploadVideoFile(client, slug, filePath, opts = {}) {
29768
31093
  const file = await resolveReadPath(filePath, VIDEO_EXT_MIME, MAX_VIDEO_BYTES);
29769
31094
  const filename = basename(file.path);
@@ -29774,7 +31099,7 @@ async function uploadVideoFile(client, slug, filePath, opts = {}) {
29774
31099
  filename,
29775
31100
  fit_mode,
29776
31101
  product_description
29777
- });
31102
+ }, opts.onProgress);
29778
31103
  }
29779
31104
  const presign = await client.post(`/editor/${slug}/uploads/presign`, {
29780
31105
  filename,
@@ -29785,11 +31110,35 @@ async function uploadVideoFile(client, slug, filePath, opts = {}) {
29785
31110
  });
29786
31111
  const { upload_id, presigned_url } = presign.data;
29787
31112
  const buf = await readFile(file.path);
31113
+ await opts.onProgress?.(0, 1, `uploading ${filename}`);
29788
31114
  await putToPresignedUrl(presigned_url, buf, file.mime);
31115
+ await opts.onProgress?.(1, 1, `uploaded ${filename}, confirming`);
29789
31116
  const confirmed = await client.post(`/editor/${slug}/uploads/${upload_id}/confirm`);
29790
31117
  return { upload_id, status: confirmed.data?.status ?? "processing" };
29791
31118
  }
29792
- async function uploadVideoMultipart(client, slug, file, meta2) {
31119
+ async function uploadPartWithRetry(client, slug, ids, part_number, chunk) {
31120
+ for (let attempt = 1;; attempt++) {
31121
+ try {
31122
+ const sign = await client.post(`/editor/${slug}/uploads/multipart/sign-part`, {
31123
+ upload_id: ids.upload_id,
31124
+ s3_upload_id: ids.s3_upload_id,
31125
+ part_number
31126
+ });
31127
+ const etag = await putToPresignedUrl(sign.data.presigned_url, chunk, undefined);
31128
+ if (!etag) {
31129
+ throw new Error(`Part ${part_number} returned no ETag from S3.`);
31130
+ }
31131
+ return etag;
31132
+ } catch (e) {
31133
+ if (attempt <= PART_RETRIES && isRetryableNetworkError(e)) {
31134
+ await sleep3(backoffDelayMs(attempt, DEFAULT_RETRY));
31135
+ continue;
31136
+ }
31137
+ throw e;
31138
+ }
31139
+ }
31140
+ }
31141
+ async function uploadVideoMultipart(client, slug, file, meta2, onProgress) {
29793
31142
  const init = await client.post(`/editor/${slug}/uploads/multipart/init`, {
29794
31143
  filename: meta2.filename,
29795
31144
  mime_type: file.mime,
@@ -29802,18 +31151,17 @@ async function uploadVideoMultipart(client, slug, file, meta2) {
29802
31151
  if (plan.length !== parts_count) {
29803
31152
  throw new Error(`Multipart plan mismatch: server expects ${parts_count} parts, computed ${plan.length} for ${file.size} bytes at ${part_size}/part.`);
29804
31153
  }
31154
+ await onProgress?.(0, plan.length, `uploading ${meta2.filename} in ${plan.length} parts`);
29805
31155
  const fh = await open(file.path, "r");
29806
31156
  const parts = [];
29807
31157
  try {
29808
31158
  for (const { part_number, offset, len } of plan) {
29809
31159
  const chunk = Buffer.alloc(len);
29810
- await fh.read(chunk, 0, len, offset);
29811
- const sign = await client.post(`/editor/${slug}/uploads/multipart/sign-part`, { upload_id, s3_upload_id, part_number });
29812
- const etag = await putToPresignedUrl(sign.data.presigned_url, chunk, undefined);
29813
- if (!etag) {
29814
- throw new Error(`Part ${part_number} returned no ETag from S3.`);
29815
- }
31160
+ const { bytesRead } = await fh.read(chunk, 0, len, offset);
31161
+ assertFullRead(bytesRead, len, part_number, offset);
31162
+ const etag = await uploadPartWithRetry(client, slug, { upload_id, s3_upload_id }, part_number, chunk);
29816
31163
  parts.push({ part_number, etag });
31164
+ await onProgress?.(part_number, plan.length, `uploaded part ${part_number}/${plan.length}`);
29817
31165
  }
29818
31166
  const done = await client.post(`/editor/${slug}/uploads/multipart/complete`, { upload_id, s3_upload_id, parts });
29819
31167
  return { upload_id, status: done.data?.status ?? "processing" };
@@ -29848,19 +31196,97 @@ async function uploadShortPoster(client, slug, filePath) {
29848
31196
  await putToPresignedUrl(upload_url, buf, file.mime);
29849
31197
  return { s3_key };
29850
31198
  }
31199
+ async function uploadCatalogAsset(client, filePath, opts = {}) {
31200
+ const file = await resolveReadPath(filePath, CATALOG_EXT_MIME, MAX_VIDEO_BYTES);
31201
+ const cap = catalogMaxBytes(file.mime);
31202
+ if (file.size > cap) {
31203
+ throw new Error(`${file.path} is ${file.size} bytes — over the ${cap}-byte cap for ${file.mime} catalog uploads.`);
31204
+ }
31205
+ if (file.mime.startsWith("video/")) {
31206
+ const duration3 = opts.durationSeconds;
31207
+ if (typeof duration3 !== "number" || !Number.isFinite(duration3) || duration3 <= 0 || duration3 > MAX_CATALOG_VIDEO_DURATION_SECONDS) {
31208
+ throw new Error(`Catalog video uploads require durationSeconds to be greater than 0 and no more than ${MAX_CATALOG_VIDEO_DURATION_SECONDS} seconds.`);
31209
+ }
31210
+ }
31211
+ const filename = basename(file.path);
31212
+ const presign = await client.post("/assets/presign", {
31213
+ filename,
31214
+ mime_type: file.mime,
31215
+ size_bytes: file.size
31216
+ });
31217
+ const { asset_id, presigned_url } = presign.data;
31218
+ if (file.size >= MULTIPART_THRESHOLD) {
31219
+ await putToPresignedUrl(presigned_url, { filePath: file.path, size: file.size }, file.mime, 300000, file.size);
31220
+ } else {
31221
+ const buf = await readFile(file.path);
31222
+ await putToPresignedUrl(presigned_url, buf, file.mime);
31223
+ }
31224
+ const confirmBody = {};
31225
+ if (typeof opts.description === "string" && opts.description.trim() !== "")
31226
+ confirmBody.description = opts.description;
31227
+ if (typeof opts.width === "number")
31228
+ confirmBody.width = opts.width;
31229
+ if (typeof opts.height === "number")
31230
+ confirmBody.height = opts.height;
31231
+ if (typeof opts.durationSeconds === "number")
31232
+ confirmBody.duration_seconds = opts.durationSeconds;
31233
+ const confirmed = await client.post(`/assets/${asset_id}/confirm`, confirmBody);
31234
+ const data = confirmed.data ?? {};
31235
+ return {
31236
+ asset_id,
31237
+ status: typeof data.status === "string" ? data.status : "ready",
31238
+ media_type: typeof data.media_type === "string" ? data.media_type : "",
31239
+ mime_type: typeof data.mime_type === "string" ? data.mime_type : file.mime,
31240
+ filename: typeof data.filename === "string" ? data.filename : filename,
31241
+ size_bytes: typeof data.size_bytes === "number" ? data.size_bytes : file.size
31242
+ };
31243
+ }
29851
31244
 
29852
31245
  // src/index.ts
29853
- async function fetchStatus(client, kind, slug) {
29854
- const path = kind === "short" ? `/shorts/${slug}` : `/editor/${slug}`;
29855
- const res = await client.get(path);
31246
+ async function fetchStatus(client, kind, slug, deadline) {
31247
+ const path = kind === "short" ? `/shorts/${slug}` : kind === "tracking" ? `/tracking/${slug}` : kind === "slider" ? `/sliders/${slug}` : `/editor/${slug}`;
31248
+ const res = await client.get(path, undefined, deadline);
29856
31249
  return normalizeStatus(kind, slug, asRecord(res).data);
29857
31250
  }
31251
+ function isRecompositeInProgressConflict(e) {
31252
+ const err = e;
31253
+ if (err?.status !== 409)
31254
+ return false;
31255
+ const texts = [];
31256
+ if (typeof err.code === "string")
31257
+ texts.push(err.code);
31258
+ if (typeof err.message === "string")
31259
+ texts.push(err.message);
31260
+ const body = err.body;
31261
+ if (body && typeof body === "object") {
31262
+ const record3 = body;
31263
+ if (typeof record3.error === "string")
31264
+ texts.push(record3.error);
31265
+ if (typeof record3.message === "string")
31266
+ texts.push(record3.message);
31267
+ const nested = record3.error;
31268
+ if (nested && typeof nested === "object") {
31269
+ const nestedRecord = nested;
31270
+ if (typeof nestedRecord.code === "string")
31271
+ texts.push(nestedRecord.code);
31272
+ if (typeof nestedRecord.message === "string")
31273
+ texts.push(nestedRecord.message);
31274
+ }
31275
+ }
31276
+ return texts.some((text) => {
31277
+ const normalized = text.toLowerCase();
31278
+ return normalized === "recomposite_in_progress" || /\bre-?composit(?:e|ing|ion)\b.*\balready\b.*\b(progress|running)\b/.test(normalized) || /\bcarousel\b.*\bre-?render\b.*\balready\b.*\bprogress\b/.test(normalized);
31279
+ });
31280
+ }
29858
31281
  var ALLOW_LOOPBACK_FETCH2 = /^http:\/\/(localhost|127\.0\.0\.1|\[?::1\]?)/.test(process.env.HUBFLUENCER_BASE_URL || "");
29859
31282
  async function downloadTo(videoUrl, savePath) {
29860
31283
  const target = resolveSavePath(savePath);
29861
31284
  assertSafeFetchUrl(videoUrl, { allowLoopback: ALLOW_LOOPBACK_FETCH2 });
29862
31285
  const MAX_BYTES = 1024 * 1024 * 1024;
29863
- const resp = await fetch(videoUrl, { signal: AbortSignal.timeout(120000) });
31286
+ const resp = await fetch(videoUrl, {
31287
+ headers: { "user-agent": USER_AGENT },
31288
+ signal: AbortSignal.timeout(120000)
31289
+ });
29864
31290
  if (!resp.ok)
29865
31291
  throw new Error(`Failed to download video (HTTP ${resp.status}).`);
29866
31292
  const declared = Number(resp.headers.get("content-length") ?? "");
@@ -29889,6 +31315,7 @@ async function downloadTo(videoUrl, savePath) {
29889
31315
  await reader.cancel().catch(() => {});
29890
31316
  }
29891
31317
  const buf = Buffer.concat(chunks, total);
31318
+ await mkdir2(dirname2(target), { recursive: true });
29892
31319
  await writeFile2(target, buf);
29893
31320
  return { saved_to: target, bytes: buf.length };
29894
31321
  }
@@ -29918,14 +31345,12 @@ function fail(message) {
29918
31345
  function errMessage(e) {
29919
31346
  const he = e;
29920
31347
  if (he && typeof he.status === "number") {
29921
- const base = `API error (HTTP ${he.status}${he.code ? `, ${he.code}` : ""}): ${he.message}`;
29922
- const body = he.body;
29923
- if (body && (body.required_credits != null || body.available_credits != null)) {
29924
- const req = body.required_credits;
29925
- const have = body.available_credits;
29926
- return `${base} (needs ${req ?? "?"} credits, have ${have ?? "?"}).`;
29927
- }
29928
- return base;
31348
+ return formatApiErrorMessage({
31349
+ status: he.status,
31350
+ code: he.code,
31351
+ message: he.message,
31352
+ body: he.body
31353
+ });
29929
31354
  }
29930
31355
  return e instanceof Error ? e.message : String(e);
29931
31356
  }
@@ -29986,8 +31411,8 @@ function tool(fn) {
29986
31411
  }
29987
31412
  };
29988
31413
  }
29989
- var sleep2 = (ms) => new Promise((r) => setTimeout(r, ms));
29990
- var kindSchema = exports_external.enum(["short", "editor"]).describe("Project kind");
31414
+ var sleep4 = (ms) => new Promise((r) => setTimeout(r, ms));
31415
+ var kindSchema = exports_external.enum(["short", "editor", "tracking", "slider"]).describe("Project kind");
29991
31416
  var CREATIVE_FORMATS = [
29992
31417
  "problem_solution",
29993
31418
  "mistake_fix",
@@ -30010,16 +31435,45 @@ var WRITE = {
30010
31435
  destructiveHint: false,
30011
31436
  openWorldHint: true
30012
31437
  };
30013
- var server = new McpServer({ name: "hubfluencer", version: "0.5.0" });
31438
+ var SERVER_INSTRUCTIONS = `Hubfluencer turns a prompt — or a whole product page — into finished, post-ready vertical videos and image carousels, and can run the surrounding content operation (plan → draft → review → schedule → measure → repeat).
31439
+
31440
+ MAKE ONE ASSET (simplest)
31441
+ - make_video({ prompt }) creates, generates, and returns a finished MP4 — it picks a single-clip short or a multi-scene editor ad from the prompt (override with kind:"short"|"editor"). create_short / create_editor_ad are the per-product one-shots; create_slider makes an image carousel; create_tracking_video burns a CV overlay onto a local clip.
31442
+ - Granular (full control): create a draft, then drive the pipeline step by step (create_editor_draft → generate_scenario → set_scene_count → set_segment_prompt → generate_segment(s) → generate_voice + generate_music → render). Poll with get_status / wait_for_completion; fetch with download_result (a slider has no MP4 — read its slides with get_slider).
31443
+
31444
+ DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
31445
+ 1. ONBOARD a product: extract_product_page(url) pulls facts + imagery (SSRF-hardened, 0cr); import_product_image saves its picture/logo. Persist identity once with create_product_profile + create_brand_profile so every later draft inherits it instead of re-describing the brand.
31446
+ 2. PLAN ($0, free quota — not credits): plan_campaign proposes angles/formats + a per-item credit estimate; generate_hook_variations writes hook options. create_campaign(url | product_profile_id) does 1-2 end-to-end and returns a pack of editable DRAFTS.
31447
+ 3. PACK & MATERIALIZE ($0): create_campaign_pack / add_pack_variations assemble items; materialize_pack_item turns each into a 0-credit draft (editor_ad/short → a video project, carousel → a slider). list_campaign_packs lists packs; get_campaign_pack({ id }) shows every item's draft slug + credit estimate.
31448
+ 4. GENERATE per draft (CREDITS — a separate, explicit step): run start_autopilot/generate_short/generate_slider on each draft slug. Nothing above this line spends video credits.
31449
+ 5. REVIEW: create_review_link mints a no-login page where a human approves/comments (list_review_links / revoke_review_link manage them). Gate spend or posting on approval.
31450
+ 6. CALENDAR (reminder-only): schedule_post / list_scheduled_posts / reschedule_post / cancel_scheduled_post stage a calendar that pushes REMINDERS to the user — it does NOT auto-publish (posting to TikTok/IG stays a human step in the app). After the user posts the presigned export, call mark_scheduled_posted.
31451
+ 7. MEASURE → DOUBLE DOWN: record_performance logs views/engagement (tag creatives via set_item_attributes); get_recommendations gives a CAUTIOUS, non-causal read of your own best hook/format (present its confidence + note as-is, never upgrade them); make_more_like_winner spins fresh hooks from a proven item — feed a winner back into add_pack_variations → step 4.
31452
+ 8. RECURRING: create_series + plan_series_episodes → materialize_episode drafts each episode; get_series_dashboard shows upcoming/drafted/published lanes; mark_episode_posted closes a recurring show's loop.
31453
+
31454
+ CREDITS (spent server-side; each tool prices before charging — see its description)
31455
+ - Free (0 credits): every draft create/edit, reorder, all slider re-composites, and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
31456
+ - Charged: short render 15; editor AI scene 5 (4 in a batch of ≥3); voice 3; music 5; editor render 0 (auto-charges ungenerated scenes); slider 1/slide; tracking render 1 (refunded on failure). make_video/create_* report the estimate first — pass dry_run to preview, max_credits to cap.
31457
+ - AI ASSISTS (all generate_*/enhance/suggest/plan/hook tools) draw a FREE daily quota (20/day), NOT credits. On 429, set auto_unlock:true to spend 1 credit for +10 assists (retried once), or write the copy yourself and use the set_* tools.
31458
+
31459
+ WAITING & RESUMING
31460
+ - Renders run async and can take minutes. wait_for_completion polls a budget capped at 280s; if it returns terminal=false / timed_out, just CALL IT AGAIN with the same slug. make_video returns the slug + a resume hint on timeout.
31461
+ - Creates and charged actions are idempotency-keyed: a transport retry is safe; a genuine re-run after a terminal FAILURE restarts rather than replaying the dead attempt.
31462
+
31463
+ LOCAL FILES (sandboxed)
31464
+ - Reads (upload_video / product / logo / closing / poster) are confined to HUBFLUENCER_INPUT_DIR (default: cwd); writes (save_path on wait_for_completion / download_result) are confined to HUBFLUENCER_OUTPUT_DIR (default: cwd), .mp4 only. Paths outside the base are refused.
31465
+
31466
+ AUTH: set HUBFLUENCER_API_TOKEN (Settings → Access tokens) or run \`npx -y @hubfluencer/mcp login\`. HUBFLUENCER_BASE_URL overrides the API host; HUBFLUENCER_CREDENTIALS overrides the device-link credentials path.`;
31467
+ var server = new McpServer({ name: "hubfluencer", version: VERSION }, { instructions: SERVER_INSTRUCTIONS });
30014
31468
  var registerTool = server.registerTool.bind(server);
30015
31469
  async function pollToTerminal(client, kind, slug, extra, budgetMs, intervalMs) {
30016
31470
  const deadline = Date.now() + budgetMs;
30017
- let status = await fetchStatus(client, kind, slug);
31471
+ let status = await fetchStatus(client, kind, slug, deadline);
30018
31472
  let n = 0;
30019
31473
  while (!status.terminal && Date.now() + intervalMs <= deadline) {
30020
31474
  await reportProgress(extra, ++n, `stage: ${status.stage}`);
30021
- await sleep2(intervalMs);
30022
- status = await fetchStatus(client, kind, slug);
31475
+ await sleep4(intervalMs);
31476
+ status = await fetchStatus(client, kind, slug, deadline);
30023
31477
  }
30024
31478
  return status;
30025
31479
  }
@@ -30027,7 +31481,7 @@ async function pollSegmentToTerminal(client, slug, segmentId, extra, budgetMs, i
30027
31481
  const deadline = Date.now() + budgetMs;
30028
31482
  const sid = String(segmentId);
30029
31483
  const read = async () => {
30030
- const res = await client.get(`/editor/${slug}`);
31484
+ const res = await client.get(`/editor/${slug}`, undefined, deadline);
30031
31485
  const data = asRecord(asRecord(res).data);
30032
31486
  const segments = Array.isArray(data.segments) ? data.segments : [];
30033
31487
  const seg = segments.find((s) => String(s.id) === sid);
@@ -30040,7 +31494,7 @@ async function pollSegmentToTerminal(client, slug, segmentId, extra, budgetMs, i
30040
31494
  let { status, error: error2 } = await read();
30041
31495
  while (status !== "completed" && status !== "failed" && Date.now() + intervalMs <= deadline) {
30042
31496
  await reportProgress(extra, ++n, `segment ${sid}: ${status}`);
30043
- await sleep2(intervalMs);
31497
+ await sleep4(intervalMs);
30044
31498
  ({ status, error: error2 } = await read());
30045
31499
  }
30046
31500
  return {
@@ -30055,7 +31509,9 @@ registerTool("make_video", {
30055
31509
  inputSchema: {
30056
31510
  prompt: exports_external.string().describe("What the ad/video should be about (min 10 chars)"),
30057
31511
  kind: exports_external.string().optional().describe("'short' (fast, 1 clip), 'editor' (multi-scene), or 'auto' (default — inferred)"),
30058
- language: exports_external.string().optional().describe('Language code, e.g. "en" (default)'),
31512
+ product_subject: exports_external.string().max(300).optional().describe('EDITOR only: the concrete product / main subject the video must be about (e.g. "Bordeaux red ' + 'wine, dark green bottle"). Hard-grounds the AI so it cannot invent an unrelated product. ' + "Recommended for ads."),
31513
+ project_intent: exports_external.enum(["social_ad", "creative_story"]).optional().describe("EDITOR only: social_ad for promotional content (product focus + CTA) or creative_story (default)."),
31514
+ language: exports_external.string().optional().describe('Language code, e.g. "en" (default), "fr". Drives scenario, narration, voice, and captions.'),
30059
31515
  aspect: exports_external.string().optional().describe("Aspect ratio for editor: 9:16 (default), 16:9, or 1:1"),
30060
31516
  voice_id: exports_external.string().optional().describe("Preferred narration voice id for editor ads (see list_voices); shorts have no voiceover"),
30061
31517
  headline: exports_external.string().optional().describe("SHORTS only: the on-screen TITLE overlay (≤160). Set this so the short isn't bare."),
@@ -30088,15 +31544,34 @@ registerTool("make_video", {
30088
31544
  dry_run: exports_external.boolean().optional().describe("Preview only: create a free draft, price it, and STOP before spending credits. " + "Returns {estimated_credits, available_credits, slug}. Resume with generate_short / start_autopilot."),
30089
31545
  max_credits: exports_external.number().optional().describe("Spend cap: refuse to start (no charge) if the priced estimate exceeds this. Returns the estimate.")
30090
31546
  },
31547
+ outputSchema: makeVideoOutput,
30091
31548
  annotations: { title: "Make a video", ...WRITE, idempotentHint: false }
30092
31549
  }, tool(async (args, client, extra) => {
30093
- if (!args.prompt || args.prompt.trim().length < 10) {
30094
- return fail("prompt must be at least 10 characters.");
30095
- }
31550
+ const promptLen = args.prompt?.trim().length ?? 0;
31551
+ if (promptLen < 10 || promptLen > 5000) {
31552
+ return fail("prompt must be 10–5000 characters.");
31553
+ }
31554
+ const langErr = validateLanguage(args.language);
31555
+ if (langErr)
31556
+ return fail(langErr);
31557
+ const kindErr = validateKind(args.kind);
31558
+ if (kindErr)
31559
+ return fail(kindErr);
30096
31560
  const requestedKind = args.kind === "short" || args.kind === "editor" ? args.kind : undefined;
30097
31561
  const kind = requestedKind ?? inferKind(args.prompt);
30098
31562
  const waitSeconds = Math.min(280, Math.max(10, args.max_wait_seconds ?? 240));
30099
31563
  const budgetMs = waitSeconds * 1000;
31564
+ if (args.star_rating !== undefined && (args.star_rating < 0 || args.star_rating > 5)) {
31565
+ return fail("star_rating must be between 0 and 5.");
31566
+ }
31567
+ if (args.text_beats !== undefined) {
31568
+ if (args.text_beats.length > 8) {
31569
+ return fail("text_beats must have at most 8 entries.");
31570
+ }
31571
+ if (args.text_beats.some((b) => b.length > 120)) {
31572
+ return fail("each text_beats entry must be at most 120 characters.");
31573
+ }
31574
+ }
30100
31575
  if (args.save_path)
30101
31576
  resolveSavePath(args.save_path);
30102
31577
  let slug;
@@ -30127,12 +31602,14 @@ registerTool("make_video", {
30127
31602
  const created = await client.post("/editor", {
30128
31603
  language: args.language ?? "en",
30129
31604
  product_prompt: args.prompt,
31605
+ product_subject: args.product_subject,
31606
+ project_intent: args.project_intent,
30130
31607
  export_aspect_ratio: args.aspect,
30131
31608
  voice_id: args.voice_id,
30132
31609
  creative_format: args.creative_format,
30133
31610
  visual_language: args.visual_language,
30134
31611
  theme: args.theme
30135
- }, idemKey("make-editor", args.prompt, args.language ?? "en", args.aspect ?? "", args.voice_id ?? "", args.creative_format ?? "", args.visual_language ?? "", args.theme ?? ""));
31612
+ }, makeVideoEditorCreateKey(args));
30136
31613
  slug = created.data.slug;
30137
31614
  }
30138
31615
  const costPath = kind === "short" ? `/shorts/${slug}/cost` : `/editor/${slug}/autopilot/cost`;
@@ -30159,10 +31636,15 @@ registerTool("make_video", {
30159
31636
  note
30160
31637
  });
30161
31638
  }
31639
+ let startState = "start";
31640
+ try {
31641
+ const cur = await client.get(kind === "short" ? `/shorts/${slug}` : `/editor/${slug}`);
31642
+ startState = startStateDiscriminator(kind, asRecord(cur).data);
31643
+ } catch {}
30162
31644
  if (kind === "short") {
30163
- await client.post(`/shorts/${slug}/generate`, undefined, `gen-short:${slug}`);
31645
+ await client.post(`/shorts/${slug}/generate`, undefined, `gen-short:${slug}:${startState}`);
30164
31646
  } else {
30165
- await client.post(`/editor/${slug}/autopilot`, undefined, `autopilot:${slug}`);
31647
+ await client.post(`/editor/${slug}/autopilot`, undefined, makeVideoAutopilotKey(slug, args, startState));
30166
31648
  }
30167
31649
  await reportProgress(extra, 0, `started ${kind} ${slug}`);
30168
31650
  const status = await pollToTerminal(client, kind, slug, extra, budgetMs, 15000);
@@ -30183,13 +31665,14 @@ registerTool("make_video", {
30183
31665
  const links = status.ready && status.video_url ? [mp4Link(status.video_url, slug)] : [];
30184
31666
  return ok({
30185
31667
  ...result,
30186
- note: status.ready ? "Done. Presigned ~24h URL — download promptly." : status.terminal ? `Terminal (${status.stage}). ${status.error ?? ""}`.trim() : `Still rendering — call wait_for_completion(${resumeArgs}).`
31668
+ note: status.ready ? "Done. Presigned ~24h URL — download promptly." : status.terminal ? `Terminal (${status.stage}). ${status.error ?? ""}`.trim() + ` To restart, fix the cause (often credits) then re-run make_video, or resume with ${resume}.` : `Still rendering — call wait_for_completion(${resumeArgs}).`
30187
31669
  }, links);
30188
31670
  }));
30189
31671
  registerTool("get_credits", {
30190
31672
  title: "Get credit balance",
30191
31673
  description: "Returns the authenticated account's credit balance. A short costs 15 credits.",
30192
31674
  inputSchema: {},
31675
+ outputSchema: getCreditsOutput,
30193
31676
  annotations: { title: "Get credits", ...RO }
30194
31677
  }, tool(async (_args, client) => {
30195
31678
  const res = await client.get("/studio/credits");
@@ -30215,6 +31698,7 @@ registerTool("list_projects", {
30215
31698
  include_completed: exports_external.boolean().optional().describe("Also include completed shorts (default false)"),
30216
31699
  limit: exports_external.number().optional().describe("Max items per kind (default 25, capped at 100)")
30217
31700
  },
31701
+ outputSchema: listProjectsOutput,
30218
31702
  annotations: { title: "List projects", ...RO }
30219
31703
  }, tool(async (args, client) => {
30220
31704
  const cap = Math.min(100, Math.max(1, args.limit ?? 25));
@@ -30272,6 +31756,7 @@ registerTool("create_short", {
30272
31756
  star_rating: exports_external.number().min(0).max(5).optional().describe("Optional 0..5 star rating under the subheadline"),
30273
31757
  text_beats: exports_external.array(exports_external.string().max(120)).max(8).optional().describe("Optional caption beats shown sequentially instead of the static subheadline.")
30274
31758
  },
31759
+ outputSchema: createDraftOutput,
30275
31760
  annotations: { title: "Create short", ...WRITE, idempotentHint: true }
30276
31761
  }, tool(async (args, client) => {
30277
31762
  const body = {
@@ -30322,11 +31807,147 @@ registerTool("create_short", {
30322
31807
  next: "set_short_product / set_short_poster (optional branding), then generate_short"
30323
31808
  });
30324
31809
  }));
31810
+ async function getTracking(client, slug, deadline) {
31811
+ const res = await client.get(`/tracking/${slug}`, undefined, deadline);
31812
+ return res.data;
31813
+ }
31814
+ async function waitForTrackingSource(client, slug, extra, budgetMs = 120000, intervalMs = 3000) {
31815
+ const deadline = Date.now() + budgetMs;
31816
+ let n = 0;
31817
+ while (Date.now() + intervalMs <= deadline) {
31818
+ const t = await getTracking(client, slug, deadline);
31819
+ if (t.source?.status === "ready")
31820
+ return "ready";
31821
+ if (t.source?.status === "failed")
31822
+ return "failed";
31823
+ await reportProgress(extra, ++n, "processing source clip");
31824
+ await sleep4(intervalMs);
31825
+ }
31826
+ return "timeout";
31827
+ }
31828
+ async function pollTrackingComplete(client, slug, extra, budgetMs = 900000, intervalMs = 4000) {
31829
+ const deadline = Date.now() + budgetMs;
31830
+ let n = 0;
31831
+ let t = await getTracking(client, slug, deadline);
31832
+ while (t.result?.status !== "completed" && t.result?.status !== "failed" && Date.now() + intervalMs <= deadline) {
31833
+ await reportProgress(extra, ++n, "applying tracking overlay");
31834
+ await sleep4(intervalMs);
31835
+ t = await getTracking(client, slug, deadline);
31836
+ }
31837
+ return t;
31838
+ }
31839
+ registerTool("create_tracking_video", {
31840
+ title: "Create a tracking-overlay video",
31841
+ description: "One-shot: upload a local video and burn the computer-vision 'object tracking' overlay on top " + "(white convex-hull polygons hugging each moving subject, HUD corner brackets, a centroid crosshair, " + "and per-subject telemetry), then download the result. Costs 1 credit on render. The source clip must " + "be ≤60s and ≤1080p. This creates the project, uploads the clip, waits for it to be ready, renders, " + "polls to completion, and (when save_path is given) downloads the finished MP4. " + "RESUMING: if the call returns terminal=false (slow upload or a render still cold-starting on the GPU), " + "call this tool AGAIN with the returned slug to resume — it reuses the in-flight upload (it only (re)uploads " + "when no source exists yet or the prior upload failed) and never re-charges (a render in flight is reported, " + "not duplicated). Resuming a slug whose render already COMPLETED just returns the finished video: a " + "completed project is FINAL. The source clip is deleted on successful completion, so you cannot re-render it " + "with a different overlay: for a variant (different classes/color) start a NEW project (omit slug, pass " + "video_path). Re-uploading even the identical clip charges a fresh 1-credit render (the dedup keys on the new " + "upload). Once the project " + "exists you can also poll/download it with get_status / download_result / wait_for_completion using " + 'kind:"tracking" and that slug.',
31842
+ inputSchema: {
31843
+ video_path: exports_external.string().optional().describe("Local path to the source video (mp4/mov/webm/mkv), inside HUBFLUENCER_INPUT_DIR or cwd. " + "Required on a fresh call; optional when resuming with slug (the clip is already uploaded)."),
31844
+ slug: exports_external.string().optional().describe("Resume an existing tracking project (from a prior timeout/partial-failure payload): skips create " + "and reuses the in-flight upload (only re-uploads when no source exists yet or the prior upload failed). " + "Resuming a project whose render already COMPLETED just returns the finished video: a completed project " + "is final (its source clip was deleted on completion), so you cannot re-render it with a different overlay. " + "For a variant, start a fresh project (omit slug, pass video_path). Pass this back to continue an in-flight " + "project rather than forking a new one; omit it to start a fresh project."),
31845
+ classes: exports_external.array(exports_external.number().int().min(0).max(79)).optional().describe("COCO-80 class ids (0-79) to track (e.g. [0]=person, [16]=dog). Omit to track all subjects."),
31846
+ color: exports_external.array(exports_external.number().int().min(0).max(255)).min(3).max(4).optional().describe("Optional overlay color as RGB [r,g,b] or RGBA [r,g,b,a] (each 0–255). Omit for the default white overlay."),
31847
+ save_path: exports_external.string().optional().describe("Optional .mp4 path to download the finished video to (inside HUBFLUENCER_OUTPUT_DIR or cwd).")
31848
+ },
31849
+ annotations: {
31850
+ title: "Create tracking video",
31851
+ ...WRITE,
31852
+ idempotentHint: false
31853
+ }
31854
+ }, tool(async (args, client, extra) => {
31855
+ if (args.save_path)
31856
+ resolveSavePath(args.save_path);
31857
+ const existing = args.slug ? await getTracking(client, args.slug) : null;
31858
+ const plan = decideTrackingPlan(existing, args, randomUUID());
31859
+ let slug;
31860
+ if (args.slug) {
31861
+ slug = args.slug;
31862
+ } else {
31863
+ if (!args.video_path) {
31864
+ return fail("video_path is required to start a new tracking video.");
31865
+ }
31866
+ await resolveVideoReadPath(args.video_path);
31867
+ const created = await client.post("/tracking", {}, plan.idemBase);
31868
+ slug = created.data.slug;
31869
+ }
31870
+ if (existing?.result?.status === "completed") {
31871
+ const videoUrl2 = existing.result.video_url ?? null;
31872
+ let saved_to2;
31873
+ if (videoUrl2 && args.save_path) {
31874
+ saved_to2 = (await downloadTo(videoUrl2, args.save_path)).saved_to;
31875
+ }
31876
+ const variantNote = plan.configSupplied ? " A different overlay needs a NEW tracking project (omit slug, pass video_path): the finished project's source clip was deleted on completion, so re-running can't re-render it, and re-uploading even the same clip charges a fresh 1-credit render." : "";
31877
+ return ok({
31878
+ slug,
31879
+ kind: "tracking",
31880
+ status: "completed",
31881
+ terminal: true,
31882
+ video_url: videoUrl2,
31883
+ ...saved_to2 ? { saved_to: saved_to2 } : {},
31884
+ note: `Already completed — the project is final. Presigned ~24h URL, download promptly.${variantNote}`
31885
+ }, videoUrl2 ? [mp4Link(videoUrl2, slug)] : []);
31886
+ }
31887
+ if (!plan.hasLiveSource) {
31888
+ if (!args.video_path) {
31889
+ return fail(`Source for ${slug} is not uploaded yet (or the prior upload failed) and no ` + "video_path was given to (re)upload. Pass video_path to upload, or wait and " + "resume with the slug if an upload is already in flight.");
31890
+ }
31891
+ await uploadVideoFile(client, slug, args.video_path, {
31892
+ onProgress: (completed, total, message) => reportProgress(extra, completed, message, total)
31893
+ });
31894
+ }
31895
+ const sourceState = await waitForTrackingSource(client, slug, extra);
31896
+ if (sourceState === "failed") {
31897
+ return ok({
31898
+ slug,
31899
+ kind: "tracking",
31900
+ status: "failed",
31901
+ terminal: true,
31902
+ note: `Source upload failed for ${slug} — re-run create_tracking_video with slug:"${slug}" and video_path to re-upload the clip.`
31903
+ });
31904
+ }
31905
+ if (sourceState === "timeout") {
31906
+ return ok({
31907
+ slug,
31908
+ kind: "tracking",
31909
+ status: "processing",
31910
+ terminal: false,
31911
+ note: `Source still processing — call create_tracking_video again with slug:"${slug}" to resume (no re-upload, no re-charge).`
31912
+ });
31913
+ }
31914
+ if (plan.shouldRender) {
31915
+ const body = {};
31916
+ if (args.classes !== undefined)
31917
+ body.classes = args.classes;
31918
+ if (args.color !== undefined)
31919
+ body.color = args.color;
31920
+ try {
31921
+ await client.post(`/tracking/${slug}/render`, body, plan.renderKey);
31922
+ } catch (e) {
31923
+ if (e.status !== 409)
31924
+ throw e;
31925
+ }
31926
+ }
31927
+ const final = await pollTrackingComplete(client, slug, extra);
31928
+ const status = final.result?.status ?? final.status;
31929
+ const videoUrl = final.result?.video_url ?? null;
31930
+ const terminal = status === "completed" || status === "failed";
31931
+ let saved_to;
31932
+ if (videoUrl && args.save_path) {
31933
+ saved_to = (await downloadTo(videoUrl, args.save_path)).saved_to;
31934
+ }
31935
+ const note = status === "completed" ? "Done. Presigned ~24h URL — download promptly." : status === "failed" ? "Render failed — the 1 credit was refunded. Retry with the same slug." : `Still rendering — resume with create_tracking_video({ slug: "${slug}"${args.save_path ? `, save_path: "${args.save_path}"` : ""} }), or poll wait_for_completion({ slug: "${slug}", kind: "tracking" }). It may still fail+refund.`;
31936
+ return ok({
31937
+ slug,
31938
+ kind: "tracking",
31939
+ status,
31940
+ terminal,
31941
+ video_url: videoUrl,
31942
+ ...saved_to ? { saved_to } : {},
31943
+ note
31944
+ }, videoUrl ? [mp4Link(videoUrl, slug)] : []);
31945
+ }));
30325
31946
  registerTool("generate_short", {
30326
31947
  title: "Generate (render) a short",
30327
31948
  description: "Deducts 15 credits and starts the render pipeline for an existing short. Safe to call again: a " + "duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " + "short can be re-generated. Then poll with get_status or wait_for_completion (kind=short).",
30328
31949
  inputSchema: { slug: exports_external.string().describe("Short slug from create_short") },
30329
- annotations: { title: "Generate short", ...WRITE, idempotentHint: true }
31950
+ annotations: { title: "Generate short", ...WRITE }
30330
31951
  }, tool(async (args, client) => {
30331
31952
  try {
30332
31953
  const res = await client.post(`/shorts/${args.slug}/generate`);
@@ -30382,6 +32003,7 @@ registerTool("create_slider", {
30382
32003
  accent_color: exports_external.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe("Brand accent: must start with #, exactly 6 hex digits, e.g. #09EFBE"),
30383
32004
  text_position: exports_external.enum(["top", "middle", "bottom"]).optional().describe("Vertical placement of the on-image copy across all slides. Omit for the template's natural placement.")
30384
32005
  },
32006
+ outputSchema: createDraftOutput,
30385
32007
  annotations: { title: "Create slider", ...WRITE, idempotentHint: true }
30386
32008
  }, tool(async (args, client) => {
30387
32009
  if (args.slide_count !== undefined) {
@@ -30402,7 +32024,7 @@ registerTool("create_slider", {
30402
32024
  body.accent_color = args.accent_color;
30403
32025
  if (args.text_position !== undefined)
30404
32026
  body.text_position = args.text_position;
30405
- const res = await client.post("/sliders", body, idemKey("create-slider", args.prompt, args.mode ?? "", args.template ?? ""));
32027
+ const res = await client.post("/sliders", body, sliderCreateKey(args));
30406
32028
  return ok({
30407
32029
  slug: res.data.slug,
30408
32030
  kind: "slider",
@@ -30415,7 +32037,7 @@ registerTool("generate_slider", {
30415
32037
  inputSchema: {
30416
32038
  slug: exports_external.string().describe("Slider slug from create_slider")
30417
32039
  },
30418
- annotations: { title: "Generate slider", ...WRITE, idempotentHint: true }
32040
+ annotations: { title: "Generate slider", ...WRITE }
30419
32041
  }, tool(async (args, client) => {
30420
32042
  try {
30421
32043
  const res = await client.post(`/sliders/${args.slug}/generate`);
@@ -30436,6 +32058,7 @@ registerTool("get_slider", {
30436
32058
  title: "Get carousel status + deliverable",
30437
32059
  description: "Returns the carousel's status and, when completed, the per-slide image URLs (download these), the " + "per-slide headline/body/kicker, the caption, and the hashtags. status flows draft → processing → " + "completed | failed. completed:true means every slide image_url is ready to save. Once completed you " + "can restyle_slider (new template/accent) or edit_slider_slide (one slide's text) for free.",
30438
32060
  inputSchema: { slug: exports_external.string().describe("Slider slug") },
32061
+ outputSchema: getSliderOutput,
30439
32062
  annotations: { title: "Get slider", ...RO }
30440
32063
  }, tool(async (args, client) => {
30441
32064
  const res = await client.get(`/sliders/${args.slug}`);
@@ -30497,7 +32120,7 @@ registerTool("restyle_slider", {
30497
32120
  next: "poll get_slider until status is 'completed' (free re-composite, 0 credits)"
30498
32121
  });
30499
32122
  } catch (e) {
30500
- if (e.status === 409) {
32123
+ if (isRecompositeInProgressConflict(e)) {
30501
32124
  return ok({ slug: args.slug, kind: "slider", status: "processing" });
30502
32125
  }
30503
32126
  throw e;
@@ -30537,7 +32160,7 @@ registerTool("edit_slider_slide", {
30537
32160
  next: "poll get_slider until the slide's status is 'completed' (free re-composite, 0 credits)"
30538
32161
  });
30539
32162
  } catch (e) {
30540
- if (e.status === 409) {
32163
+ if (isRecompositeInProgressConflict(e)) {
30541
32164
  return ok({
30542
32165
  slug: args.slug,
30543
32166
  kind: "slider",
@@ -30553,39 +32176,69 @@ registerTool("create_editor_ad", {
30553
32176
  description: "Creates an editor project from a product prompt and starts autopilot (server-orchestrated " + "scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " + "Returns the slug. Costs credits. Then poll with get_status / wait_for_completion (kind=editor). Prefer " + "make_video for the one-shot path. " + "BRANDING: to attach the user's own product image, brand logo, or closing card, create the draft with " + "create_editor_draft first, call set_product / set_logo / set_closing_image (all 0 credits), then " + "start_autopilot — autopilot weaves them in. (Editor ads carry their copy in-scene/narration, not as a " + "title overlay; for an on-screen title/subtitle use a short with headline/subheadline instead.)",
30554
32177
  inputSchema: {
30555
32178
  product_prompt: exports_external.string().min(10).describe("Brief for the ad (min 10 chars)"),
30556
- language: exports_external.string().optional().describe('Language code, e.g. "en" (default)'),
32179
+ product_subject: exports_external.string().max(300).optional().describe('The concrete product or main subject the video must be about (e.g. "Bordeaux red wine, dark green ' + 'bottle with a gold label"). Distinct from the freeform brief: hard-grounds the AI scenario + narration ' + "so it cannot invent an unrelated product. Strongly recommended for ads."),
32180
+ project_intent: exports_external.enum(["social_ad", "creative_story"]).optional().describe("social_ad for promotional/ad content (product focus + CTA), creative_story for brand narratives " + "(default)."),
32181
+ language: exports_external.string().optional().describe('Language code, e.g. "en" (default), "fr", "es". Drives the WHOLE video: scenario, narration script, ' + "narration voice, and captions. Ask the user / infer from their locale."),
30557
32182
  creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Narrative arc / segment structure. Omit for Auto/generic."),
30558
32183
  visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Visual style direction and render look. Good default: kinetic_creator. When set it drives the look and a theme of 'none' is ignored."),
30559
32184
  theme: exports_external.string().optional().describe('Visual theme / genre overlay (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.'),
30560
32185
  voice_id: exports_external.string().optional().describe("Preferred narration voice id (see list_voices); omit for the default voice"),
30561
32186
  export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")')
30562
32187
  },
32188
+ outputSchema: createEditorOutput,
30563
32189
  annotations: { title: "Create editor ad", ...WRITE, idempotentHint: true }
30564
32190
  }, tool(async (args, client) => {
32191
+ const langErr = validateLanguage(args.language);
32192
+ if (langErr)
32193
+ return fail(langErr);
32194
+ const promptErr = validateProductPrompt(args.product_prompt);
32195
+ if (promptErr)
32196
+ return fail(promptErr);
30565
32197
  const created = await client.post("/editor", {
30566
32198
  language: args.language ?? "en",
30567
32199
  product_prompt: args.product_prompt,
32200
+ product_subject: args.product_subject,
32201
+ project_intent: args.project_intent,
30568
32202
  creative_format: args.creative_format,
30569
32203
  visual_language: args.visual_language,
30570
32204
  theme: args.theme,
30571
32205
  voice_id: args.voice_id,
30572
32206
  export_aspect_ratio: args.export_aspect_ratio
30573
- }, idemKey("create-editor", String(args.product_prompt ?? ""), String(args.language ?? "en"), String(args.creative_format ?? ""), String(args.visual_language ?? ""), String(args.theme ?? ""), String(args.voice_id ?? ""), String(args.export_aspect_ratio ?? "")));
32207
+ }, editorAdCreateKey(args));
30574
32208
  const slug = created.data.slug;
30575
- const started = await client.post(`/editor/${slug}/autopilot`, undefined, `autopilot:${slug}`);
32209
+ const started = await client.post(`/editor/${slug}/autopilot`, undefined, editorAdAutopilotKey(slug, args));
30576
32210
  const status = normalizeStatus("editor", slug, asRecord(started).data);
30577
32211
  return ok({ slug, kind: "editor", status, next: "wait_for_completion" });
30578
32212
  }));
30579
32213
  registerTool("start_autopilot", {
30580
32214
  title: "Start autopilot on an existing editor draft",
30581
- description: "Starts server-orchestrated autopilot (scenario → segments → narration → voice → music → render) on an " + "EXISTING editor project/draft. Spends credits — preview the estimate first with the autopilot/cost endpoint " + "or make_video({ dry_run:true }). Idempotent per slug (safe to retry). Use this to run a draft created by " + "make_video dry_run, or to resume after topping up / raising your spend cap. Then poll with " + 'wait_for_completion({ slug, kind: "editor" }).',
30582
- inputSchema: { slug: exports_external.string().describe("Editor project slug") },
30583
- annotations: { title: "Start autopilot", ...WRITE, idempotentHint: true }
32215
+ description: "Starts server-orchestrated autopilot (scenario → segments → narration → voice → music → render) on an " + "EXISTING editor project/draft. Spends credits — preview the estimate first with the autopilot/cost endpoint " + "or make_video({ dry_run:true }). Each call is a genuine start attempt (like tapping Launch in the app): " + "if a run is already in progress for this slug the server returns already_running — it never double-starts " + "or double-charges. Once finished, failed, or cancelled, calling again relaunches it (resume after a top-up, raising your spend cap, or a mid-pipeline failure). Then poll with " + 'wait_for_completion({ slug, kind: "editor" }).',
32216
+ inputSchema: {
32217
+ slug: exports_external.string().describe("Editor project slug"),
32218
+ language: exports_external.string().optional().describe('Optional language override applied before launch (ISO 639-1, e.g. "fr"). Drives scenario, ' + "narration, voice, and captions."),
32219
+ product_subject: exports_external.string().max(300).optional().describe("Optional locked product / main subject applied before launch so the AI cannot invent an " + "unrelated product."),
32220
+ product_prompt: exports_external.string().optional().describe("Optional freeform brief to set/replace before launch.")
32221
+ },
32222
+ annotations: { title: "Start autopilot", ...WRITE, idempotentHint: false }
30584
32223
  }, tool(async (args, client) => {
30585
- const started = await client.post(`/editor/${args.slug}/autopilot`, undefined, `autopilot:${args.slug}`);
30586
- const status = normalizeStatus("editor", args.slug, asRecord(started).data);
32224
+ const langErr = validateLanguage(args.language);
32225
+ if (langErr)
32226
+ return fail(langErr);
32227
+ const promptErr = validateProductPrompt(args.product_prompt);
32228
+ if (promptErr)
32229
+ return fail(promptErr);
32230
+ const slug = args.slug;
32231
+ const overrides = {};
32232
+ if (args.language !== undefined)
32233
+ overrides.language = args.language;
32234
+ if (args.product_subject !== undefined)
32235
+ overrides.product_subject = args.product_subject;
32236
+ if (args.product_prompt !== undefined)
32237
+ overrides.product_prompt = args.product_prompt;
32238
+ const started = await client.post(`/editor/${slug}/autopilot`, Object.keys(overrides).length > 0 ? overrides : undefined, idemKey("autopilot", slug, randomUUID()));
32239
+ const status = normalizeStatus("editor", slug, asRecord(started).data);
30587
32240
  return ok({
30588
- slug: args.slug,
32241
+ slug,
30589
32242
  kind: "editor",
30590
32243
  status,
30591
32244
  next: "wait_for_completion"
@@ -30612,14 +32265,16 @@ registerTool("create_editor_draft", {
30612
32265
  description: "Creates an editor project from a product prompt and stops (NO autopilot). Costs 0 credits. Returns the " + "slug. Use this to drive the pipeline step by step (vs create_editor_ad which runs autopilot end to end). " + "Each AI-generated scene renders to a fixed 8 seconds (uploaded clips keep their own length). " + "product_prompt is optional content: send 10–5000 chars, or omit it entirely (empty is treated as omit).",
30613
32266
  inputSchema: {
30614
32267
  product_prompt: exports_external.string().min(10).max(5000).optional().describe("Brief for the ad — 10–5000 chars, or omit entirely"),
30615
- language: exports_external.string().min(2).max(10).optional().describe('Language code, e.g. "en" (default)'),
32268
+ product_subject: exports_external.string().max(300).optional().describe("The concrete product / main subject the video must be about. Hard-grounds the AI so it cannot " + "invent an unrelated product. Recommended for ads."),
32269
+ language: exports_external.string().min(2).max(10).optional().describe('Language code, e.g. "en" (default). Drives scenario, narration, voice, and captions.'),
30616
32270
  creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Narrative arc / segment structure. Omit for Auto/generic."),
30617
32271
  visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Visual style direction and render look. Good default: kinetic_creator. When set it drives the look and a theme of 'none' is ignored."),
30618
32272
  theme: exports_external.string().optional().describe('Visual theme / genre overlay (default "realistic"). One of: none (no imposed style — segments follow your prompts literally), realistic, cinematic, anime, sci_fi, fantasy, noir, superhero, horror, mockumentary, sports, gaming, retro_80s, minimalist, cyberpunk.'),
30619
32273
  voice_id: exports_external.string().regex(/^[A-Za-z0-9_-]+$/).max(64).optional().describe("Preferred narration voice id (see list_voices); ≤64 chars, [A-Za-z0-9_-] only"),
30620
32274
  export_aspect_ratio: exports_external.enum(["9:16", "16:9", "1:1"]).optional().describe('Aspect ratio (default "9:16")'),
30621
- project_intent: exports_external.enum(["social_ad", "creative_story"]).optional().describe("Project intent (optional)")
32275
+ project_intent: exports_external.enum(["social_ad", "creative_story"]).optional().describe("social_ad for promotional/ad content (product focus + CTA), creative_story for brand " + "narratives (default).")
30622
32276
  },
32277
+ outputSchema: createEditorOutput,
30623
32278
  annotations: {
30624
32279
  title: "Create editor draft",
30625
32280
  ...WRITE,
@@ -30629,6 +32284,7 @@ registerTool("create_editor_draft", {
30629
32284
  const prompt = args.product_prompt?.trim();
30630
32285
  const body = {
30631
32286
  language: args.language ?? "en",
32287
+ product_subject: args.product_subject,
30632
32288
  creative_format: args.creative_format,
30633
32289
  visual_language: args.visual_language,
30634
32290
  theme: args.theme,
@@ -30638,7 +32294,7 @@ registerTool("create_editor_draft", {
30638
32294
  };
30639
32295
  if (prompt)
30640
32296
  body.product_prompt = prompt;
30641
- const created = await client.post("/editor", body, idemKey("create-editor-draft", prompt ?? "", args.language ?? "en", args.creative_format ?? "", args.visual_language ?? "", args.theme ?? ""));
32297
+ const created = await client.post("/editor", body, idemKey("create-editor-draft", prompt ?? "", args.product_subject ?? "", args.project_intent ?? "", args.language ?? "en", args.creative_format ?? "", args.visual_language ?? "", args.theme ?? "", args.voice_id ?? "", args.export_aspect_ratio ?? ""));
30642
32298
  return ok({
30643
32299
  slug: created.data.slug,
30644
32300
  kind: "editor",
@@ -30697,7 +32353,7 @@ registerTool("generate_scenario", {
30697
32353
  }));
30698
32354
  registerTool("set_scene_count", {
30699
32355
  title: "Set the number of scenes",
30700
- description: "Adjusts the editor to exactly `count` scenes (1–20) by adding pending segments to grow, or deleting " + "only TRAILING un-generated (pending) segments to shrink. Never deletes completed/processing segments — " + "if a shrink would require that, it stops and reports. Free (no credits, no assist). Each AI-generated " + "scene is a fixed 8 seconds, so `count` scenes ≈ count×8s of AI footage (uploaded clips keep their own length).",
32356
+ description: "Adjusts the editor to exactly `count` scenes (1–20) by adding pending segments to grow, or deleting " + "only TRAILING un-generated (pending or failed) segments to shrink. Never deletes completed/processing segments — " + "if a shrink would require that, it stops and reports. Free (no credits, no assist). Each AI-generated " + "scene is a fixed 8 seconds, so `count` scenes ≈ count×8s of AI footage (uploaded clips keep their own length).",
30701
32357
  inputSchema: {
30702
32358
  slug: exports_external.string().describe("Editor project slug"),
30703
32359
  count: exports_external.number().int().min(1).max(20).describe("Target scene count (1–20)")
@@ -30710,8 +32366,9 @@ registerTool("set_scene_count", {
30710
32366
  const current = segments.length;
30711
32367
  const target = args.count;
30712
32368
  if (target > current) {
32369
+ const growNonce = randomUUID();
30713
32370
  for (let i = current;i < target; i++) {
30714
- await client.post(`/editor/${args.slug}/segments`, {}, idemKey("add-segment", args.slug, String(i)));
32371
+ await client.post(`/editor/${args.slug}/segments`, {}, addSegmentKey(args.slug, i, growNonce));
30715
32372
  }
30716
32373
  } else if (target < current) {
30717
32374
  const isPending = (s) => {
@@ -30725,7 +32382,7 @@ registerTool("set_scene_count", {
30725
32382
  removable++;
30726
32383
  }
30727
32384
  if (current - removable > target) {
30728
- return fail(`Cannot shrink to ${target} scenes: only ${removable} trailing pending segment(s) are safe to ` + `delete (the rest are completed/processing). Currently ${current} scenes.`);
32385
+ return fail(`Cannot shrink to ${target} scenes: only ${removable} trailing un-generated (pending/failed) segment(s) are safe ` + `to delete (the rest are completed/processing). Currently ${current} scenes.`);
30729
32386
  }
30730
32387
  for (let i = segments.length - 1;i >= target; i--) {
30731
32388
  const seg = segments[i];
@@ -30773,7 +32430,7 @@ registerTool("generate_segment", {
30773
32430
  }));
30774
32431
  registerTool("generate_all_segments", {
30775
32432
  title: "Generate all pending segments (sequential)",
30776
- description: "Generates every not-yet-completed scene (pending or previously failed) in position order, waiting for each to finish before starting" + "the next (preserves visual continuity). Each AI-generated scene is a fixed 8 seconds, so N scenes ≈ N×8s of footage. Costs 5 credits per segment generated. Stops and reports what " + "completed if a scene errors on submit (e.g. 402 credits_insufficient) or fails while rendering; if a " + "scene is still rendering past the budget it returns timed_out so you can re-run to continue (completed " + "scenes are skipped). Tip: to generate everything at lowest cost, skip this and just call render — it auto-generates any ungenerated scenes at the batch rate (4 credits/scene for ≥3); use this tool when you want to review or stop per scene. May take several minutes.",
32433
+ description: "Generates every not-yet-completed scene (pending or previously failed) in position order, waiting for each to finish before starting" + "the next (preserves visual continuity). Each AI-generated scene is a fixed 8 seconds, so N scenes ≈ N×8s of footage. Costs 5 credits per segment generated. Stops and reports what " + "completed if a scene errors on submit (e.g. 402 credits_insufficient) or fails while rendering; if a " + "scene is still rendering past the budget it returns timed_out so you can re-run to continue (completed " + "scenes are skipped). The whole call is capped at ~280s: on a project with many scenes it processes as " + "many as fit, returns timed_out, and you re-run to continue (it picks up the remaining pending scenes). " + "Tip: to generate everything at lowest cost, skip this and just call render — it auto-generates any ungenerated scenes at the batch rate (4 credits/scene for ≥3); use this tool when you want to review or stop per scene. May take several minutes.",
30777
32434
  inputSchema: { slug: exports_external.string().describe("Editor project slug") },
30778
32435
  annotations: {
30779
32436
  title: "Generate all segments",
@@ -30788,6 +32445,8 @@ registerTool("generate_all_segments", {
30788
32445
  const st = s.status ?? s.generation_status;
30789
32446
  return st === undefined || st === "pending" || st === "draft" || st === "failed";
30790
32447
  });
32448
+ const overallDeadline = Date.now() + 280000;
32449
+ const PER_SEGMENT_MAX_MS = 6 * 60 * 1000;
30791
32450
  const generated = [];
30792
32451
  let n = 0;
30793
32452
  for (const seg of pending) {
@@ -30795,6 +32454,15 @@ registerTool("generate_all_segments", {
30795
32454
  if (id === undefined)
30796
32455
  continue;
30797
32456
  const sid = id;
32457
+ const remainingMs = overallDeadline - Date.now();
32458
+ if (remainingMs <= 0) {
32459
+ return ok({
32460
+ slug: args.slug,
32461
+ generated,
32462
+ timed_out: true,
32463
+ note: "Wall-clock budget reached — stopped before the next scene. Re-run generate_all_segments to continue (it retries failed/pending scenes and skips completed ones)."
32464
+ });
32465
+ }
30798
32466
  await reportProgress(extra, ++n, `generating segment ${String(sid)} (${n}/${pending.length})`, pending.length);
30799
32467
  try {
30800
32468
  await client.post(`/editor/${args.slug}/segments/${String(sid)}/generate`, undefined, undefined);
@@ -30807,7 +32475,7 @@ registerTool("generate_all_segments", {
30807
32475
  note: "Stopped on first error — fix the cause (often credits) and re-run; completed scenes are skipped, failed/pending are retried."
30808
32476
  });
30809
32477
  }
30810
- const seg_result = await pollSegmentToTerminal(client, args.slug, sid, extra, 6 * 60 * 1000, 15000);
32478
+ const seg_result = await pollSegmentToTerminal(client, args.slug, sid, extra, Math.min(PER_SEGMENT_MAX_MS, remainingMs), 15000);
30811
32479
  if (seg_result.status === "failed") {
30812
32480
  return ok({
30813
32481
  slug: args.slug,
@@ -31009,7 +32677,7 @@ async function pollUploadToReady(client, slug, uploadId, extra, budgetMs, interv
31009
32677
  let { status, error: error2 } = await read();
31010
32678
  while (status !== "ready" && status !== "failed" && Date.now() + intervalMs <= deadline) {
31011
32679
  await reportProgress(extra, ++n, `upload ${uid}: ${status}`);
31012
- await sleep2(intervalMs);
32680
+ await sleep4(intervalMs);
31013
32681
  ({ status, error: error2 } = await read());
31014
32682
  }
31015
32683
  return {
@@ -31031,7 +32699,8 @@ registerTool("upload_video", {
31031
32699
  annotations: { title: "Upload video", ...WRITE, idempotentHint: false }
31032
32700
  }, tool(async (args, client, extra) => {
31033
32701
  const uploaded = await uploadVideoFile(client, args.slug, args.file_path, {
31034
- fitMode: args.fit_mode
32702
+ fitMode: args.fit_mode,
32703
+ onProgress: (completed, total, message) => reportProgress(extra, completed, message, total)
31035
32704
  });
31036
32705
  await reportProgress(extra, 0, `uploaded ${uploaded.upload_id}, processing…`);
31037
32706
  const waitSeconds = Math.min(280, Math.max(10, args.max_wait_seconds ?? 240));
@@ -31231,8 +32900,9 @@ registerTool("set_short_poster", {
31231
32900
  }));
31232
32901
  registerTool("get_status", {
31233
32902
  title: "Get generation status",
31234
- description: "Returns a normalized status {stage, terminal, ready, video_url, error} for a short or editor " + "project. ready:true means the post-ready MP4 is at video_url.",
32903
+ description: "Returns a normalized status {stage, terminal, ready, video_url, error} for a short, editor, " + "tracking, or slider project. ready:true means the post-ready MP4 is at video_url — except for a " + "slider (a carousel of stills, not a video): its video_url is always null, ready:true means every " + "slide is composited, and you read the per-slide image URLs with get_slider.",
31235
32904
  inputSchema: { slug: exports_external.string(), kind: kindSchema },
32905
+ outputSchema: getStatusOutput,
31236
32906
  annotations: { title: "Get status", ...RO }
31237
32907
  }, tool(async (args, client) => {
31238
32908
  const status = await fetchStatus(client, args.kind, args.slug);
@@ -31240,7 +32910,7 @@ registerTool("get_status", {
31240
32910
  }));
31241
32911
  registerTool("wait_for_completion", {
31242
32912
  title: "Wait for a render to finish",
31243
- description: "Polls status (emitting progress) until terminal (ready/failed) or the wait budget is exhausted. " + "Generation can take several minutes; if it returns terminal=false, call again to keep waiting. " + "Pass save_path to download the finished MP4 when it's ready (e.g. to resume a make_video that timed out mid-render).",
32913
+ description: "Polls status (emitting progress) until terminal (ready/failed) or the wait budget is exhausted. " + "Generation can take several minutes; if it returns terminal=false, call again to keep waiting. " + "Pass save_path to download the finished MP4 when it's ready (e.g. to resume a make_video that timed out mid-render). " + 'Works for kind:"slider" too, but a carousel has no MP4 — it just goes terminal when composited; read the slide ' + "images with get_slider (save_path is ignored for sliders).",
31244
32914
  inputSchema: {
31245
32915
  slug: exports_external.string(),
31246
32916
  kind: kindSchema,
@@ -31248,7 +32918,12 @@ registerTool("wait_for_completion", {
31248
32918
  poll_interval_seconds: exports_external.number().int().min(5).max(60).optional().describe("Default 15"),
31249
32919
  save_path: exports_external.string().optional().describe("Optional .mp4 path to download to when ready (confined to HUBFLUENCER_OUTPUT_DIR or cwd)")
31250
32920
  },
31251
- annotations: { title: "Wait for completion", ...RO }
32921
+ outputSchema: waitForCompletionOutput,
32922
+ annotations: {
32923
+ title: "Wait for completion",
32924
+ ...WRITE,
32925
+ idempotentHint: true
32926
+ }
31252
32927
  }, tool(async (args, client, extra) => {
31253
32928
  if (args.save_path)
31254
32929
  resolveSavePath(args.save_path);
@@ -31265,14 +32940,18 @@ registerTool("wait_for_completion", {
31265
32940
  }));
31266
32941
  registerTool("download_result", {
31267
32942
  title: "Get / download the finished video",
31268
- description: "Returns the post-ready MP4 URL for a completed project (presigned, ~24h TTL — use promptly). " + "If save_path is given (confined to HUBFLUENCER_OUTPUT_DIR or cwd), the file is downloaded there.",
32943
+ description: "Returns the post-ready MP4 URL for a completed project (presigned, ~24h TTL — use promptly). " + "If save_path is given (confined to HUBFLUENCER_OUTPUT_DIR or cwd), the file is downloaded there. " + "Not for sliders — a carousel has no MP4; use get_slider for its slide images.",
31269
32944
  inputSchema: {
31270
32945
  slug: exports_external.string(),
31271
32946
  kind: kindSchema,
31272
32947
  save_path: exports_external.string().optional().describe("Optional .mp4 path inside the output base")
31273
32948
  },
31274
- annotations: { title: "Download result", ...RO }
32949
+ outputSchema: downloadResultOutput,
32950
+ annotations: { title: "Download result", ...WRITE, idempotentHint: true }
31275
32951
  }, tool(async (args, client) => {
32952
+ if (args.kind === "slider") {
32953
+ return fail("A slider (carousel) has no MP4 to download. Use get_slider to read the per-slide image URLs.");
32954
+ }
31276
32955
  const status = await fetchStatus(client, args.kind, args.slug);
31277
32956
  if (!status.ready || !status.video_url) {
31278
32957
  return fail(`Not ready to download (stage=${status.stage}, ready=${status.ready}). Wait for completion first.`);
@@ -31287,6 +32966,900 @@ registerTool("download_result", {
31287
32966
  note: "Presigned ~24h URL — download promptly."
31288
32967
  }, link);
31289
32968
  }));
32969
+ registerTool("extract_product_page", {
32970
+ title: "Extract product facts from a URL",
32971
+ description: "Fetches a public product page SERVER-SIDE (SSRF-guarded, HTML-only, no redirects to internal ranges) " + "and returns parsed product facts: name, description, benefits, price, brand, plus candidate product " + "image URLs and a site/brand logo URL (URLs only — images are NOT downloaded here). Stateless: writes " + "nothing and spends 0 credits. Scope: video:generate. Rate limit: 30/min. " + "Next: create_product_profile from these facts, then import_product_image to pull the primary image + logo " + "into the profile — or just call create_campaign to do all of that end to end.",
32972
+ inputSchema: {
32973
+ url: exports_external.string().max(2048).describe("Absolute http(s) URL of a product page (≤2048 chars)")
32974
+ },
32975
+ annotations: {
32976
+ title: "Extract product page",
32977
+ ...WRITE,
32978
+ idempotentHint: true
32979
+ }
32980
+ }, tool(async (args, client) => {
32981
+ const url = args.url?.trim();
32982
+ if (!url)
32983
+ return fail("url is required.");
32984
+ const res = await client.post("/product-sources/extract", { url });
32985
+ return ok(asRecord(res).data ?? res);
32986
+ }));
32987
+ registerTool("import_product_image", {
32988
+ title: "Import a product image URL into a profile",
32989
+ description: "Fetches an image at a public URL SERVER-SIDE (SSRF-guarded, JPEG/PNG only — verified by magic bytes, not " + "just the Content-Type — size-capped) and stores it as a ready catalog asset, linking it to a product " + "profile. link_as 'primary_image' (default) sets the profile's product image; 'logo' sets its logo. " + "Spends 0 credits. Scope: video:generate. Rate limit: 30/min. Use the image/logo URLs from " + "extract_product_page. 404 if the product profile isn't yours; 422 if the URL isn't a safe JPG/PNG.",
32990
+ inputSchema: {
32991
+ product_profile_id: exports_external.number().int().describe("Id of one of YOUR product profiles to link the image to"),
32992
+ url: exports_external.string().max(2048).describe("Absolute http(s) URL of a JPG/PNG image (≤2048 chars)"),
32993
+ link_as: exports_external.enum(["primary_image", "logo"]).optional().describe("Which profile field to set: 'primary_image' (default) or 'logo'")
32994
+ },
32995
+ annotations: {
32996
+ title: "Import product image",
32997
+ ...WRITE,
32998
+ idempotentHint: false
32999
+ }
33000
+ }, tool(async (args, client) => {
33001
+ const url = args.url?.trim();
33002
+ if (!url)
33003
+ return fail("url is required.");
33004
+ const body = {
33005
+ image_url: url,
33006
+ product_profile_id: args.product_profile_id
33007
+ };
33008
+ if (args.link_as !== undefined)
33009
+ body.link_as = args.link_as;
33010
+ const res = await client.post("/product-sources/import-image", body);
33011
+ return ok(asRecord(res).data ?? res);
33012
+ }));
33013
+ registerTool("list_product_profiles", {
33014
+ title: "List your product profiles",
33015
+ description: "Lists your saved product fact sheets (name, benefits, offer, audience, CTA, compliance constraints, " + "imported image/logo keys), newest first. Read-only, 0 credits. Scope: video:read. Pass an id to plan_campaign / " + "generate_hook_variations / create_campaign_pack so a campaign is planned from a saved source of truth.",
33016
+ inputSchema: {},
33017
+ annotations: { title: "List product profiles", ...RO }
33018
+ }, tool(async (_args, client) => ok(await client.get("/product-profiles"))));
33019
+ registerTool("create_product_profile", {
33020
+ title: "Create a product profile",
33021
+ description: "Saves a reusable product fact sheet. Only `name` is required; the rest (description, benefits[], offer, " + "proof_points[], audience, cta, banned_claims[], brand_tone) ground the AI when planning/generating. " + "0 credits. Scope: video:generate. Tip: seed it from extract_product_page's facts. To attach imagery, call " + "import_product_image afterwards (sets primary_image/logo).",
33022
+ inputSchema: {
33023
+ name: exports_external.string().min(1).max(300).describe("Product name (required)"),
33024
+ description: exports_external.string().max(4000).optional().describe("What the product is / does"),
33025
+ benefits: exports_external.array(exports_external.string()).optional().describe("Key benefit statements"),
33026
+ offer: exports_external.string().max(500).optional().describe("Headline offer / price / promotion"),
33027
+ proof_points: exports_external.array(exports_external.string()).optional().describe("Ratings, review counts, other proof points"),
33028
+ audience: exports_external.string().max(500).optional().describe("Target audience"),
33029
+ cta: exports_external.string().max(300).optional().describe("Preferred call to action"),
33030
+ banned_claims: exports_external.array(exports_external.string()).optional().describe("Claims the product must never make"),
33031
+ brand_tone: exports_external.string().max(500).optional().describe("Tone of voice for generated copy"),
33032
+ source_url: exports_external.string().max(2048).optional().describe("Product page URL the facts came from")
33033
+ },
33034
+ annotations: {
33035
+ title: "Create product profile",
33036
+ ...WRITE,
33037
+ idempotentHint: false
33038
+ }
33039
+ }, tool(async (args, client) => {
33040
+ const res = await client.post("/product-profiles", pickProductProfileFields(args));
33041
+ return ok(asRecord(res).data ?? res);
33042
+ }));
33043
+ registerTool("update_product_profile", {
33044
+ title: "Update a product profile",
33045
+ description: "Patches a saved product profile — only the fields you pass change. 0 credits. Scope: video:generate. " + "404 for an unknown/foreign id.",
33046
+ inputSchema: {
33047
+ id: exports_external.number().int().describe("Product profile id"),
33048
+ name: exports_external.string().min(1).max(300).optional().describe("Product name"),
33049
+ description: exports_external.string().max(4000).optional(),
33050
+ benefits: exports_external.array(exports_external.string()).optional(),
33051
+ offer: exports_external.string().max(500).optional(),
33052
+ proof_points: exports_external.array(exports_external.string()).optional(),
33053
+ audience: exports_external.string().max(500).optional(),
33054
+ cta: exports_external.string().max(300).optional(),
33055
+ banned_claims: exports_external.array(exports_external.string()).optional(),
33056
+ brand_tone: exports_external.string().max(500).optional(),
33057
+ source_url: exports_external.string().max(2048).optional()
33058
+ },
33059
+ annotations: {
33060
+ title: "Update product profile",
33061
+ ...WRITE,
33062
+ idempotentHint: true
33063
+ }
33064
+ }, tool(async (args, client) => {
33065
+ const res = await client.patch(`/product-profiles/${args.id}`, pickProductProfileFields(args));
33066
+ return ok(asRecord(res).data ?? res);
33067
+ }));
33068
+ registerTool("list_brand_profiles", {
33069
+ title: "List your brand profiles",
33070
+ description: "Lists your saved brand identities (name, logo key, colors, font, tone words, default CTA/audience, " + "preferred visual_language/creative_format, and linked assets), newest first. Read-only, 0 credits. " + "Scope: video:read. Pass an id as brand_profile_id to generate_hook_variations / create_campaign_pack / " + "create_campaign to steer tone/format and inherit the logo into materialized drafts.",
33071
+ inputSchema: {},
33072
+ annotations: { title: "List brand profiles", ...RO }
33073
+ }, tool(async (_args, client) => ok(await client.get("/brand-profiles"))));
33074
+ registerTool("create_brand_profile", {
33075
+ title: "Create a brand profile",
33076
+ description: "Saves a reusable brand identity. Only `name` is required. primary_color/secondary_color are hex " + "(e.g. #09EFBE); preferred_visual_language and preferred_creative_format take the same values as the " + "editor/short tools; tone_words[]/default_cta/default_audience/banned_claims[] steer generated copy. " + "0 credits. Scope: video:generate. Attach a logo/product asset from your catalog with " + "update_brand_profile (attach_catalog_asset_id).",
33077
+ inputSchema: {
33078
+ name: exports_external.string().min(1).max(300).describe("Brand name (required)"),
33079
+ logo_s3_key: exports_external.string().optional().describe("S3 key of the brand logo (from an import/upload)"),
33080
+ primary_color: exports_external.string().optional().describe("Hex color, e.g. #ffffff"),
33081
+ secondary_color: exports_external.string().optional().describe("Hex color, e.g. #ffffff"),
33082
+ font_family: exports_external.string().optional().describe("Brand typography (a supported Shorts overlay font)"),
33083
+ tone_words: exports_external.array(exports_external.string()).optional().describe("Tone descriptors, e.g. ['bold','warm']"),
33084
+ default_cta: exports_external.string().max(300).optional(),
33085
+ default_audience: exports_external.string().max(500).optional(),
33086
+ banned_claims: exports_external.array(exports_external.string()).optional(),
33087
+ preferred_visual_language: exports_external.enum(VISUAL_LANGUAGES).optional().describe("Default visual language for generated videos"),
33088
+ preferred_creative_format: exports_external.enum(CREATIVE_FORMATS).optional().describe("Default creative format for generated videos")
33089
+ },
33090
+ annotations: {
33091
+ title: "Create brand profile",
33092
+ ...WRITE,
33093
+ idempotentHint: false
33094
+ }
33095
+ }, tool(async (args, client) => {
33096
+ const res = await client.post("/brand-profiles", pickBrandProfileFields(args));
33097
+ return ok(asRecord(res).data ?? res);
33098
+ }));
33099
+ registerTool("update_brand_profile", {
33100
+ title: "Update a brand profile (+ attach/detach assets)",
33101
+ description: "Patches a saved brand profile — only the fields you pass change. 0 credits. Scope: video:generate. " + "You can ALSO manage its catalog-asset links in the same call: attach_catalog_asset_id links one of your " + "catalog assets (role required: 'logo' | 'product_image' | 'other'); detach_asset_id removes an existing " + "link by its LINK id (from the profile's assets[].id, not the catalog_asset_id). 404 for an unknown/foreign " + "profile; 422 if the catalog asset isn't yours. Returns the updated profile with its assets.",
33102
+ inputSchema: {
33103
+ id: exports_external.number().int().describe("Brand profile id"),
33104
+ name: exports_external.string().min(1).max(300).optional(),
33105
+ logo_s3_key: exports_external.string().optional(),
33106
+ primary_color: exports_external.string().optional().describe("Hex color, e.g. #ffffff"),
33107
+ secondary_color: exports_external.string().optional().describe("Hex color"),
33108
+ font_family: exports_external.string().optional(),
33109
+ tone_words: exports_external.array(exports_external.string()).optional(),
33110
+ default_cta: exports_external.string().max(300).optional(),
33111
+ default_audience: exports_external.string().max(500).optional(),
33112
+ banned_claims: exports_external.array(exports_external.string()).optional(),
33113
+ preferred_visual_language: exports_external.enum(VISUAL_LANGUAGES).optional(),
33114
+ preferred_creative_format: exports_external.enum(CREATIVE_FORMATS).optional(),
33115
+ attach_catalog_asset_id: exports_external.number().int().optional().describe("Link this catalog asset id to the profile (requires asset_role)"),
33116
+ asset_role: exports_external.enum(["logo", "product_image", "other"]).optional().describe("Role for attach_catalog_asset_id (required when attaching)"),
33117
+ detach_asset_id: exports_external.number().int().optional().describe("Remove this asset LINK id (assets[].id) from the profile")
33118
+ },
33119
+ annotations: {
33120
+ title: "Update brand profile",
33121
+ ...WRITE,
33122
+ idempotentHint: false
33123
+ }
33124
+ }, tool(async (args, client) => {
33125
+ if (args.attach_catalog_asset_id !== undefined && args.asset_role === undefined) {
33126
+ return fail("asset_role is required when attach_catalog_asset_id is set ('logo' | 'product_image' | 'other').");
33127
+ }
33128
+ const fields = pickBrandProfileFields(args);
33129
+ if (Object.keys(fields).length > 0) {
33130
+ await client.patch(`/brand-profiles/${args.id}`, fields);
33131
+ }
33132
+ if (args.attach_catalog_asset_id !== undefined) {
33133
+ await client.post(`/brand-profiles/${args.id}/assets`, {
33134
+ catalog_asset_id: args.attach_catalog_asset_id,
33135
+ role: args.asset_role
33136
+ });
33137
+ }
33138
+ if (args.detach_asset_id !== undefined) {
33139
+ await client.del(`/brand-profiles/${args.id}/assets/${args.detach_asset_id}`);
33140
+ }
33141
+ const res = await client.get(`/brand-profiles/${args.id}`);
33142
+ return ok(asRecord(res).data ?? res);
33143
+ }));
33144
+ registerTool("plan_campaign", {
33145
+ title: "Plan a campaign from product facts",
33146
+ description: "Turns product facts (a saved product_profile_id OR an inline product with a name) into a campaign PLAN: " + "ad hooks, recommended output formats, a one-line positioning statement, and a transparent credit ESTIMATE " + "for the proposed paid outputs. Generates no media and persists nothing. Spends $0 — it consumes the FREE " + "daily AI-assist quota (NOT credits). Scope: video:generate. Rate limit: 30/min. On 429 the quota is " + "exhausted: set auto_unlock:true to spend 1 credit for +10 assists (retried once) or call unlock_ai_assists. " + "Next: generate_hook_variations for draftable variations, then create_campaign_pack.",
33147
+ inputSchema: {
33148
+ product_profile_id: exports_external.number().int().optional().describe("Id of a saved product profile (OR pass product)"),
33149
+ product: exports_external.object({
33150
+ name: exports_external.string(),
33151
+ description: exports_external.string().optional(),
33152
+ benefits: exports_external.array(exports_external.string()).optional(),
33153
+ offer: exports_external.string().optional(),
33154
+ proof_points: exports_external.array(exports_external.string()).optional(),
33155
+ audience: exports_external.string().optional(),
33156
+ cta: exports_external.string().optional(),
33157
+ banned_claims: exports_external.array(exports_external.string()).optional()
33158
+ }).optional().describe("Inline product facts (name required) if you have no profile"),
33159
+ goal: exports_external.string().optional().describe("Campaign goal, e.g. awareness or conversion"),
33160
+ platform: exports_external.string().optional().describe("Target platform, e.g. tiktok, instagram, reels, shorts"),
33161
+ language: exports_external.string().optional().describe('Viewer-facing language code, e.g. "en" (default)'),
33162
+ auto_unlock: exports_external.boolean().optional().describe("On 429, spend 1 credit to unlock +10 assists and retry once (default false)")
33163
+ },
33164
+ annotations: {
33165
+ title: "Plan campaign",
33166
+ ...WRITE,
33167
+ idempotentHint: false
33168
+ }
33169
+ }, tool(async (args, client) => {
33170
+ const srcErr = validatePlanSource(args.product_profile_id, args.product);
33171
+ if (srcErr)
33172
+ return fail(srcErr);
33173
+ const body = buildPlanBody(args);
33174
+ const res = await withAssist(client, args.auto_unlock ?? false, () => client.post("/campaign-packs/plan", body));
33175
+ return ok(asRecord(res).data ?? res);
33176
+ }));
33177
+ registerTool("generate_hook_variations", {
33178
+ title: "Generate a hook variations board",
33179
+ description: "Turns product facts (a saved product_profile_id OR an inline product with a name) and an OPTIONAL " + "brand_profile_id into 3-6 distinct hook variations — each with a hook, angle, suggested output format " + "(editor_ad/short/carousel), a draft script, a ready-to-post caption, hashtags, and a per-variation credit " + "ESTIMATE. Generates no media and persists nothing. Spends $0 — it consumes the FREE daily AI-assist quota " + "(NOT credits). Scope: video:generate. Rate limit: 30/min. On 429 set auto_unlock:true (1 credit → +10 " + "assists, retried once) or call unlock_ai_assists. Next: create_campaign_pack, then add_pack_variations to " + "persist + materialize the ones you pick as DRAFTS.",
33180
+ inputSchema: {
33181
+ product_profile_id: exports_external.number().int().optional().describe("Id of a saved product profile (OR pass product)"),
33182
+ product: exports_external.object({
33183
+ name: exports_external.string(),
33184
+ description: exports_external.string().optional(),
33185
+ benefits: exports_external.array(exports_external.string()).optional(),
33186
+ offer: exports_external.string().optional(),
33187
+ proof_points: exports_external.array(exports_external.string()).optional(),
33188
+ audience: exports_external.string().optional(),
33189
+ cta: exports_external.string().optional(),
33190
+ banned_claims: exports_external.array(exports_external.string()).optional()
33191
+ }).optional().describe("Inline product facts (name required) if you have no profile"),
33192
+ brand_profile_id: exports_external.number().int().optional().describe("Optional saved brand profile to steer tone/format/banned claims"),
33193
+ count: exports_external.number().int().optional().describe("Desired number of variations, clamped to 3-6 (default 4)"),
33194
+ goal: exports_external.string().optional().describe("Campaign goal"),
33195
+ platform: exports_external.string().optional().describe("Target platform, e.g. tiktok, instagram"),
33196
+ language: exports_external.string().optional().describe('Viewer-facing language code, e.g. "en" (default)'),
33197
+ auto_unlock: exports_external.boolean().optional().describe("On 429, spend 1 credit to unlock +10 assists and retry once (default false)")
33198
+ },
33199
+ annotations: {
33200
+ title: "Generate hook variations",
33201
+ ...WRITE,
33202
+ idempotentHint: false
33203
+ }
33204
+ }, tool(async (args, client) => {
33205
+ const srcErr = validatePlanSource(args.product_profile_id, args.product);
33206
+ if (srcErr)
33207
+ return fail(srcErr);
33208
+ const body = buildPlanBody(args);
33209
+ const res = await withAssist(client, args.auto_unlock ?? false, () => client.post("/campaign-packs/hook-variations", body));
33210
+ return ok(asRecord(res).data ?? res);
33211
+ }));
33212
+ registerTool("create_campaign_pack", {
33213
+ title: "Persist a campaign pack",
33214
+ description: "Stores a campaign pack (the planning container) plus any inline TEXT items (hook/caption copy). editor_ad/" + "short/carousel items are NOT created here — persist them via add_pack_variations (which also materializes " + "them into drafts). Spends 0 credits. Scope: video:generate. Link a product_profile_id and/or brand_profile_id " + "so materialized drafts inherit the product image + brand logo. Returns the pack id + items. Next: " + "add_pack_variations to fill it with draftable outputs.",
33215
+ inputSchema: {
33216
+ title: exports_external.string().max(300).optional().describe("Human-friendly pack name"),
33217
+ product_profile_id: exports_external.number().int().optional().describe("Saved product profile to ground the pack (422 if not yours)"),
33218
+ brand_profile_id: exports_external.number().int().optional().describe("Saved brand profile applied to the pack + materialized drafts"),
33219
+ brief: exports_external.string().max(4000).optional().describe("Freeform brief for the campaign"),
33220
+ source_type: exports_external.enum(["url", "image", "brief", "asset"]).optional().describe("Where the pack originated"),
33221
+ source_url: exports_external.string().max(2048).optional().describe("Origin URL when source_type is 'url'"),
33222
+ language: exports_external.string().optional().describe("Language stored on the pack + used for materialized draft copy"),
33223
+ items: exports_external.array(exports_external.object({
33224
+ kind: exports_external.enum(["hook", "caption"]).describe("Only hook/caption text items persist here"),
33225
+ hook: exports_external.string().optional(),
33226
+ caption: exports_external.string().optional(),
33227
+ hashtags: exports_external.array(exports_external.string()).optional()
33228
+ })).optional().describe("Optional inline hook/caption copy items")
33229
+ },
33230
+ annotations: {
33231
+ title: "Create campaign pack",
33232
+ ...WRITE,
33233
+ idempotentHint: false
33234
+ }
33235
+ }, tool(async (args, client) => {
33236
+ const body = {};
33237
+ if (args.title !== undefined)
33238
+ body.title = args.title;
33239
+ if (args.product_profile_id !== undefined)
33240
+ body.product_profile_id = args.product_profile_id;
33241
+ if (args.brand_profile_id !== undefined)
33242
+ body.brand_profile_id = args.brand_profile_id;
33243
+ if (args.brief !== undefined)
33244
+ body.brief = args.brief;
33245
+ if (args.source_type !== undefined)
33246
+ body.source_type = args.source_type;
33247
+ if (args.source_url !== undefined)
33248
+ body.source_url = args.source_url;
33249
+ if (args.language !== undefined)
33250
+ body.language = args.language;
33251
+ if (args.items !== undefined)
33252
+ body.items = args.items;
33253
+ const res = await client.post("/campaign-packs", body);
33254
+ return ok(summarizePack(asRecord(res).data));
33255
+ }));
33256
+ registerTool("list_campaign_packs", {
33257
+ title: "List your campaign packs",
33258
+ description: "Lists your campaign packs (newest first) at the PACK level only: id, title, status, source, and linked " + "product/brand profile. It does NOT include per-item detail — the list endpoint doesn't load items, so " + "per-item kind/status/draft slug/deep-link route/credit estimate come from get_campaign_pack({ id }). " + "Read-only, 0 credits. Scope: video:read.",
33259
+ inputSchema: {},
33260
+ annotations: { title: "List campaign packs", ...RO }
33261
+ }, tool(async (_args, client) => {
33262
+ const res = await client.get("/campaign-packs");
33263
+ const data = asRecord(res).data;
33264
+ const packs = Array.isArray(data) ? data.map(summarizePackListEntry) : [];
33265
+ return ok({ packs });
33266
+ }));
33267
+ registerTool("get_campaign_pack", {
33268
+ title: "Get one campaign pack with its items",
33269
+ description: "Returns a campaign pack and its items. Per item you get: kind, status (planned → draft → generating → " + "completed | failed), the materialized DRAFT slug + deep-link route (editor_ad/short → the video project, " + "carousel → the slider), the per-item credit ESTIMATE, and — once an item has been generated — its " + "preview_url/video_result_id. Read-only, 0 credits. Scope: video:read. Use the draft slugs to generate each " + "output EXPLICITLY (a separate, PAID step): start_autopilot/generate for videos, generate_slider for carousels.",
33270
+ inputSchema: {
33271
+ id: exports_external.number().int().describe("Campaign pack id")
33272
+ },
33273
+ outputSchema: getCampaignPackOutput,
33274
+ annotations: { title: "Get campaign pack", ...RO }
33275
+ }, tool(async (args, client) => {
33276
+ const res = await client.get(`/campaign-packs/${args.id}`);
33277
+ return ok(summarizePack(asRecord(res).data));
33278
+ }));
33279
+ registerTool("add_pack_variations", {
33280
+ title: "Add + materialize hook variations to a pack",
33281
+ description: "Persists a batch of selected hook variations (from generate_hook_variations) as items under a pack and, by " + "default (materialize:true), turns each into an editable DRAFT (a video project or image slider). Spends 0 " + "credits — nothing is generated/rendered (that's a separate PAID step). Scope: video:generate. Caps: at most " + "12 variations per call; rate-limited to 15/min. Per-variation BEST-EFFORT: a variation that can't be " + "materialized (e.g. you hit a draft limit) stays a 'planned' item with a recorded reason while the rest still " + "materialize — the response summary lists created/materialized/planned + skipped_reasons. Pass the variation " + "objects verbatim from the board (each needs at least suggested_format). Next: get_campaign_pack to review, " + "then generate each draft explicitly.",
33282
+ inputSchema: {
33283
+ pack_id: exports_external.number().int().describe("Campaign pack id (from create_campaign_pack)"),
33284
+ variations: exports_external.array(exports_external.object({
33285
+ suggested_format: exports_external.enum(["editor_ad", "short", "carousel"]).describe("Output kind for this variation"),
33286
+ hook: exports_external.string().optional(),
33287
+ angle: exports_external.string().optional(),
33288
+ draft_script: exports_external.string().optional(),
33289
+ caption: exports_external.string().optional(),
33290
+ hashtags: exports_external.array(exports_external.string()).optional(),
33291
+ credit_estimate: exports_external.record(exports_external.unknown()).optional()
33292
+ })).describe("1-12 variations to persist (echo them from the board)"),
33293
+ materialize: exports_external.boolean().optional().describe("Materialize each into a draft (default true); false keeps them planned")
33294
+ },
33295
+ annotations: {
33296
+ title: "Add pack variations",
33297
+ ...WRITE,
33298
+ idempotentHint: false
33299
+ }
33300
+ }, tool(async (args, client) => {
33301
+ const err = validateVariationsBatch(args.variations);
33302
+ if (err)
33303
+ return fail(err);
33304
+ const body = { variations: args.variations };
33305
+ if (args.materialize !== undefined)
33306
+ body.materialize = args.materialize;
33307
+ const res = await client.post(`/campaign-packs/${args.pack_id}/variations`, body);
33308
+ const data = asRecord(asRecord(res).data);
33309
+ const items = Array.isArray(data.items) ? data.items.map(summarizePackItem) : [];
33310
+ return ok({
33311
+ campaign_pack_id: data.campaign_pack_id ?? args.pack_id,
33312
+ items,
33313
+ summary: data.summary ?? null,
33314
+ next: "get_campaign_pack to review the drafts; generation/render is a separate, explicit, PAID step per draft."
33315
+ });
33316
+ }));
33317
+ registerTool("materialize_pack_item", {
33318
+ title: "Materialize one planned pack item into a draft",
33319
+ description: "Turns a single PLANNED editor_ad/short/carousel item into an editable DRAFT (a video project or image " + "slider) and links it back to the item. Spends 0 credits — generation/render is a separate PAID step. " + "Scope: video:generate. Rate limit: 60/min. Safe to retry: NO duplicate draft is ever created (a concurrent " + "race loser converges on the same draft under a row lock). A SEQUENTIAL re-call of an already-materialized " + "item returns 422 ('already materialized') — that 422 means the draft already exists; read it via " + "get_campaign_pack. 422 also for hook/caption items or when you hit a draft limit. Prefer add_pack_variations " + "to materialize a batch at once; use this to (re)try one item.",
33320
+ inputSchema: {
33321
+ pack_id: exports_external.number().int().describe("Campaign pack id"),
33322
+ item_id: exports_external.number().int().describe("Campaign pack item id (from get_campaign_pack)")
33323
+ },
33324
+ annotations: {
33325
+ title: "Materialize pack item",
33326
+ ...WRITE,
33327
+ idempotentHint: true
33328
+ }
33329
+ }, tool(async (args, client) => {
33330
+ const res = await client.post(`/campaign-packs/${args.pack_id}/items/${args.item_id}/materialize`);
33331
+ return ok(summarizePackItem(asRecord(res).data));
33332
+ }));
33333
+ registerTool("create_campaign", {
33334
+ title: "Create a whole campaign from a product (one shot)",
33335
+ description: "The high-level path (like make_video, but for a content CAMPAIGN): give a product URL (or a saved " + "product_profile_id) and get back a persisted campaign pack full of editable DRAFTS. It runs the whole " + "pipeline — extract the page (if a url) → save a product profile → best-effort import the primary image + " + "logo → plan → generate hook variations → create the pack → add the variations as materialized DRAFTS. " + "Spends $0 end to end: it only ever touches the FREE daily AI-assist quota (plan + hook variations) and " + "creates 0-credit drafts. It NEVER starts autopilot/generation/render — that stays a SEPARATE, explicit, " + "PAID step you trigger per draft afterwards. Scope: video:generate. On a partial failure it returns " + "everything achieved so far (profile id, pack id, per-item status) plus precise resume guidance naming the " + "granular tool to continue with. The result lists each draft's slug + kind + per-item credit estimate.",
33336
+ inputSchema: {
33337
+ url: exports_external.string().max(2048).optional().describe("Product page URL to ingest (required unless product_profile_id is given)"),
33338
+ product_profile_id: exports_external.number().int().optional().describe("Use a saved product profile instead of extracting a URL"),
33339
+ brand_profile_id: exports_external.number().int().optional().describe("Optional saved brand profile — steers hooks and gives drafts your logo/brand"),
33340
+ item_count: exports_external.number().int().optional().describe("How many variations to aim for, clamped to 3-6 (default 6)"),
33341
+ formats: exports_external.array(exports_external.enum(["editor_ad", "short", "carousel"])).optional().describe("Restrict to these output formats (default: whatever the board recommends)"),
33342
+ language: exports_external.string().optional().describe('Viewer-facing language code, e.g. "en" (default)'),
33343
+ goal: exports_external.string().optional().describe("Campaign goal, e.g. conversion"),
33344
+ platform: exports_external.string().optional().describe("Target platform, e.g. tiktok, instagram")
33345
+ },
33346
+ annotations: {
33347
+ title: "Create campaign",
33348
+ ...WRITE,
33349
+ idempotentHint: false
33350
+ },
33351
+ outputSchema: createCampaignOutput
33352
+ }, tool(async (args, client) => {
33353
+ if (!args.url && args.product_profile_id === undefined) {
33354
+ return fail("Provide either url or product_profile_id.");
33355
+ }
33356
+ const result = await runCreateCampaign(client, args, (msg) => console.error(`[create_campaign] ${msg}`));
33357
+ return ok(result);
33358
+ }));
33359
+ registerTool("create_series", {
33360
+ title: "Create a recurring-content series",
33361
+ description: "Creates a reusable recurring-content SHOW template (e.g. 'Myth vs Fact', 'Founder Tip') bound to an " + "optional brand + product profile — the durable format the episode planner draws upcoming ideas from. " + "name and template are required. Spends 0 credits. Scope: video:generate. A supplied " + "brand_profile_id/product_profile_id must be one of your own (422 invalid_brand_profile/" + "invalid_product_profile otherwise). Next: plan_series_episodes to draft idea episodes.",
33362
+ inputSchema: {
33363
+ name: exports_external.string().max(300).describe("Show name, e.g. 'Myth vs Fact' (required)"),
33364
+ template: exports_external.enum([
33365
+ "founder_tip",
33366
+ "myth_vs_fact",
33367
+ "before_after",
33368
+ "customer_question",
33369
+ "build_in_public",
33370
+ "product_demo_of_the_week",
33371
+ "mistake_to_avoid"
33372
+ ]).describe("The recurring format/template (required). One of: founder_tip, myth_vs_fact, " + "before_after, customer_question, build_in_public, product_demo_of_the_week, mistake_to_avoid"),
33373
+ brand_profile_id: exports_external.number().int().optional().describe("Optional saved brand profile the series (and its drafts) inherit"),
33374
+ product_profile_id: exports_external.number().int().optional().describe("Optional saved product profile grounding episode ideas"),
33375
+ cadence: exports_external.enum(["weekly", "biweekly", "monthly"]).optional().describe("Posting cadence: weekly, biweekly, or monthly (steers the ideas)"),
33376
+ tone: exports_external.string().optional().describe("Editorial tone, e.g. playful, expert"),
33377
+ default_format: exports_external.enum(["editor_ad", "short", "carousel"]).optional().describe("Default output format for materialized episodes"),
33378
+ status: exports_external.enum(["active", "paused", "archived"]).optional().describe("Series status (default active)")
33379
+ },
33380
+ annotations: {
33381
+ title: "Create series",
33382
+ ...WRITE,
33383
+ idempotentHint: false
33384
+ }
33385
+ }, tool(async (args, client) => {
33386
+ const res = await client.post("/series", pickSeriesFields(args));
33387
+ return ok(summarizeSeries(asRecord(res).data));
33388
+ }));
33389
+ registerTool("list_series", {
33390
+ title: "List your series",
33391
+ description: "Lists your recurring-content series (newest first), each summarized with its status, template, cadence, " + "tone, default format, and linked brand/product profile + rolling campaign pack ids. Read-only, 0 credits. " + "Scope: video:read. Use get_series_dashboard for one series' episodes grouped by lane.",
33392
+ inputSchema: {},
33393
+ annotations: { title: "List series", ...RO }
33394
+ }, tool(async (_args, client) => {
33395
+ const res = await client.get("/series");
33396
+ const data = asRecord(res).data;
33397
+ const series = Array.isArray(data) ? data.map(summarizeSeries) : [];
33398
+ return ok({ series });
33399
+ }));
33400
+ registerTool("update_series", {
33401
+ title: "Update a series",
33402
+ description: "Patches one of your series; only the fields you pass change. Toggle status among active/paused/archived " + "(a paused series stops surfacing for new ideas). A changed brand_profile_id/product_profile_id is " + "re-verified against your own profiles (422 otherwise). Spends 0 credits. Scope: video:generate. " + "404 for an unknown/foreign series.",
33403
+ inputSchema: {
33404
+ id: exports_external.number().int().describe("Series id"),
33405
+ name: exports_external.string().max(300).optional().describe("Show name"),
33406
+ template: exports_external.enum([
33407
+ "founder_tip",
33408
+ "myth_vs_fact",
33409
+ "before_after",
33410
+ "customer_question",
33411
+ "build_in_public",
33412
+ "product_demo_of_the_week",
33413
+ "mistake_to_avoid"
33414
+ ]).optional().describe("Recurring format/template. One of: founder_tip, myth_vs_fact, before_after, " + "customer_question, build_in_public, product_demo_of_the_week, mistake_to_avoid"),
33415
+ brand_profile_id: exports_external.number().int().optional().describe("Saved brand profile to bind (422 if not yours)"),
33416
+ product_profile_id: exports_external.number().int().optional().describe("Saved product profile to bind (422 if not yours)"),
33417
+ cadence: exports_external.enum(["weekly", "biweekly", "monthly"]).optional().describe("Posting cadence: weekly, biweekly, or monthly"),
33418
+ tone: exports_external.string().optional().describe("Editorial tone"),
33419
+ default_format: exports_external.enum(["editor_ad", "short", "carousel"]).optional().describe("Default output format for materialized episodes"),
33420
+ status: exports_external.enum(["active", "paused", "archived"]).optional().describe("Series status")
33421
+ },
33422
+ annotations: { title: "Update series", ...WRITE, idempotentHint: false }
33423
+ }, tool(async (args, client) => {
33424
+ const res = await client.patch(`/series/${args.id}`, pickSeriesFields(args));
33425
+ return ok(summarizeSeries(asRecord(res).data));
33426
+ }));
33427
+ registerTool("plan_series_episodes", {
33428
+ title: "Plan upcoming episode ideas for a series",
33429
+ description: "Drafts 3-5 distinct upcoming EPISODE ideas (status 'idea') for one of your series, grounded on its " + "product profile (or name) and steered by its brand/tone/cadence, and persists them as episodes. " + "Generates no media and spends $0 — it consumes the FREE daily AI-assist quota (NOT credits). " + "Scope: video:generate. Rate limit: 30/min. On 429 the quota is exhausted: set auto_unlock:true to spend " + "1 credit for +10 assists (retried once) or call unlock_ai_assists. 404 for an unknown/foreign series. " + "Next: materialize_episode to turn a chosen idea into a 0-credit draft.",
33430
+ inputSchema: {
33431
+ series_id: exports_external.number().int().describe("Series id"),
33432
+ count: exports_external.number().int().optional().describe("Desired number of ideas, clamped to 3-5 (default 4)"),
33433
+ language: exports_external.string().optional().describe('Viewer-facing language code, e.g. "en" (default)'),
33434
+ auto_unlock: exports_external.boolean().optional().describe("On 429, spend 1 credit to unlock +10 assists and retry once (default false)")
33435
+ },
33436
+ annotations: {
33437
+ title: "Plan series episodes",
33438
+ ...WRITE,
33439
+ idempotentHint: false
33440
+ }
33441
+ }, tool(async (args, client) => {
33442
+ const body = {};
33443
+ if (args.count !== undefined)
33444
+ body.count = args.count;
33445
+ if (args.language !== undefined && args.language.trim() !== "")
33446
+ body.language = args.language;
33447
+ const res = await withAssist(client, args.auto_unlock ?? false, () => client.post(`/series/${args.series_id}/episodes/plan`, body));
33448
+ const data = asRecord(asRecord(res).data);
33449
+ const episodes = Array.isArray(data.episodes) ? data.episodes.map(summarizeEpisode) : [];
33450
+ return ok({
33451
+ series_id: args.series_id,
33452
+ episodes,
33453
+ ai_assist_quota: data.ai_assist_quota ?? null,
33454
+ next: "materialize_episode({ series_id, episode_id }) to turn an idea into a 0-credit draft."
33455
+ });
33456
+ }));
33457
+ registerTool("list_series_episodes", {
33458
+ title: "List a series' episodes",
33459
+ description: "Lists a series' episodes (position ascending), each summarized with its status (idea → drafted → " + "published), hook/brief/caption, suggested format, and — once materialized — the draft slug + deep-link " + "route (editor_ad/short → the video project, carousel → the slider). Optionally filter by status. " + "Read-only, 0 credits. Scope: video:read. 404 for an unknown/foreign series.",
33460
+ inputSchema: {
33461
+ series_id: exports_external.number().int().describe("Series id"),
33462
+ status: exports_external.enum(["idea", "drafted", "published", "archived"]).optional().describe("Filter to one status")
33463
+ },
33464
+ annotations: { title: "List series episodes", ...RO }
33465
+ }, tool(async (args, client) => {
33466
+ const res = await client.get(`/series/${args.series_id}/episodes`, args.status ? { status: args.status } : undefined);
33467
+ const data = asRecord(asRecord(res).data);
33468
+ const episodes = Array.isArray(data.episodes) ? data.episodes.map(summarizeEpisode) : [];
33469
+ return ok({ series_id: args.series_id, episodes });
33470
+ }));
33471
+ registerTool("materialize_episode", {
33472
+ title: "Materialize an episode idea into a draft",
33473
+ description: "Turns an 'idea' episode into an editable 0-credit DRAFT (a video project or image slider) grouped under " + "the series' rolling campaign pack and inheriting the series' brand. Spends 0 credits — generation/render " + "is a SEPARATE, explicit, PAID step. Scope: video:generate. Rate limit: 60/min. Safe to retry: NO duplicate " + "draft is ever created (a concurrent race loser converges on the same draft under a row lock). A SEQUENTIAL " + "re-call of an already-materialized episode returns 422 ('already materialized') — that 422 means the draft " + "already exists; find it via list_series_episodes / get_series_dashboard. 422 also for a non-idea episode or " + "when you hit a draft limit. Returns the drafted episode with its draft slug + kind; generate it explicitly " + "next (start_autopilot/generate for videos, generate_slider for carousels).",
33474
+ inputSchema: {
33475
+ series_id: exports_external.number().int().describe("Series id"),
33476
+ episode_id: exports_external.number().int().describe("Episode id (from plan_series_episodes / list_series_episodes)")
33477
+ },
33478
+ annotations: {
33479
+ title: "Materialize episode",
33480
+ ...WRITE,
33481
+ idempotentHint: true
33482
+ }
33483
+ }, tool(async (args, client) => {
33484
+ const res = await client.post(`/series/${args.series_id}/episodes/${args.episode_id}/materialize`);
33485
+ const episode = summarizeEpisode(asRecord(res).data);
33486
+ return ok({
33487
+ ...episode,
33488
+ next: episode.draft_slug ? "Generate this draft EXPLICITLY (a separate, PAID step): editor_ad/short → start_autopilot({ slug }); carousel → generate_slider({ slug })." : "Draft created — review it with list_series_episodes."
33489
+ });
33490
+ }));
33491
+ registerTool("get_series_dashboard", {
33492
+ title: "Get a series dashboard",
33493
+ description: "Returns a read-only overview of one series: the series plus its episodes grouped into lanes — upcoming " + "(ideas), drafted, and published — with per-item performance notes for episodes linked to a campaign-pack " + "item. Each drafted/published episode carries its draft slug + deep-link route. Read-only, 0 credits. " + "Scope: video:read. 404 for an unknown/foreign series.",
33494
+ inputSchema: {
33495
+ series_id: exports_external.number().int().describe("Series id")
33496
+ },
33497
+ outputSchema: getSeriesDashboardOutput,
33498
+ annotations: { title: "Get series dashboard", ...RO }
33499
+ }, tool(async (args, client) => {
33500
+ const res = await client.get(`/series/${args.series_id}/dashboard`);
33501
+ return ok(summarizeDashboard(asRecord(res).data));
33502
+ }));
33503
+ registerTool("mark_episode_posted", {
33504
+ title: "Mark a drafted episode as posted",
33505
+ description: "Records that you posted a DRAFTED episode MANUALLY (there is no platform posting API): flips the episode " + "'drafted' → 'published' (labelled 'Posted' in the app) and stamps posted_at, making the dashboard's " + "published lane reachable. Spends 0 credits. Scope: video:generate. Atomic — only a drafted episode " + "transitions; an idea or archived episode returns 422 invalid_status (materialize it into a draft first). " + "IDEMPOTENT on replay: re-marking an already-published episode returns 200 with the original posted_at " + "untouched. 404 for an unknown/foreign series or episode.",
33506
+ inputSchema: {
33507
+ series_id: exports_external.number().int().describe("Series id"),
33508
+ episode_id: exports_external.number().int().describe("Episode id (must be a drafted episode)")
33509
+ },
33510
+ annotations: {
33511
+ title: "Mark episode posted",
33512
+ ...WRITE,
33513
+ idempotentHint: true
33514
+ }
33515
+ }, tool(async (args, client) => {
33516
+ const res = await client.post(`/series/${args.series_id}/episodes/${args.episode_id}/mark-posted`);
33517
+ return ok(summarizeEpisode(asRecord(res).data));
33518
+ }));
33519
+ function calendarWrite(fn) {
33520
+ return tool(async (args, client, extra) => {
33521
+ try {
33522
+ return await fn(args, client, extra);
33523
+ } catch (e) {
33524
+ const he = e;
33525
+ if (he && he.status === 403) {
33526
+ return fail(calendarScopeErrorText(he.status, he.code, errMessage(e)));
33527
+ }
33528
+ throw e;
33529
+ }
33530
+ });
33531
+ }
33532
+ registerTool("schedule_post", {
33533
+ title: "Schedule a posting reminder for a finished video",
33534
+ description: "Schedules one of your own COMPLETED video results for a FUTURE UTC instant as a manual-posting REMINDER: " + "a push notification fires to YOU at the scheduled time and you confirm the manual post with " + "mark_scheduled_posted. This tool is reminder-only — it never attaches a social account, so publishing " + "stays in your hands (auto-publish is app-only). Spends 0 credits. Scope: video:generate. Get the " + "video_result_id from a completed project's result (get_status/download_result) or list_scheduled_posts. " + `platform is your posting INTENT (${SCHEDULED_POST_PLATFORMS.join("/")}); "other" for anywhere else. ` + `Caption ≤${5000} chars; ≤${30} hashtags (each ≤${100}). At most ${MAX_PENDING_SCHEDULED_POSTS} pending ` + "reminders per account (422 scheduled_post_limit_reached beyond that — cancel or mark some posted first). " + "422 for a non-completed/foreign video result or a non-future instant.",
33535
+ inputSchema: {
33536
+ video_result_id: exports_external.number().int().describe("Id of one of your own COMPLETED video results (the render to post)"),
33537
+ platform: exports_external.enum(SCHEDULED_POST_PLATFORMS).describe('Where you intend to post: tiktok, instagram, or "other"'),
33538
+ scheduled_at: exports_external.string().describe("The UTC instant to be reminded, ISO-8601 (must be in the future)"),
33539
+ caption: exports_external.string().optional().describe("Optional post caption to stage with the reminder (≤5000 chars)"),
33540
+ hashtags: exports_external.array(exports_external.string()).optional().describe("Optional hashtags to stage (≤30, each ≤100 chars)"),
33541
+ timezone: exports_external.string().optional().describe("Your chosen timezone (display only), e.g. Europe/Paris"),
33542
+ campaign_pack_item_id: exports_external.number().int().optional().describe("Optional id of one of your own campaign pack items to group under")
33543
+ },
33544
+ annotations: {
33545
+ title: "Schedule post reminder",
33546
+ ...WRITE,
33547
+ idempotentHint: false
33548
+ }
33549
+ }, calendarWrite(async (args, client) => {
33550
+ const copyErr = validateScheduledPostCopy(args);
33551
+ if (copyErr)
33552
+ return fail(copyErr);
33553
+ const res = await client.post("/scheduled-posts", buildScheduledPostBody(args));
33554
+ return ok(summarizeScheduledPost(asRecord(res).data));
33555
+ }));
33556
+ registerTool("list_scheduled_posts", {
33557
+ title: "List your scheduled posts (calendar)",
33558
+ description: "Lists your scheduled posts (calendar) ordered by scheduled_at ascending, each summarized with its " + "platform, status, the staged caption/hashtags, the presigned export video_url (post it manually), and the " + "owning video's kind. Optionally narrow the window with from/to (ISO-8601 UTC) and/or filter by status " + `(${SCHEDULED_POST_STATUSES.join("/")}). Read-only, 0 credits. Scope: video:read. Account bindings are ` + "intentionally omitted (this surface is reminder-only).",
33559
+ inputSchema: {
33560
+ from: exports_external.string().optional().describe("Lower bound (inclusive) on scheduled_at, ISO-8601 UTC"),
33561
+ to: exports_external.string().optional().describe("Upper bound (inclusive) on scheduled_at, ISO-8601 UTC"),
33562
+ status: exports_external.enum(SCHEDULED_POST_STATUSES).optional().describe("Filter to one status")
33563
+ },
33564
+ outputSchema: listScheduledPostsOutput,
33565
+ annotations: { title: "List scheduled posts", ...RO }
33566
+ }, tool(async (args, client) => {
33567
+ const query = {};
33568
+ if (args.from !== undefined)
33569
+ query.from = args.from;
33570
+ if (args.to !== undefined)
33571
+ query.to = args.to;
33572
+ if (args.status !== undefined)
33573
+ query.status = args.status;
33574
+ const res = await client.get("/scheduled-posts", Object.keys(query).length > 0 ? query : undefined);
33575
+ const data = asRecord(res).data;
33576
+ const posts = Array.isArray(data) ? data.map(summarizeScheduledPost) : [];
33577
+ return ok({ posts });
33578
+ }));
33579
+ registerTool("reschedule_post", {
33580
+ title: "Reschedule a scheduled post",
33581
+ description: "Moves a still-'scheduled' or 'reminded' post to a new FUTURE instant (a reminded post is re-armed back to " + "'scheduled' — 'remind me later'). Spends 0 credits. Scope: video:generate. 422 not_reschedulable for a " + "post already posted/publishing/published/failed/canceled, or scheduled_in_past for a non-future/ malformed " + "instant. 404 for unknown/foreign ids. A 403 insufficient_scope means the targeted post carries a social " + "account (app-only) — reminder-only posts are reschedulable with video:generate.",
33582
+ inputSchema: {
33583
+ id: exports_external.number().int().describe("Scheduled post id"),
33584
+ scheduled_at: exports_external.string().describe("The new UTC instant, ISO-8601 (must be in the future)")
33585
+ },
33586
+ annotations: { title: "Reschedule post", ...WRITE, idempotentHint: false }
33587
+ }, calendarWrite(async (args, client) => {
33588
+ const res = await client.patch(`/scheduled-posts/${args.id}`, { scheduled_at: args.scheduled_at });
33589
+ return ok(summarizeScheduledPost(asRecord(res).data));
33590
+ }));
33591
+ registerTool("cancel_scheduled_post", {
33592
+ title: "Cancel a scheduled post",
33593
+ description: "Cancels a 'scheduled', 'reminded', or 'failed' post (status → 'canceled'); it will no longer fire. Spends " + "0 credits. Scope: video:generate. 422 not_cancelable for a post already posted/publishing/published/" + "canceled. 404 for unknown/foreign ids. A 403 insufficient_scope means the targeted post carries a social " + "account (app-only) — reminder-only posts are cancelable with video:generate.",
33594
+ inputSchema: {
33595
+ id: exports_external.number().int().describe("Scheduled post id")
33596
+ },
33597
+ annotations: {
33598
+ title: "Cancel scheduled post",
33599
+ readOnlyHint: false,
33600
+ destructiveHint: true,
33601
+ openWorldHint: true,
33602
+ idempotentHint: false
33603
+ }
33604
+ }, calendarWrite(async (args, client) => {
33605
+ const res = await client.del(`/scheduled-posts/${args.id}`);
33606
+ return ok(summarizeScheduledPost(asRecord(res).data));
33607
+ }));
33608
+ registerTool("mark_scheduled_posted", {
33609
+ title: "Mark a scheduled post as posted (manual posting)",
33610
+ description: "Records that you posted this content MANUALLY: transitions a 'scheduled' or 'reminded' post to 'posted' " + "and stamps posted_at. Spends 0 credits. Scope: video:generate (closing the manual-posting loop is allowed " + "for agent tokens). Atomic — a post in any other status (including an already-posted one, so a SECOND call) " + "returns 422 not_markable; this is NOT a replayable no-op. 404 for unknown/foreign ids. Touches no platform API.",
33611
+ inputSchema: {
33612
+ id: exports_external.number().int().describe("Scheduled post id (a scheduled or reminded post)")
33613
+ },
33614
+ annotations: {
33615
+ title: "Mark scheduled post posted",
33616
+ ...WRITE,
33617
+ idempotentHint: false
33618
+ }
33619
+ }, tool(async (args, client) => {
33620
+ const res = await client.post(`/scheduled-posts/${args.id}/mark-posted`);
33621
+ return ok(summarizeScheduledPost(asRecord(res).data));
33622
+ }));
33623
+ registerTool("record_performance", {
33624
+ title: "Record a performance snapshot for a campaign-pack item",
33625
+ description: "Attaches one performance snapshot (platform + engagement/conversion counts + optional revenue/watch " + "time) to one of your own campaign-pack items (item_id from get_campaign_pack). Many snapshots per item " + "are allowed — captured_at (defaults to now if omitted) distinguishes them, so you can log a trend over " + "time. Spends 0 credits. Scope: video:generate. Rate limit: 30/min. 404 for a foreign/unknown item; 422 " + "for invalid metrics. NOT idempotency-keyed — a transport retry records a DUPLICATE snapshot, so don't " + "blindly retry a success.",
33626
+ inputSchema: {
33627
+ item_id: exports_external.number().int().describe("Campaign-pack item id (from get_campaign_pack)"),
33628
+ platform: exports_external.enum(PERFORMANCE_PLATFORMS).describe('Where it ran: tiktok, instagram, or "other"'),
33629
+ views: exports_external.number().int().optional().describe("View count (≥0)"),
33630
+ likes: exports_external.number().int().optional().describe("Like count (≥0)"),
33631
+ comments: exports_external.number().int().optional().describe("Comment count (≥0)"),
33632
+ shares: exports_external.number().int().optional().describe("Share count (≥0)"),
33633
+ clicks: exports_external.number().int().optional().describe("Click count (≥0)"),
33634
+ conversions: exports_external.number().int().optional().describe("Conversion count (≥0)"),
33635
+ revenue: exports_external.number().optional().describe("Attributed revenue (≥0, ≤~1 trillion)"),
33636
+ watch_time_seconds: exports_external.number().int().optional().describe("Total watch time in seconds (≥0)"),
33637
+ source: exports_external.enum(PERFORMANCE_SOURCES).optional().describe("How the metrics were gathered (default manual)"),
33638
+ captured_at: exports_external.string().optional().describe("When measured, ISO-8601 UTC (defaults to now; not future)"),
33639
+ metadata: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Optional provenance blob (≤16 KiB JSON)")
33640
+ },
33641
+ annotations: {
33642
+ title: "Record performance",
33643
+ ...WRITE,
33644
+ idempotentHint: false
33645
+ }
33646
+ }, tool(async (args, client) => {
33647
+ const err = validatePerformanceMetrics(args);
33648
+ if (err)
33649
+ return fail(err);
33650
+ const res = await client.post(`/campaign-pack-items/${args.item_id}/performance`, buildPerformanceBody(args));
33651
+ return ok({
33652
+ ...summarizePerformance(asRecord(res).data),
33653
+ next: "set_item_attributes to tag this item's creative, then get_recommendations once a few items have both."
33654
+ });
33655
+ }));
33656
+ registerTool("list_performance", {
33657
+ title: "List a campaign-pack item's performance snapshots",
33658
+ description: "Returns one of your own campaign-pack items' performance snapshots (newest first) plus a small summary " + "(latest snapshot + count). Read-only, 0 credits. Scope: video:read. 404 for a foreign/unknown item.",
33659
+ inputSchema: {
33660
+ item_id: exports_external.number().int().describe("Campaign-pack item id")
33661
+ },
33662
+ annotations: { title: "List performance", ...RO }
33663
+ }, tool(async (args, client) => {
33664
+ const res = await client.get(`/campaign-pack-items/${args.item_id}/performance`);
33665
+ const data = asRecord(asRecord(res).data);
33666
+ const performance = Array.isArray(data.performance) ? data.performance.map(summarizePerformance) : [];
33667
+ return ok({
33668
+ item_id: args.item_id,
33669
+ performance,
33670
+ summary: data.summary ?? null
33671
+ });
33672
+ }));
33673
+ registerTool("set_item_attributes", {
33674
+ title: "Set the creative attributes for a campaign-pack item",
33675
+ description: "Inserts-or-updates the SINGLE creative-attributes row (hook_type, creative_format, visual_language, " + "product_category, cta, length, caption_style) for one of your own campaign-pack items — the tags the " + "recommendation loop correlates with performance. Calling it again UPDATES the same row (atomic upsert). " + "Spends 0 credits. Scope: video:generate. 404 for a foreign/unknown item; 422 for invalid attributes.",
33676
+ inputSchema: {
33677
+ item_id: exports_external.number().int().describe("Campaign-pack item id"),
33678
+ hook_type: exports_external.string().optional().describe('The hook style, e.g. "question", "bold_claim"'),
33679
+ creative_format: exports_external.string().optional().describe('Narrative format, e.g. "problem_solution"'),
33680
+ visual_language: exports_external.string().optional().describe('Production treatment, e.g. "ugc_realism"'),
33681
+ product_category: exports_external.string().optional().describe("Product category tag"),
33682
+ cta: exports_external.string().optional().describe("Call-to-action used"),
33683
+ length: exports_external.string().optional().describe('Length bucket, e.g. "short"'),
33684
+ caption_style: exports_external.string().optional().describe("Caption style tag"),
33685
+ metadata: exports_external.record(exports_external.string(), exports_external.unknown()).optional().describe("Optional extra classifiers")
33686
+ },
33687
+ annotations: {
33688
+ title: "Set item attributes",
33689
+ ...WRITE,
33690
+ idempotentHint: true
33691
+ }
33692
+ }, tool(async (args, client) => {
33693
+ const res = await client.put(`/campaign-pack-items/${args.item_id}/attributes`, pickCreativeAttributeFields(args));
33694
+ return ok(asRecord(res).data);
33695
+ }));
33696
+ registerTool("get_item_attributes", {
33697
+ title: "Fetch the creative attributes for a campaign-pack item",
33698
+ description: "Returns the single creative-attributes row for one of your own campaign-pack items, or null if none has " + "been set. Read-only, 0 credits. Scope: video:read. 404 for a foreign/unknown item.",
33699
+ inputSchema: {
33700
+ item_id: exports_external.number().int().describe("Campaign-pack item id")
33701
+ },
33702
+ annotations: { title: "Get item attributes", ...RO }
33703
+ }, tool(async (args, client) => {
33704
+ const res = await client.get(`/campaign-pack-items/${args.item_id}/attributes`);
33705
+ return ok(asRecord(res).data ?? null);
33706
+ }));
33707
+ registerTool("get_recommendations", {
33708
+ title: "Cautious creative recommendation from your own performance",
33709
+ description: "Aggregates your own campaign-pack items that have BOTH creative attributes AND at least one performance " + "snapshot into a CAUTIOUS recommendation: the top-performing hook_type / creative_format / visual_language " + "by average views, your best items, and an HONEST confidence level. Confidence is a function of sample size " + 'and is returned as "none" (no data yet), "low", or "moderate" — it is NEVER overstated, and the result ' + "NEVER claims causation, ROAS, or attribution: these are correlations in YOUR OWN data, a hint to test, not " + "proof. Present the server's `note` and `confidence` as-is; do not upgrade them. Read-only; NO LLM; NO " + "video credits; NO quota. Scope: video:read.",
33710
+ inputSchema: {},
33711
+ outputSchema: getRecommendationsOutput,
33712
+ annotations: { title: "Get recommendations", ...RO }
33713
+ }, tool(async (_args, client) => {
33714
+ const res = await client.get("/performance/recommendations");
33715
+ return ok(asRecord(res).data);
33716
+ }));
33717
+ registerTool("make_more_like_winner", {
33718
+ title: "Make more hook variations like a winning item",
33719
+ description: "Generates 3 follow-up hook variations from one of your own campaign-pack items, threading that item's " + "winning angle/format/hook into the generator while keeping its brand + product profile — the way to " + "double down on a proven creative. Generates no media, persists nothing, and spends NO video credits: it " + "consumes the FREE daily AI-assist quota instead (429 when exhausted; set auto_unlock:true to spend 1 credit " + "for +10 assists, retried once, or call unlock_ai_assists). Scope: video:generate. Rate limit: 30/min. 404 " + "for a foreign/unknown item; 422 when the item has no usable product context. These are new IDEAS — feed a " + "chosen one into add_pack_variations / create_campaign_pack to turn it into a draft.",
33720
+ inputSchema: {
33721
+ item_id: exports_external.number().int().describe("Campaign-pack item id of the winning creative"),
33722
+ auto_unlock: exports_external.boolean().optional().describe("On 429, spend 1 credit to unlock +10 assists and retry once (default false)")
33723
+ },
33724
+ annotations: {
33725
+ title: "Make more like winner",
33726
+ ...WRITE,
33727
+ idempotentHint: false
33728
+ }
33729
+ }, tool(async (args, client) => {
33730
+ const res = await withAssist(client, args.auto_unlock ?? false, () => client.post(`/campaign-pack-items/${args.item_id}/make-more`));
33731
+ const data = asRecord(asRecord(res).data);
33732
+ return ok({
33733
+ item_id: args.item_id,
33734
+ variations: Array.isArray(data.variations) ? data.variations : [],
33735
+ ai_assist_quota: data.ai_assist_quota ?? null,
33736
+ next: "Feed a chosen variation into add_pack_variations({ pack_id, variations, materialize: true }) to draft it."
33737
+ });
33738
+ }));
33739
+ registerTool("create_review_link", {
33740
+ title: "Create a no-login review link for one completed output",
33741
+ description: "Mints an expiring, hashed-token review link for EXACTLY ONE of your own COMPLETED outputs — a video " + "result (video_result_id) OR a carousel/slider (slider_id), never both — optionally grouped under one of " + "your campaign packs. The reviewer opens the returned URL with NO account. Spends 0 credits. Scope: " + "video:generate. IMPORTANT: the plaintext token + review URL are returned ONCE and CANNOT be retrieved " + "again (only the hash is stored) — surface them to the user immediately. expires_in_hours defaults to 168 " + `(7 days) and is clamped to ${REVIEW_LINK_MIN_EXPIRES_IN_HOURS}..${REVIEW_LINK_MAX_EXPIRES_IN_HOURS}. ` + "422 for a not-completed/foreign output or a foreign campaign pack.",
33742
+ inputSchema: {
33743
+ video_result_id: exports_external.number().int().optional().describe("Id of one of your own COMPLETED video results (mutually exclusive with slider_id)"),
33744
+ slider_id: exports_external.number().int().optional().describe("Id of one of your own COMPLETED sliders/carousels (mutually exclusive with video_result_id)"),
33745
+ campaign_pack_id: exports_external.number().int().optional().describe("Optional id of one of your own campaign packs to group under"),
33746
+ expires_in_hours: exports_external.number().int().optional().describe(`How long the link stays valid; clamped ${REVIEW_LINK_MIN_EXPIRES_IN_HOURS}..${REVIEW_LINK_MAX_EXPIRES_IN_HOURS} (default 168 = 7 days)`),
33747
+ download_enabled: exports_external.boolean().optional().describe("Whether the reviewer may download the output (default false)"),
33748
+ version_label: exports_external.string().optional().describe("Optional human label for this review version (≤120 chars)")
33749
+ },
33750
+ annotations: {
33751
+ title: "Create review link",
33752
+ ...WRITE,
33753
+ idempotentHint: false
33754
+ }
33755
+ }, tool(async (args, client) => {
33756
+ const err = validateReviewOutput(args.video_result_id, args.slider_id);
33757
+ if (err)
33758
+ return fail(err);
33759
+ const res = await client.post("/review-links", buildReviewLinkBody(args));
33760
+ const result = shapeCreateReviewLink(asRecord(res).data, reviewWebOrigin());
33761
+ return ok(result);
33762
+ }));
33763
+ registerTool("list_review_links", {
33764
+ title: "List your review links",
33765
+ description: "Lists your review links, newest first, each summarized with its status (pending → viewed → approved / " + "changes_requested / revoked), the output it points at, expiry, and its visible comment_count — so you can " + "see review + approval activity at a glance. NEVER returns the token (only shown once at creation). " + "Read-only, 0 credits. Scope: video:read.",
33766
+ inputSchema: {},
33767
+ annotations: { title: "List review links", ...RO }
33768
+ }, tool(async (_args, client) => {
33769
+ const res = await client.get("/review-links");
33770
+ const data = asRecord(res).data;
33771
+ const links = Array.isArray(data) ? data.map(summarizeReviewLink) : [];
33772
+ return ok({ review_links: links });
33773
+ }));
33774
+ registerTool("revoke_review_link", {
33775
+ title: "Revoke a review link",
33776
+ description: "Revokes one of your own review links (status → 'revoked'); the no-login URL immediately stops working. " + "Returns the revoked link. Spends 0 credits. Scope: video:generate. 404 for an unknown/foreign id.",
33777
+ inputSchema: {
33778
+ id: exports_external.number().int().describe("Review link id (from list_review_links)")
33779
+ },
33780
+ annotations: {
33781
+ title: "Revoke review link",
33782
+ readOnlyHint: false,
33783
+ destructiveHint: true,
33784
+ openWorldHint: true,
33785
+ idempotentHint: false
33786
+ }
33787
+ }, tool(async (args, client) => {
33788
+ const res = await client.del(`/review-links/${args.id}`);
33789
+ return ok(summarizeReviewLink(asRecord(res).data));
33790
+ }));
33791
+ function assetCatalog(fn) {
33792
+ return tool(async (args, client, extra) => {
33793
+ try {
33794
+ return await fn(args, client, extra);
33795
+ } catch (e) {
33796
+ const he = e;
33797
+ if (he && he.status === 402) {
33798
+ return fail(assetSubscriptionErrorText(he.status, he.code, errMessage(e)));
33799
+ }
33800
+ throw e;
33801
+ }
33802
+ });
33803
+ }
33804
+ registerTool("list_assets", {
33805
+ title: "List your reusable asset catalog",
33806
+ description: "Lists your reusable asset catalog (images + videos), newest first, each with its media type, mime, size, " + "description, status, and a presigned asset_url — plus your storage quota. Optionally page with limit/offset " + "(offset = page * limit). Read-only, 0 credits. Scope: video:read. Requires an ACTIVE SUBSCRIPTION (402 " + "subscription_required otherwise).",
33807
+ inputSchema: {
33808
+ limit: exports_external.number().int().optional().describe("Max assets to return (a positive integer)"),
33809
+ offset: exports_external.number().int().optional().describe("How many to skip (offset = page * limit)")
33810
+ },
33811
+ annotations: { title: "List assets", ...RO }
33812
+ }, assetCatalog(async (args, client) => {
33813
+ const query = {};
33814
+ if (args.limit !== undefined)
33815
+ query.limit = args.limit;
33816
+ if (args.offset !== undefined)
33817
+ query.offset = args.offset;
33818
+ const res = await client.get("/assets", Object.keys(query).length > 0 ? query : undefined);
33819
+ const data = asRecord(res).data;
33820
+ const assets = Array.isArray(data) ? data.map(summarizeCatalogAsset) : [];
33821
+ const quota = asRecord(asRecord(res).meta).quota;
33822
+ return ok({
33823
+ assets,
33824
+ quota: quota ? summarizeAssetQuota(quota) : null
33825
+ });
33826
+ }));
33827
+ registerTool("get_asset_quota", {
33828
+ title: "Get your asset-catalog storage quota",
33829
+ description: "Returns your asset-catalog storage quota (limit / used / pending / reserved / remaining bytes). Read-only, " + "0 credits. Scope: video:read. Requires an ACTIVE SUBSCRIPTION (402 subscription_required otherwise).",
33830
+ inputSchema: {},
33831
+ annotations: { title: "Get asset quota", ...RO }
33832
+ }, assetCatalog(async (_args, client) => {
33833
+ const res = await client.get("/assets/quota");
33834
+ return ok(summarizeAssetQuota(asRecord(res).data));
33835
+ }));
33836
+ registerTool("upload_asset", {
33837
+ title: "Upload a local file to your reusable asset catalog",
33838
+ description: "Uploads a local IMAGE or VIDEO into your reusable asset catalog so it can be reused across projects. " + `Allowed types: ${CATALOG_ASSET_EXTS.map((e) => `.${e}`).join(", ")} — images ≤20 MiB, videos ≤500 MB. ` + "Runs presign → PUT → confirm; the confirm HEADs the object and validates size/type, so a mismatch 422s. " + "For a VIDEO you MUST pass duration_seconds (≤60; the server rejects a video with no/over-long duration). " + "Spends 0 credits. Scope: video:generate. Rate limit: 30/min. Requires an ACTIVE SUBSCRIPTION (402 " + "subscription_required otherwise). The local path is confined to HUBFLUENCER_INPUT_DIR (default: cwd). " + "Returns the confirmed asset id + media type + status.",
33839
+ inputSchema: {
33840
+ file_path: exports_external.string().describe("Path to a local image/video, inside HUBFLUENCER_INPUT_DIR (default: cwd)"),
33841
+ description: exports_external.string().optional().describe("Optional description stored with the asset"),
33842
+ duration_seconds: exports_external.number().optional().describe("Video duration in seconds (REQUIRED for a video, ≤60)"),
33843
+ width: exports_external.number().int().optional().describe("Optional pixel width"),
33844
+ height: exports_external.number().int().optional().describe("Optional pixel height")
33845
+ },
33846
+ annotations: {
33847
+ title: "Upload asset",
33848
+ ...WRITE,
33849
+ idempotentHint: false
33850
+ }
33851
+ }, assetCatalog(async (args, client) => {
33852
+ const asset = await uploadCatalogAsset(client, args.file_path, {
33853
+ description: args.description,
33854
+ durationSeconds: args.duration_seconds,
33855
+ width: args.width,
33856
+ height: args.height
33857
+ });
33858
+ return ok({
33859
+ ...asset,
33860
+ next: "list_assets to see it; reuse it across projects from the app's asset picker."
33861
+ });
33862
+ }));
31290
33863
  async function main() {
31291
33864
  if (process.argv[2] === "login") {
31292
33865
  await runLogin(process.argv[3]);