@hubfluencer/mcp 0.8.2 → 0.9.1

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.2",
3
+ "version": "0.9.1",
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,12 @@ 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>;
268
274
  /** Short only (additive, absent on older APIs): the newest render row is a free re-render. */
269
275
  latest_render_free?: boolean;
270
276
  /**
@@ -395,23 +401,251 @@ export function normalizeStatus(
395
401
  };
396
402
  }
397
403
 
398
- // 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.
399
409
  const autopilot = (d.autopilot_status as string) ?? "unknown";
400
410
  const renderStatus = (latest.status as string) ?? null;
401
- const ready = renderStatus === "completed" && Boolean(videoUrl);
402
- const terminal =
403
- ready ||
404
- autopilot === "failed" ||
405
- autopilot === "cancelled" ||
406
- 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|pending|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 === "pending" ||
485
+ d.scenario_apply_status === "applying";
486
+ // Narration generation is a distinct async worker that runs before an
487
+ // AudioVersion exists. Without this gate, a project with an older completed
488
+ // render looks ready/terminal during narration regeneration and
489
+ // wait_for_completion can return that old MP4 while the new script is still
490
+ // being produced.
491
+ const narrationActive = d.narration_status === "generating";
492
+ const scenarioFailed = d.scenario_status === "failed";
493
+ const scenarioApplyFailed = d.scenario_apply_status === "failed";
494
+ const narrationFailed = d.narration_status === "failed";
495
+ // First-time generation is visible through the current selection. A
496
+ // regeneration intentionally keeps the prior completed version current until
497
+ // the replacement lands, so inspect the version histories as well; otherwise
498
+ // an old matching render can look ready while a paid replacement is running.
499
+ const currentAudio = asRecord(d.current_audio);
500
+ const currentMusic = asRecord(d.current_music);
501
+ const audioVersions = Array.isArray(d.audio_versions)
502
+ ? d.audio_versions.map(asRecord)
503
+ : [];
504
+ const musicVersions = Array.isArray(d.music_versions)
505
+ ? d.music_versions.map(asRecord)
506
+ : [];
507
+ const numericVersion = (version: unknown): number =>
508
+ typeof version === "number" && Number.isFinite(version) ? version : 0;
509
+ const selectedAudioVersion = numericVersion(currentAudio.version);
510
+ const selectedMusicVersion = numericVersion(currentMusic.version);
511
+ const newerAudioVersions = audioVersions.filter(
512
+ (version) => numericVersion(version.version) > selectedAudioVersion,
513
+ );
514
+ const newerMusicVersions = musicVersions.filter(
515
+ (version) => numericVersion(version.version) > selectedMusicVersion,
516
+ );
517
+ const versionActive = (version: Record<string, unknown>) =>
518
+ version.status === "pending" || version.status === "processing";
519
+ const newestVersion = (versions: Array<Record<string, unknown>>) =>
520
+ versions.reduce<Record<string, unknown> | null>((latest, version) => {
521
+ if (!latest) return version;
522
+ return numericVersion(version.version) > numericVersion(latest.version)
523
+ ? version
524
+ : latest;
525
+ }, null);
526
+ const newestReplacementAudio = newestVersion(newerAudioVersions);
527
+ const newestReplacementMusic = newestVersion(newerMusicVersions);
528
+ const audioFailed =
529
+ currentAudio.status === "failed" ||
530
+ currentMusic.status === "failed" ||
531
+ newestReplacementAudio?.status === "failed" ||
532
+ newestReplacementMusic?.status === "failed";
533
+ const audioError = [
534
+ newestReplacementAudio?.error_message,
535
+ newestReplacementMusic?.error_message,
536
+ currentAudio.error_message,
537
+ currentMusic.error_message,
538
+ ].find(
539
+ (value): value is string =>
540
+ typeof value === "string" && value.trim().length > 0,
541
+ );
542
+ const audioActive =
543
+ currentAudio.status === "pending" ||
544
+ currentAudio.status === "processing" ||
545
+ currentMusic.status === "pending" ||
546
+ currentMusic.status === "processing" ||
547
+ newerAudioVersions.some(versionActive) ||
548
+ newerMusicVersions.some(versionActive);
549
+ const workActive =
550
+ autopilotActive ||
551
+ batchActive ||
552
+ renderActive ||
553
+ scenarioActive ||
554
+ narrationActive ||
555
+ audioActive ||
556
+ activeSegments.length > 0;
557
+ // Orchestration failures are historical once a repaired timeline has a fresh,
558
+ // completed MP4. Keeping them terminal would permanently block download_result
559
+ // after a successful granular recovery.
560
+ const deliveredArtifactSupersedesOrchestrationFailure =
561
+ renderStatus === "completed" &&
562
+ Boolean(videoUrl) &&
563
+ renderFresh &&
564
+ !stale &&
565
+ !workActive &&
566
+ failedSegments.length === 0 &&
567
+ !audioFailed;
568
+ const ready = deliveredArtifactSupersedesOrchestrationFailure;
569
+ const autopilotFailed = autopilot === "failed" || autopilot === "cancelled";
570
+ const renderFailed = renderStatus === "failed";
571
+ const failed =
572
+ (autopilotFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
573
+ renderFailed ||
574
+ (scenarioApplyFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
575
+ (scenarioFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
576
+ (narrationFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
577
+ (batchFailed && !deliveredArtifactSupersedesOrchestrationFailure) ||
578
+ failedSegments.length > 0 ||
579
+ audioFailed;
580
+ // Terminal = control should return to the agent because nothing will progress
581
+ // without another tool call. With every async job type folded into workActive
582
+ // above (autopilot, batch, segments, scenario, narration, audio, render), NO
583
+ // work active means exactly that — whether the project is ready, failed,
584
+ // stale (editing_required), parked on credits, or a pristine idle draft, only
585
+ // a new tool call can move it, so wait_for_completion must return instead of
586
+ // polling its full budget. (ready is subsumed by !workActive but kept for
587
+ // clarity; explicit failure states are terminal even while cleanup work spins.)
588
+ const terminal = ready || failed || batchInsufficientCredits || !workActive;
589
+ let stage = autopilot;
590
+ // Failure always wins over an unrelated active flag. Workers can leave an
591
+ // outer autopilot/batch state active after a child stage has failed; reporting
592
+ // the active stage would make agents poll forever instead of taking action.
593
+ if (ready) stage = "completed";
594
+ else if (autopilot === "failed") stage = "failed";
595
+ else if (autopilot === "cancelled") stage = "cancelled";
596
+ else if (renderFailed) stage = "render_failed";
597
+ else if (scenarioApplyFailed) stage = "scenario_apply_failed";
598
+ else if (scenarioFailed) stage = "scenario_failed";
599
+ else if (narrationFailed) stage = "narration_failed";
600
+ else if (batchFailed) stage = "batch_failed";
601
+ else if (failedSegments.length > 0) stage = "segment_failed";
602
+ else if (audioFailed) stage = "audio_failed";
603
+ else if (batchInsufficientCredits) stage = "insufficient_credits";
604
+ else if (activeSegments.length > 0) stage = "segment_processing";
605
+ else if (batchActive) stage = "segment_generation";
606
+ else if (renderActive) stage = "rendering";
607
+ else if (scenarioActive) stage = "scenario_processing";
608
+ else if (narrationActive) stage = "narration_processing";
609
+ else if (audioActive) stage = "audio_processing";
610
+ else if (!workActive && stale) stage = "editing_required";
611
+ const segmentError = failedSegments
612
+ .map((segment) => segment.error_message)
613
+ .find(
614
+ (value): value is string => typeof value === "string" && value.length > 0,
615
+ );
407
616
  return {
408
617
  kind,
409
618
  slug,
410
- stage: autopilot,
619
+ stage,
411
620
  terminal,
412
621
  ready,
413
622
  video_url: videoUrl,
414
- error: (d.autopilot_error_message as string) ?? null,
623
+ error: ready
624
+ ? null
625
+ : ((d.autopilot_error_message as string) ??
626
+ (latest.error_message as string) ??
627
+ (d.scenario_apply_error_message as string) ??
628
+ (d.scenario_error_message as string) ??
629
+ (d.narration_error_message as string) ??
630
+ (d.batch_generation_error_message as string) ??
631
+ segmentError ??
632
+ audioError ??
633
+ (batchInsufficientCredits
634
+ ? "Batch generation paused: not enough credits to finish. Top up or reduce scope, then resume."
635
+ : null)),
636
+ stale,
637
+ active_segment_ids: activeSegments
638
+ .map((segment) => segment.id)
639
+ .filter(
640
+ (id): id is number | string =>
641
+ typeof id === "number" || typeof id === "string",
642
+ ),
643
+ stale_segment_ids: staleSegments
644
+ .map((segment) => segment.id)
645
+ .filter(
646
+ (id): id is number | string =>
647
+ typeof id === "number" || typeof id === "string",
648
+ ),
415
649
  };
416
650
  }
417
651
 
@@ -541,6 +775,26 @@ export interface EditorAdKeyArgs {
541
775
  theme?: string;
542
776
  voice_id?: string;
543
777
  export_aspect_ratio?: string;
778
+ product_image_path?: string;
779
+ product_description?: string;
780
+ closing_image_path?: string;
781
+ logo_path?: string;
782
+ logo_treatment?: string;
783
+ logo_position?: string;
784
+ logo_duration_seconds?: number;
785
+ cast_mode?: string;
786
+ /** Identities of the retained, pre-create asset byte snapshots. */
787
+ product_image_identity?: string;
788
+ closing_image_identity?: string;
789
+ logo_identity?: string;
790
+ /**
791
+ * The approved spend cap forwarded to the launch POST. Folded into the
792
+ * autopilot-start key ONLY (not the draft-create key) so that re-calling
793
+ * create_editor_ad with a RAISED cap after a failed/over-cap launch mints a
794
+ * fresh key and re-enqueues, instead of replaying the server's 24h-cached dead
795
+ * 202 from the earlier lower-cap attempt.
796
+ */
797
+ max_credits?: number;
544
798
  }
