@hubfluencer/mcp 0.8.1 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hubfluencer/mcp",
3
- "version": "0.8.1",
3
+ "version": "0.9.0",
4
4
  "description": "Model Context Protocol server for Hubfluencer — let AI agents generate post-ready shorts and editor ads.",
5
5
  "license": "MIT",
6
6
  "author": "Monocursive <contact@monocursive.com>",
package/src/campaign.ts CHANGED
@@ -686,7 +686,7 @@ export async function runCreateCampaign(
686
686
  items,
687
687
  summary,
688
688
  steps,
689
- 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 }).`,
689
+ next: `get_campaign_pack({ id: ${campaignPackId} }) to review the drafts; then quote each editor draft with start_autopilot({ slug }) and, after approval, launch with start_autopilot({ slug, max_credits: approved_cap }); use generate_short/generate_slider for approved short/carousel drafts.`,
690
690
  note:
691
691
  `Campaign pack ${campaignPackId} created with ${summary.created} item(s), ${draftable} materialized into editable DRAFTS. ` +
692
692
  (positioning ? `Positioning: ${positioning}. ` : "") +
package/src/client.ts CHANGED
@@ -251,7 +251,10 @@ export class HubfluencerClient {
251
251
  );
252
252
  }
253
253
  throw e instanceof Error
254
- ? new Error(`Network error on ${method} ${path}: ${e.message}`)
254
+ ? Object.assign(
255
+ new Error(`Network error on ${method} ${path}: ${e.message}`),
256
+ { retryable: isRetryableNetworkError(e) },
257
+ )
255
258
  : e;
256
259
  }
257
260
 
package/src/core.ts CHANGED
@@ -265,6 +265,20 @@ export interface NormalizedStatus {
265
265
  ready: boolean;
266
266
  video_url: string | null;
267
267
  error: string | null;
268
+ /** Editor only: the newest delivered render no longer matches the live project. */
269
+ stale?: boolean;
270
+ /** Editor only: current scene ids that are still generating. */
271
+ active_segment_ids?: Array<number | string>;
272
+ /** Editor only: completed current scene ids invalidated by continuity edits. */
273
+ stale_segment_ids?: Array<number | string>;
274
+ /** Short only (additive, absent on older APIs): the newest render row is a free re-render. */
275
+ latest_render_free?: boolean;
276
+ /**
277
+ * Short only (additive): the newest render row is a FAILED free re-render.
278
+ * The delivered video stays intact (stage/latest_render fall back to the
279
+ * latest completed render), so the agent can just retry rerender_short — free.
280
+ */
281
+ last_free_rerender_failed?: boolean;
268
282
  }
269
283
 
270
284
  /** The structured fields of an API error envelope errMessage formats from. */
@@ -318,7 +332,7 @@ export function normalizeStatus(
318
332
 
319
333
  if (kind === "short") {
320
334
  const stage = (d.stage as string) ?? "unknown";
321
- return {
335
+ const status: NormalizedStatus = {
322
336
  kind,
323
337
  slug,
324
338
  stage,
@@ -327,6 +341,19 @@ export function normalizeStatus(
327
341
  video_url: videoUrl,
328
342
  error: (d.error_message as string) ?? null,
329
343
  };
344
+ // Additive free-re-render bookkeeping (2026-07 API): surfaced only when
345
+ // the server actually sends the booleans, so older payloads normalize
346
+ // byte-identically. A failed free re-render does NOT flip stage to
347
+ // "failed" server-side — the delivered video stays visible — so these
348
+ // flags are the only signal that the newest render row was a free
349
+ // re-render (or a failed one worth retrying, still free).
350
+ if (typeof d.latest_render_free === "boolean") {
351
+ status.latest_render_free = d.latest_render_free;
352
+ }
353
+ if (typeof d.last_free_rerender_failed === "boolean") {
354
+ status.last_free_rerender_failed = d.last_free_rerender_failed;
355
+ }
356
+ return status;
330
357
  }
331
358
 
332
359
  if (kind === "tracking") {
@@ -374,23 +401,249 @@ export function normalizeStatus(
374
401
  };
375
402
  }
376
403
 
377
- // editor
404
+ // editor. A completed render is only "ready" when it still represents the
405
+ // CURRENT timeline and no newer scene/audio work is active or stale. The API
406
+ // intentionally keeps the last successful MP4 available after edits; treating
407
+ // that historical delivery as current made get_status/wait_for_completion
408
+ // return immediately while paid regenerations were still running.
378
409
  const autopilot = (d.autopilot_status as string) ?? "unknown";
379
410
  const renderStatus = (latest.status as string) ?? null;
380
- const ready = renderStatus === "completed" && Boolean(videoUrl);
381
- const terminal =
382
- ready ||
383
- autopilot === "failed" ||
384
- autopilot === "cancelled" ||
385
- renderStatus === "failed";
411
+ const segments = Array.isArray(d.segments) ? d.segments.map(asRecord) : [];
412
+ // "Active" means a scene is genuinely mid-generation. Crucially it must NOT
413
+ // include the *idle* defaults: segment.status "pending" and preview_status
414
+ // "pending" are the never-generated / cleared-preview defaults the API stamps
415
+ // on every fresh scene (video_segment.ex: status default "pending",
416
+ // preview_status default "pending"). Counting either as active made a fully
417
+ // idle draft report stage "segment_processing" with every scene in
418
+ // active_segment_ids, so wait_for_completion burned its whole budget on a
419
+ // project that isn't generating anything. The real in-flight states are
420
+ // segment.status "processing" and preview_status "generating" (its wire enum
421
+ // is pending|generating|completed|failed|stale — "processing" is kept only as
422
+ // a defensive alias, it never appears).
423
+ const activeSegments = segments.filter((segment) => {
424
+ const status = segment.status as string | undefined;
425
+ const preview = segment.preview_status as string | undefined;
426
+ return (
427
+ status === "processing" ||
428
+ preview === "processing" ||
429
+ preview === "generating"
430
+ );
431
+ });
432
+ const staleSegments = segments.filter(
433
+ (segment) => segment.video_stale === true && segment.status === "completed",
434
+ );
435
+ const failedSegments = segments.filter(
436
+ (segment) => segment.status === "failed",
437
+ );
438
+ const batchStatus = (d.batch_generation_status as string) ?? null;
439
+ // The batch enum's ACTIVE states (video_factory.ex): preparing (spinning up),
440
+ // awaiting_previews (previews generating), running, and paused_for_retry
441
+ // (auto-resuming). "queued" is not a real value and was dropped; "preparing"
442
+ // and "awaiting_previews" were missing, so an in-flight batch briefly looked
443
+ // idle and could be reported terminal mid-run.
444
+ const batchActive =
445
+ batchStatus === "running" ||
446
+ batchStatus === "preparing" ||
447
+ batchStatus === "awaiting_previews" ||
448
+ batchStatus === "paused_for_retry";
449
+ // A parked batch that ran out of credits: it cannot progress without the user
450
+ // topping up or reducing scope, so it is a needs-user-action TERMINAL state
451
+ // (not active work to wait on).
452
+ const batchInsufficientCredits =
453
+ batchStatus === "paused_insufficient_credits";
454
+ const batchFailed = batchStatus === "failed";
455
+ const autopilotActive = [
456
+ "pending",
457
+ "running",
458
+ "generating",
459
+ "rendering",
460
+ ].includes(autopilot);
461
+ const audioStale =
462
+ d.narration_stale === true ||
463
+ d.voice_stale === true ||
464
+ d.music_stale === true;
465
+ const renderStale = latest.is_stale === true;
466
+ // Only an explicit false proves the completed render matches the live editor.
467
+ // Legacy/null/omitted freshness is unknown and must never be promoted to ready.
468
+ const renderFresh = latest.is_stale === false;
469
+ const stale = renderStale || staleSegments.length > 0 || audioStale;
470
+ // A render in flight is active work too — fold it into workActive so the
471
+ // terminal computation below can never fire while a render is queued/running.
472
+ // "pending" here is NOT an idle default: a video_results row only exists once
473
+ // a render was actually requested, so a pending latest render is queued work.
474
+ const renderActive =
475
+ renderStatus === "pending" ||
476
+ renderStatus === "rendering" ||
477
+ renderStatus === "processing";
478
+ // Scenario text generation / scenario apply run as async workers too
479
+ // (scenario_status: none|generating|ready|failed; scenario_apply_status:
480
+ // idle|applying|failed) — count them as active so a wait right after
481
+ // generate_scenario/apply_scenario doesn't bail out as an "idle" draft.
482
+ const scenarioActive =
483
+ d.scenario_status === "generating" ||
484
+ d.scenario_apply_status === "applying";
485
+ // Narration generation is a distinct async worker that runs before an
486
+ // AudioVersion exists. Without this gate, a project with an older completed
487
+ // render looks ready/terminal during narration regeneration and
488
+ // wait_for_completion can return that old MP4 while the new script is still
489
+ // being produced.
490
+ const narrationActive = d.narration_status === "generating";
491
+ const scenarioFailed = d.scenario_status === "failed";
492
+ const scenarioApplyFailed = d.scenario_apply_status === "failed";
493
+ const narrationFailed = d.narration_status === "failed";
494
+ // First-time generation is visible through the current selection. A
495
+ // regeneration intentionally keeps the prior completed version current until
496
+ // the replacement lands, so inspect the version histories as well; otherwise
497
+ // an old matching render can look ready while a paid replacement is running.
498
+ const currentAudio = asRecord(d.current_audio);
499
+ const currentMusic = asRecord(d.current_music);
500
+ const audioVersions = Array.isArray(d.audio_versions)
501
+ ? d.audio_versions.map(asRecord)
502
+ : [];
503
+ const musicVersions = Array.isArray(d.music_versions)
504
+ ? d.music_versions.map(asRecord)
505
+ : [];
506
+ const numericVersion = (version: unknown): number =>
507
+ typeof version === "number" && Number.isFinite(version) ? version : 0;
508
+ const selectedAudioVersion = numericVersion(currentAudio.version);
509
+ const selectedMusicVersion = numericVersion(currentMusic.version);
510
+ const newerAudioVersions = audioVersions.filter(
511
+ (version) => numericVersion(version.version) > selectedAudioVersion,
512
+ );
513
+ const newerMusicVersions = musicVersions.filter(
514
+ (version) => numericVersion(version.version) > selectedMusicVersion,
515
+ );
516
+ const versionActive = (version: Record<string, unknown>) =>
517
+ version.status === "pending" || version.status === "processing";
518
+ const newestVersion = (versions: Array<Record<string, unknown>>) =>
519
+ versions.reduce<Record<string, unknown> | null>((latest, version) => {
520
+ if (!latest) return version;
521
+ return numericVersion(version.version) > numericVersion(latest.version)
522
+ ? version
523
+ : latest;
524
+ }, null);
525
+ const newestReplacementAudio = newestVersion(newerAudioVersions);
526
+ const newestReplacementMusic = newestVersion(newerMusicVersions);
527
+ const audioFailed =
528
+ currentAudio.status === "failed" ||
529
+ currentMusic.status === "failed" ||
530
+ newestReplacementAudio?.status === "failed" ||
531
+ newestReplacementMusic?.status === "failed";
532
+ const audioError = [
533
+ newestReplacementAudio?.error_message,
534
+ newestReplacementMusic?.error_message,
535
+ currentAudio.error_message,
536
+ currentMusic.error_message,
537
+ ].find(
538
+ (value): value is string =>
539
+ typeof value === "string" && value.trim().length > 0,
540
+ );
541
+ const audioActive =
542
+ currentAudio.status === "pending" ||
543
+ currentAudio.status === "processing" ||
544
+ currentMusic.status === "pending" ||
545
+ currentMusic.status === "processing" ||
546
+ newerAudioVersions.some(versionActive) ||
547
+ newerMusicVersions.some(versionActive);
548
+ const workActive =
549
+ autopilotActive ||
550
+ batchActive ||
551
+ renderActive ||
552
+ scenarioActive ||
553
+ narrationActive ||
554
+ audioActive ||
555
+ activeSegments.length > 0;
556
+ const ready =
557
+ renderStatus === "completed" &&
558
+ Boolean(videoUrl) &&
559
+ renderFresh &&
560
+ !stale &&
561
+ !workActive &&
562
+ failedSegments.length === 0 &&
563
+ !audioFailed &&
564
+ !scenarioFailed &&
565
+ !scenarioApplyFailed &&
566
+ !narrationFailed &&
567
+ !batchFailed;
568
+ const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
569
+ const renderFailed = renderStatus === "failed";
570
+ const failed =
571
+ autopilotFailed ||
572
+ renderFailed ||
573
+ scenarioApplyFailed ||
574
+ scenarioFailed ||
575
+ narrationFailed ||
576
+ batchFailed ||
577
+ failedSegments.length > 0 ||
578
+ audioFailed;
579
+ // Terminal = control should return to the agent because nothing will progress
580
+ // without another tool call. With every async job type folded into workActive
581
+ // above (autopilot, batch, segments, scenario, narration, audio, render), NO
582
+ // work active means exactly that — whether the project is ready, failed,
583
+ // stale (editing_required), parked on credits, or a pristine idle draft, only
584
+ // a new tool call can move it, so wait_for_completion must return instead of
585
+ // polling its full budget. (ready is subsumed by !workActive but kept for
586
+ // clarity; explicit failure states are terminal even while cleanup work spins.)
587
+ const terminal = ready || failed || batchInsufficientCredits || !workActive;
588
+ let stage = autopilot;
589
+ // Failure always wins over an unrelated active flag. Workers can leave an
590
+ // outer autopilot/batch state active after a child stage has failed; reporting
591
+ // the active stage would make agents poll forever instead of taking action.
592
+ if (autopilot === "failed") stage = "failed";
593
+ else if (autopilot === "cancelled") stage = "cancelled";
594
+ else if (renderFailed) stage = "render_failed";
595
+ else if (scenarioApplyFailed) stage = "scenario_apply_failed";
596
+ else if (scenarioFailed) stage = "scenario_failed";
597
+ else if (narrationFailed) stage = "narration_failed";
598
+ else if (batchFailed) stage = "batch_failed";
599
+ else if (failedSegments.length > 0) stage = "segment_failed";
600
+ else if (audioFailed) stage = "audio_failed";
601
+ else if (batchInsufficientCredits) stage = "insufficient_credits";
602
+ else if (activeSegments.length > 0) stage = "segment_processing";
603
+ else if (batchActive) stage = "segment_generation";
604
+ else if (renderActive) stage = "rendering";
605
+ else if (scenarioActive) stage = "scenario_processing";
606
+ else if (narrationActive) stage = "narration_processing";
607
+ else if (audioActive) stage = "audio_processing";
608
+ else if (!workActive && stale) stage = "editing_required";
609
+ else if (ready) stage = "completed";
610
+ const segmentError = failedSegments
611
+ .map((segment) => segment.error_message)
612
+ .find(
613
+ (value): value is string => typeof value === "string" && value.length > 0,
614
+ );
386
615
  return {
387
616
  kind,
388
617
  slug,
389
- stage: autopilot,
618
+ stage,
390
619
  terminal,
391
620
  ready,
392
621
  video_url: videoUrl,
393
- error: (d.autopilot_error_message as string) ?? null,
622
+ error:
623
+ (d.autopilot_error_message as string) ??
624
+ (latest.error_message as string) ??
625
+ (d.scenario_apply_error_message as string) ??
626
+ (d.scenario_error_message as string) ??
627
+ (d.narration_error_message as string) ??
628
+ (d.batch_generation_error_message as string) ??
629
+ segmentError ??
630
+ audioError ??
631
+ (batchInsufficientCredits
632
+ ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume."
633
+ : null),
634
+ stale,
635
+ active_segment_ids: activeSegments
636
+ .map((segment) => segment.id)
637
+ .filter(
638
+ (id): id is number | string =>
639
+ typeof id === "number" || typeof id === "string",
640
+ ),
641
+ stale_segment_ids: staleSegments
642
+ .map((segment) => segment.id)
643
+ .filter(
644
+ (id): id is number | string =>
645
+ typeof id === "number" || typeof id === "string",
646
+ ),
394
647
  };
395
648
  }
396
649
 
@@ -520,6 +773,26 @@ export interface EditorAdKeyArgs {
520
773
  theme?: string;
521
774
  voice_id?: string;
522
775
  export_aspect_ratio?: string;
776
+ product_image_path?: string;
777
+ product_description?: string;
778
+ closing_image_path?: string;
779
+ logo_path?: string;
780
+ logo_treatment?: string;
781
+ logo_position?: string;
782
+ logo_duration_seconds?: number;
783
+ cast_mode?: string;
784
+ /** Identities of the retained, pre-create asset byte snapshots. */
785
+ product_image_identity?: string;
786
+ closing_image_identity?: string;
787
+ logo_identity?: string;
788
+ /**
789
+ * The approved spend cap forwarded to the launch POST. Folded into the
790
+ * autopilot-start key ONLY (not the draft-create key) so that re-calling
791
+ * create_editor_ad with a RAISED cap after a failed/over-cap launch mints a
792
+ * fresh key and re-enqueues, instead of replaying the server's 24h-cached dead
793
+ * 202 from the earlier lower-cap attempt.
794
+ */
795
+ max_credits?: number;
523
796
  }
524
797
 
525
798
  /** create_editor_ad's editor draft-create key (`POST /editor`). */
@@ -535,14 +808,30 @@ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
535
808
  a.theme ?? "",
536
809
  a.voice_id ?? "",
537
810
  a.export_aspect_ratio ?? "",
811
+ a.product_image_path ?? "",
812
+ a.product_image_identity ?? "",
813
+ a.product_description ?? "",
814
+ a.closing_image_path ?? "",
815
+ a.closing_image_identity ?? "",
816
+ a.logo_path ?? "",
817
+ a.logo_identity ?? "",
818
+ a.logo_treatment ?? "",
819
+ a.logo_position ?? "",
820
+ a.logo_duration_seconds === undefined
821
+ ? ""
822
+ : String(a.logo_duration_seconds),
823
+ a.cast_mode ?? "",
538
824
  );
539
825
  }
540
826
 
541
827
  /**
542
828
  * create_editor_ad's autopilot-start key (`POST /editor/:slug/autopilot`). Folds
543
- * the create params (not a startState this tool starts right after create, so
544
- * there is no prior terminal state to guard against) so it tracks the inputs, not
545
- * just the slug. Self-consistent per-tool only.
829
+ * the create params AND the approved max_credits cap. There is no startState
830
+ * discriminator (this tool starts right after create, so there is no prior
831
+ * terminal state to guard against), so max_credits is what lets a re-call with a
832
+ * RAISED cap after a failed/over-cap launch mint a fresh key and re-enqueue
833
+ * rather than replay the server's 24h-cached dead 202. Self-consistent per-tool
834
+ * only.
546
835
  */
547
836
  export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
548
837
  return idemKey(
@@ -557,6 +846,17 @@ export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
557
846
  a.theme ?? "",
558
847
  a.voice_id ?? "",
559
848
  a.export_aspect_ratio ?? "",
849
+ a.product_image_path ?? "",
850
+ a.product_description ?? "",
851
+ a.closing_image_path ?? "",
852
+ a.logo_path ?? "",
853
+ a.logo_treatment ?? "",
854
+ a.logo_position ?? "",
855
+ a.logo_duration_seconds === undefined
856
+ ? ""
857
+ : String(a.logo_duration_seconds),
858
+ a.cast_mode ?? "",
859
+ a.max_credits === undefined ? "" : String(a.max_credits),
560
860
  );
561
861
  }
562
862
 
@@ -616,9 +916,22 @@ export function addSegmentKey(
616
916
  */
617
917
  export function resolveSavePath(savePath: string): string {
618
918
  const base = resolve(process.env.HUBFLUENCER_OUTPUT_DIR || process.cwd());
619
- const target = isAbsolute(savePath)
620
- ? resolve(savePath)
621
- : resolve(base, savePath);
919
+ let target: string;
920
+ if (isAbsolute(savePath)) {
921
+ target = resolve(savePath);
922
+ } else {
923
+ // Accept both output-root-relative paths ("campaign/ad.mp4") and
924
+ // workspace-relative paths that already include the configured output
925
+ // directory ("videos/campaign/ad.mp4"). Always choose a candidate only
926
+ // when it remains confined to `base`; this fixes /videos/videos/... without
927
+ // weakening the traversal guard.
928
+ const workspaceCandidate = resolve(process.cwd(), savePath);
929
+ const workspaceCandidateInsideBase =
930
+ workspaceCandidate === base || workspaceCandidate.startsWith(base + sep);
931
+ target = workspaceCandidateInsideBase
932
+ ? workspaceCandidate
933
+ : resolve(base, savePath);
934
+ }
622
935
  if (!target.toLowerCase().endsWith(".mp4")) {
623
936
  throw new Error("save_path must end in .mp4");
624
937
  }