545
799
 
546
800
  /** create_editor_ad's editor draft-create key (`POST /editor`). */
@@ -556,14 +810,30 @@ export function editorAdCreateKey(a: EditorAdKeyArgs): string {
556
810
  a.theme ?? "",
557
811
  a.voice_id ?? "",
558
812
  a.export_aspect_ratio ?? "",
813
+ a.product_image_path ?? "",
814
+ a.product_image_identity ?? "",
815
+ a.product_description ?? "",
816
+ a.closing_image_path ?? "",
817
+ a.closing_image_identity ?? "",
818
+ a.logo_path ?? "",
819
+ a.logo_identity ?? "",
820
+ a.logo_treatment ?? "",
821
+ a.logo_position ?? "",
822
+ a.logo_duration_seconds === undefined
823
+ ? ""
824
+ : String(a.logo_duration_seconds),
825
+ a.cast_mode ?? "",
559
826
  );
560
827
  }
561
828
 
562
829
  /**
563
830
  * create_editor_ad's autopilot-start key (`POST /editor/:slug/autopilot`). Folds
564
- * the create params (not a startState this tool starts right after create, so
565
- * there is no prior terminal state to guard against) so it tracks the inputs, not
566
- * just the slug. Self-consistent per-tool only.
831
+ * the create params AND the approved max_credits cap. There is no startState
832
+ * discriminator (this tool starts right after create, so there is no prior
833
+ * terminal state to guard against), so max_credits is what lets a re-call with a
834
+ * RAISED cap after a failed/over-cap launch mint a fresh key and re-enqueue
835
+ * rather than replay the server's 24h-cached dead 202. Self-consistent per-tool
836
+ * only.
567
837
  */
568
838
  export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
569
839
  return idemKey(
@@ -578,6 +848,17 @@ export function editorAdAutopilotKey(slug: string, a: EditorAdKeyArgs): string {
578
848
  a.theme ?? "",
579
849
  a.voice_id ?? "",
580
850
  a.export_aspect_ratio ?? "",
851
+ a.product_image_path ?? "",
852
+ a.product_description ?? "",
853
+ a.closing_image_path ?? "",
854
+ a.logo_path ?? "",
855
+ a.logo_treatment ?? "",
856
+ a.logo_position ?? "",
857
+ a.logo_duration_seconds === undefined
858
+ ? ""
859
+ : String(a.logo_duration_seconds),
860
+ a.cast_mode ?? "",
861
+ a.max_credits === undefined ? "" : String(a.max_credits),
581
862
  );
582
863
  }
583
864
 
@@ -637,9 +918,22 @@ export function addSegmentKey(
637
918
  */
638
919
  export function resolveSavePath(savePath: string): string {
639
920
  const base = resolve(process.env.HUBFLUENCER_OUTPUT_DIR || process.cwd());
640
- const target = isAbsolute(savePath)
641
- ? resolve(savePath)
642
- : resolve(base, savePath);
921
+ let target: string;
922
+ if (isAbsolute(savePath)) {
923
+ target = resolve(savePath);
924
+ } else {
925
+ // Accept both output-root-relative paths ("campaign/ad.mp4") and
926
+ // workspace-relative paths that already include the configured output
927
+ // directory ("videos/campaign/ad.mp4"). Always choose a candidate only
928
+ // when it remains confined to `base`; this fixes /videos/videos/... without
929
+ // weakening the traversal guard.
930
+ const workspaceCandidate = resolve(process.cwd(), savePath);
931
+ const workspaceCandidateInsideBase =
932
+ workspaceCandidate === base || workspaceCandidate.startsWith(base + sep);
933
+ target = workspaceCandidateInsideBase
934
+ ? workspaceCandidate
935
+ : resolve(base, savePath);
936
+ }
643
937
  if (!target.toLowerCase().endsWith(".mp4")) {
644
938
  throw new Error("save_path must end in .mp4");
645
939
  }