@hubfluencer/mcp 0.16.0 → 0.18.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/README.md +101 -45
- package/dist/index.js +12988 -7820
- package/package.json +12 -6
- package/server.json +56 -0
- package/skill/hubfluencer-create/SKILL.md +169 -0
- package/skill/hubfluencer-create/references/full-guide.md +228 -0
- package/src/client.ts +2 -2
- package/src/core.ts +303 -431
- package/src/doctor.ts +96 -0
- package/src/generation-summary.ts +258 -0
- package/src/index.ts +947 -312
- package/src/output-schemas.ts +85 -12
- package/src/polling.ts +18 -5
- package/src/skill-installer.ts +100 -0
- package/src/tool-profile.ts +92 -0
package/src/index.ts
CHANGED
|
@@ -55,6 +55,7 @@ import {
|
|
|
55
55
|
makeVideoAutopilotKey,
|
|
56
56
|
makeVideoEditorCreateKey,
|
|
57
57
|
type NormalizedStatus,
|
|
58
|
+
normalizeFactoryIndexProject,
|
|
58
59
|
normalizeStatus,
|
|
59
60
|
resolveSavePath,
|
|
60
61
|
sliderCreateKey,
|
|
@@ -64,14 +65,21 @@ import {
|
|
|
64
65
|
validateLanguage,
|
|
65
66
|
validateProductPrompt,
|
|
66
67
|
} from "./core.js";
|
|
68
|
+
import { runDoctor } from "./doctor.js";
|
|
69
|
+
import {
|
|
70
|
+
type GenerationSummary,
|
|
71
|
+
generationSummarySchema,
|
|
72
|
+
} from "./generation-summary.js";
|
|
67
73
|
import { runLogin } from "./login.js";
|
|
68
74
|
import {
|
|
69
75
|
createCampaignOutput,
|
|
70
76
|
createDraftOutput,
|
|
71
77
|
createEditorOutput,
|
|
72
78
|
downloadResultOutput,
|
|
79
|
+
genericToolOutput,
|
|
73
80
|
getCampaignPackOutput,
|
|
74
81
|
getCreditsOutput,
|
|
82
|
+
getEditorOutput,
|
|
75
83
|
getRecommendationsOutput,
|
|
76
84
|
getSeriesDashboardOutput,
|
|
77
85
|
getSliderOutput,
|
|
@@ -115,6 +123,12 @@ import {
|
|
|
115
123
|
summarizeSeries,
|
|
116
124
|
validateScheduledPostCopy,
|
|
117
125
|
} from "./series-calendar.js";
|
|
126
|
+
import { runInstallSkill } from "./skill-installer.js";
|
|
127
|
+
import {
|
|
128
|
+
profileFromArgs,
|
|
129
|
+
toolEnabled,
|
|
130
|
+
withoutProfileArgs,
|
|
131
|
+
} from "./tool-profile.js";
|
|
118
132
|
import {
|
|
119
133
|
CATALOG_ASSET_EXTS,
|
|
120
134
|
closePreparedUploadFile,
|
|
@@ -155,6 +169,115 @@ async function fetchStatus(
|
|
|
155
169
|
return normalizeStatus(kind, slug, asRecord(res).data);
|
|
156
170
|
}
|
|
157
171
|
|
|
172
|
+
type ProductKind = "editor" | "short";
|
|
173
|
+
|
|
174
|
+
interface ObservedGenerationAction {
|
|
175
|
+
state: Record<string, unknown>;
|
|
176
|
+
summary: GenerationSummary;
|
|
177
|
+
stepKey: string | null;
|
|
178
|
+
completedStatus?: NormalizedStatus;
|
|
179
|
+
activeReplay?: true;
|
|
180
|
+
settledReplay?: true;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Reads and validates the current product-owned action immediately before a
|
|
185
|
+
* write. The summary is an authority observation, not a bearer capability:
|
|
186
|
+
* the API rechecks all of these fences under its command lock.
|
|
187
|
+
*/
|
|
188
|
+
async function observedGenerationAction(
|
|
189
|
+
client: HubfluencerClient,
|
|
190
|
+
product: ProductKind,
|
|
191
|
+
slug: string,
|
|
192
|
+
actionType: string,
|
|
193
|
+
options: {
|
|
194
|
+
workflows?: readonly string[];
|
|
195
|
+
statuses?: readonly string[];
|
|
196
|
+
requireStep?: boolean;
|
|
197
|
+
allowIdleAuthority?: boolean;
|
|
198
|
+
recoverCompleted?: boolean;
|
|
199
|
+
recoverActive?: boolean;
|
|
200
|
+
recoverCancelled?: boolean;
|
|
201
|
+
} = {},
|
|
202
|
+
): Promise<ObservedGenerationAction> {
|
|
203
|
+
const path = product === "short" ? `/shorts/${slug}` : `/editor/${slug}`;
|
|
204
|
+
const response = await client.get<{ data: unknown }>(path);
|
|
205
|
+
const state = asRecord(asRecord(response).data);
|
|
206
|
+
const parsed = generationSummarySchema.safeParse(state.generation_summary);
|
|
207
|
+
if (!parsed.success) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`The Hubfluencer ${product} response did not contain a valid generation_summary; refresh before continuing.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const summary = parsed.data;
|
|
214
|
+
if (options.recoverCompleted === true) {
|
|
215
|
+
const completedStatus = normalizeStatus(product, slug, state);
|
|
216
|
+
if (completedStatus.ready && completedStatus.video_url) {
|
|
217
|
+
return { state, summary, stepKey: null, completedStatus };
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
const idleAuthority =
|
|
221
|
+
options.allowIdleAuthority === true &&
|
|
222
|
+
summary.status === "idle" &&
|
|
223
|
+
summary.workflow === null &&
|
|
224
|
+
summary.workflow_version === null &&
|
|
225
|
+
summary.run_id === null &&
|
|
226
|
+
summary.revision === null;
|
|
227
|
+
if (
|
|
228
|
+
options.workflows &&
|
|
229
|
+
!idleAuthority &&
|
|
230
|
+
(!summary.workflow || !options.workflows.includes(summary.workflow))
|
|
231
|
+
) {
|
|
232
|
+
throw new Error(
|
|
233
|
+
`The current ${product} workflow does not authorize ${actionType}; refresh before continuing.`,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
if (options.statuses && !options.statuses.includes(summary.status)) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`The current ${product} generation status does not authorize ${actionType}; refresh before continuing.`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
const canonicalAuthority =
|
|
242
|
+
typeof summary.run_id === "string" &&
|
|
243
|
+
Number.isInteger(summary.revision) &&
|
|
244
|
+
(summary.revision as number) >= 0;
|
|
245
|
+
if (!canonicalAuthority && !idleAuthority) {
|
|
246
|
+
throw new Error(
|
|
247
|
+
`The current ${product} generation authority is missing its run or revision fence; refresh before continuing.`,
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (
|
|
252
|
+
options.recoverActive === true &&
|
|
253
|
+
(summary.status === "queued" || summary.status === "running")
|
|
254
|
+
) {
|
|
255
|
+
return { state, summary, stepKey: null, activeReplay: true };
|
|
256
|
+
}
|
|
257
|
+
if (options.recoverCancelled === true && summary.status === "cancelled") {
|
|
258
|
+
return { state, summary, stepKey: null, settledReplay: true };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const action = summary.available_actions.find(
|
|
262
|
+
(candidate) => candidate.type === actionType,
|
|
263
|
+
);
|
|
264
|
+
if (!action) {
|
|
265
|
+
throw new Error(
|
|
266
|
+
`The server does not currently advertise ${actionType} for this ${product}; refresh before continuing.`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
if (
|
|
270
|
+
options.requireStep &&
|
|
271
|
+
(typeof action.step_key !== "string" || action.step_key.length === 0)
|
|
272
|
+
) {
|
|
273
|
+
throw new Error(
|
|
274
|
+
`The advertised ${actionType} action is missing its step fence; refresh before continuing.`,
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
return { state, summary, stepKey: action.step_key };
|
|
279
|
+
}
|
|
280
|
+
|
|
158
281
|
async function editorAutopilotQuote(
|
|
159
282
|
client: HubfluencerClient,
|
|
160
283
|
slug: string,
|
|
@@ -379,10 +502,13 @@ function ok(payload: unknown, links: ResourceLink[] = []) {
|
|
|
379
502
|
const result: {
|
|
380
503
|
content: typeof content;
|
|
381
504
|
structuredContent?: Record<string, unknown>;
|
|
382
|
-
} = {
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
505
|
+
} = {
|
|
506
|
+
content,
|
|
507
|
+
structuredContent:
|
|
508
|
+
payload && typeof payload === "object" && !Array.isArray(payload)
|
|
509
|
+
? (payload as Record<string, unknown>)
|
|
510
|
+
: { value: payload },
|
|
511
|
+
};
|
|
386
512
|
return result;
|
|
387
513
|
}
|
|
388
514
|
|
|
@@ -427,21 +553,18 @@ function errMessage(e: unknown): string {
|
|
|
427
553
|
* silently no-op'ing the purchase (no credit spent, no assists added) while
|
|
428
554
|
* still returning 200. Snapshotting means a transport retry of the SAME unlock
|
|
429
555
|
* replays safely (no double-charge), but a DISTINCT "buy another batch" call
|
|
430
|
-
* gets a fresh key and executes.
|
|
431
|
-
* the
|
|
556
|
+
* gets a fresh key and executes. If the snapshot cannot be read, fail before
|
|
557
|
+
* the paid POST: a nonce fallback cannot distinguish a transport retry from a
|
|
558
|
+
* genuine new purchase and could double-charge after an accepted/lost response.
|
|
432
559
|
*/
|
|
433
560
|
async function unlockKey(client: HubfluencerClient): Promise<string> {
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
);
|
|
442
|
-
} catch {
|
|
443
|
-
return "";
|
|
444
|
-
}
|
|
561
|
+
const res = await client.get<{ data: unknown }>("/ai-assists");
|
|
562
|
+
const d = asRecord(asRecord(res).data);
|
|
563
|
+
return idemKey(
|
|
564
|
+
"unlock-ai-assists",
|
|
565
|
+
String(d.used ?? ""),
|
|
566
|
+
String(d.bonus ?? ""),
|
|
567
|
+
);
|
|
445
568
|
}
|
|
446
569
|
|
|
447
570
|
/**
|
|
@@ -450,25 +573,17 @@ async function unlockKey(client: HubfluencerClient): Promise<string> {
|
|
|
450
573
|
* deterministic, so replaying an identical (narration, voice) call is correct
|
|
451
574
|
* and free, while regenerating after the narration or voice changes gets a
|
|
452
575
|
* fresh key and actually re-runs — a fixed slug+voice key would replay stale
|
|
453
|
-
* audio for 24h after a narration edit.
|
|
576
|
+
* audio for 24h after a narration edit. If the input snapshot cannot be read,
|
|
577
|
+
* fail before the paid POST rather than minting a retry-unstable nonce.
|
|
454
578
|
*/
|
|
455
579
|
async function voiceKey(
|
|
456
580
|
client: HubfluencerClient,
|
|
457
581
|
slug: string,
|
|
458
582
|
voiceId: string,
|
|
459
583
|
): Promise<string> {
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
return idemKey(
|
|
464
|
-
"gen-voice",
|
|
465
|
-
slug,
|
|
466
|
-
voiceId,
|
|
467
|
-
String(d.narration_script ?? ""),
|
|
468
|
-
);
|
|
469
|
-
} catch {
|
|
470
|
-
return "";
|
|
471
|
-
}
|
|
584
|
+
const res = await client.get<{ data: unknown }>(`/editor/${slug}`);
|
|
585
|
+
const d = asRecord(asRecord(res).data);
|
|
586
|
+
return idemKey("gen-voice", slug, voiceId, String(d.narration_script ?? ""));
|
|
472
587
|
}
|
|
473
588
|
|
|
474
589
|
/**
|
|
@@ -497,7 +612,7 @@ async function withAssist<T>(
|
|
|
497
612
|
return await fn();
|
|
498
613
|
} catch (e) {
|
|
499
614
|
const he = e as HubfluencerError;
|
|
500
|
-
if (
|
|
615
|
+
if (he?.status !== 429) throw e;
|
|
501
616
|
if (!autoUnlock) throw e;
|
|
502
617
|
// One unlock (1 credit → +10 assists), then one retry. A 402 here means
|
|
503
618
|
// the account is out of credits — let it propagate to a clean fail().
|
|
@@ -633,6 +748,8 @@ const WRITE = {
|
|
|
633
748
|
// call (the per-tool `description`s carry the specifics). Kept concise: the two
|
|
634
749
|
// altitudes (one-shot asset vs. the full delegate-my-content-operation loop),
|
|
635
750
|
// money, how waiting/resuming works, and the local-file sandbox.
|
|
751
|
+
const activeToolProfile = profileFromArgs(process.argv.slice(2));
|
|
752
|
+
|
|
636
753
|
const 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).
|
|
637
754
|
|
|
638
755
|
MAKE ONE ASSET (simplest)
|
|
@@ -655,13 +772,16 @@ CREDITS (spent server-side; each tool prices before charging — see its descrip
|
|
|
655
772
|
- 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.
|
|
656
773
|
|
|
657
774
|
WAITING & RESUMING
|
|
658
|
-
-
|
|
775
|
+
- Every Editor and Short read includes a non-null generation_summary. Treat its status, timing, blockers, and available_actions as the only lifecycle authority; product and latest-render fields are authoring or delivery details, never competing control state. Poll only while generation_summary.timing.poll_after_ms is a positive integer; null means stop. Invoke only an action advertised by generation_summary.available_actions, carrying every route fence required by that product command. wait_for_completion enforces those timing hints within a budget capped at 280s; when timed_out, call it again with the same slug. A terminal summary can require a start/retry/input/review action instead of more polling; download only when download_result is advertised.
|
|
776
|
+
- Editor delivery freshness is separate from lifecycle authority: after a completed summary, raw render/scene/audio staleness forces normalized stale:true, ready:false, and stage:"editing_required". The retained MP4 is historical; re-render before delivery. list_projects factory rows are lightweight mixed-kind discovery records without generation_summary, so use get_status/get_editor before acting.
|
|
659
777
|
- 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.
|
|
660
778
|
|
|
661
779
|
LOCAL FILES (sandboxed)
|
|
662
780
|
- 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.
|
|
663
781
|
|
|
664
|
-
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
|
|
782
|
+
AUTH: set HUBFLUENCER_API_TOKEN (Settings → Access tokens) or run \`npx -y @hubfluencer/mcp@${VERSION} login\`. HUBFLUENCER_BASE_URL overrides the API host; HUBFLUENCER_CREDENTIALS overrides the device-link credentials path.
|
|
783
|
+
|
|
784
|
+
TOOL PROFILE: ${activeToolProfile}. The default full profile preserves every workflow. The opt-in creator profile keeps the high-level create, iterate, recovery, and delivery tools visible so tools/list uses much less agent context; switch with --profile creator or HUBFLUENCER_MCP_PROFILE=creator.`;
|
|
665
785
|
|
|
666
786
|
const server = new McpServer(
|
|
667
787
|
{ name: "hubfluencer", version: VERSION },
|
|
@@ -678,9 +798,69 @@ type LooseRegister = (
|
|
|
678
798
|
config: object,
|
|
679
799
|
handler: (...args: never[]) => unknown,
|
|
680
800
|
) => void;
|
|
681
|
-
const
|
|
801
|
+
const registerToolRaw = server.registerTool.bind(
|
|
682
802
|
server,
|
|
683
803
|
) as unknown as LooseRegister;
|
|
804
|
+
const registerTool: LooseRegister = (name, config, handler) => {
|
|
805
|
+
if (!toolEnabled(activeToolProfile, name)) return;
|
|
806
|
+
const toolConfig = config as Record<string, unknown>;
|
|
807
|
+
registerToolRaw(
|
|
808
|
+
name,
|
|
809
|
+
toolConfig.outputSchema
|
|
810
|
+
? toolConfig
|
|
811
|
+
: { ...toolConfig, outputSchema: genericToolOutput },
|
|
812
|
+
handler,
|
|
813
|
+
);
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
function serverStoppedPolling(kind: Kind, status: NormalizedStatus): boolean {
|
|
817
|
+
return (
|
|
818
|
+
(kind === "editor" || kind === "short") &&
|
|
819
|
+
status.ready === false &&
|
|
820
|
+
!(typeof status.poll_after_ms === "number" && status.poll_after_ms > 0)
|
|
821
|
+
);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function makeVideoPollingStopNote(status: NormalizedStatus): string {
|
|
825
|
+
const actions = status.available_actions ?? [];
|
|
826
|
+
const advertised =
|
|
827
|
+
actions.length > 0
|
|
828
|
+
? `The server advertises only: ${actions.join(", ")}. Follow one of those exact actions before polling again.`
|
|
829
|
+
: "The server did not advertise a next action. Inspect generation_summary.blockers before doing anything else.";
|
|
830
|
+
const restart = actions.includes("start_generation")
|
|
831
|
+
? "start_generation is advertised, so re-running make_video is allowed after the blocker is resolved."
|
|
832
|
+
: "Do not re-run make_video: start_generation is not advertised.";
|
|
833
|
+
|
|
834
|
+
return `Polling stopped at ${status.stage}. ${status.error ?? ""} ${advertised} ${restart}`
|
|
835
|
+
.replace(/\s+/g, " ")
|
|
836
|
+
.trim();
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
async function recoverCompletedMakeVideo(
|
|
840
|
+
status: NormalizedStatus,
|
|
841
|
+
savePath: string | undefined,
|
|
842
|
+
kindInferred: boolean,
|
|
843
|
+
) {
|
|
844
|
+
const videoUrl = status.video_url;
|
|
845
|
+
if (!status.ready || !videoUrl) return null;
|
|
846
|
+
|
|
847
|
+
const saved_to = savePath
|
|
848
|
+
? (await downloadTo(videoUrl, savePath)).saved_to
|
|
849
|
+
: null;
|
|
850
|
+
return ok(
|
|
851
|
+
{
|
|
852
|
+
...status,
|
|
853
|
+
kind_inferred: kindInferred,
|
|
854
|
+
charged: false,
|
|
855
|
+
replayed: true,
|
|
856
|
+
saved_to,
|
|
857
|
+
timed_out: false,
|
|
858
|
+
polling_stopped: false,
|
|
859
|
+
note: "Recovered the existing completed output. Nothing was charged.",
|
|
860
|
+
},
|
|
861
|
+
[mp4Link(videoUrl, status.slug)],
|
|
862
|
+
);
|
|
863
|
+
}
|
|
684
864
|
|
|
685
865
|
async function pollToTerminal(
|
|
686
866
|
client: HubfluencerClient,
|
|
@@ -693,8 +873,14 @@ async function pollToTerminal(
|
|
|
693
873
|
return pollWithFinalReconciliation({
|
|
694
874
|
read: (deadline) => fetchStatus(client, kind, slug, deadline),
|
|
695
875
|
isTerminal: (status) => status.terminal,
|
|
876
|
+
shouldContinue: (status) =>
|
|
877
|
+
(kind !== "editor" && kind !== "short") ||
|
|
878
|
+
(typeof status.poll_after_ms === "number" && status.poll_after_ms > 0),
|
|
696
879
|
budgetMs,
|
|
697
|
-
intervalMs
|
|
880
|
+
intervalMs: (status) =>
|
|
881
|
+
typeof status.poll_after_ms === "number"
|
|
882
|
+
? Math.min(60_000, Math.max(5_000, status.poll_after_ms))
|
|
883
|
+
: intervalMs,
|
|
698
884
|
onPoll: (status, pollNumber) =>
|
|
699
885
|
reportProgress(extra, pollNumber, `stage: ${status.stage}`),
|
|
700
886
|
});
|
|
@@ -764,7 +950,8 @@ registerTool(
|
|
|
764
950
|
title: "Make a video from a prompt (one shot)",
|
|
765
951
|
description:
|
|
766
952
|
"The simplest path: give a prompt, get a finished MP4. Creates the project (free), PRICES it against " +
|
|
767
|
-
"your live credit balance, then — only if it's affordable
|
|
953
|
+
"your live credit balance, then — only if it's affordable, within max_credits, and the canonical " +
|
|
954
|
+
"generation_summary advertises start_generation — starts generation, " +
|
|
768
955
|
"polls to completion (emitting progress), and (if save_path is given) downloads the result. " +
|
|
769
956
|
"Spends credits (15 for a short; a multi-scene editor ad ~28). Pass dry_run:true to preview the cost " +
|
|
770
957
|
"WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " +
|
|
@@ -1174,9 +1361,66 @@ registerTool(
|
|
|
1174
1361
|
);
|
|
1175
1362
|
slug = created.data.slug;
|
|
1176
1363
|
}
|
|
1364
|
+
// A deterministic create replay may already represent accepted work.
|
|
1365
|
+
// Recover that canonical run before quoting or requiring a fresh
|
|
1366
|
+
// start_generation action: active/completed work must be resumed, never
|
|
1367
|
+
// rejected or charged again because its cost route is no longer launchable.
|
|
1368
|
+
let observedStart: ObservedGenerationAction | undefined;
|
|
1369
|
+
let observationError: unknown;
|
|
1370
|
+
let activeReplay = false;
|
|
1371
|
+
if (!args.dry_run) {
|
|
1372
|
+
try {
|
|
1373
|
+
observedStart = await observedGenerationAction(
|
|
1374
|
+
client,
|
|
1375
|
+
kind === "short" ? "short" : "editor",
|
|
1376
|
+
slug,
|
|
1377
|
+
"start_generation",
|
|
1378
|
+
kind === "short"
|
|
1379
|
+
? {
|
|
1380
|
+
workflows: ["short.generation"],
|
|
1381
|
+
statuses: [
|
|
1382
|
+
"idle",
|
|
1383
|
+
"queued",
|
|
1384
|
+
"running",
|
|
1385
|
+
"failed",
|
|
1386
|
+
"cancelled",
|
|
1387
|
+
],
|
|
1388
|
+
allowIdleAuthority: true,
|
|
1389
|
+
recoverCompleted: true,
|
|
1390
|
+
recoverActive: true,
|
|
1391
|
+
}
|
|
1392
|
+
: {
|
|
1393
|
+
workflows: ["editor.autopilot"],
|
|
1394
|
+
statuses: [
|
|
1395
|
+
"idle",
|
|
1396
|
+
"queued",
|
|
1397
|
+
"running",
|
|
1398
|
+
"failed",
|
|
1399
|
+
"cancelled",
|
|
1400
|
+
],
|
|
1401
|
+
allowIdleAuthority: true,
|
|
1402
|
+
recoverCompleted: true,
|
|
1403
|
+
recoverActive: true,
|
|
1404
|
+
},
|
|
1405
|
+
);
|
|
1406
|
+
const recovery = observedStart.completedStatus
|
|
1407
|
+
? await recoverCompletedMakeVideo(
|
|
1408
|
+
observedStart.completedStatus,
|
|
1409
|
+
args.save_path,
|
|
1410
|
+
requestedKind === undefined,
|
|
1411
|
+
)
|
|
1412
|
+
: null;
|
|
1413
|
+
if (recovery) return recovery;
|
|
1414
|
+
activeReplay = observedStart.activeReplay === true;
|
|
1415
|
+
} catch (error) {
|
|
1416
|
+
// A non-launching pricing result below can still safely return its
|
|
1417
|
+
// free draft. Re-throw before any paid POST when launch is possible.
|
|
1418
|
+
observationError = error;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1177
1421
|
|
|
1178
1422
|
// 2) Preflight cost vs the live balance BEFORE charging. Shorts retain
|
|
1179
|
-
// their fixed-price
|
|
1423
|
+
// their fixed-price behavior, but an editor without a
|
|
1180
1424
|
// readable quote must never fall through to the API's uncapped launch.
|
|
1181
1425
|
const costPath =
|
|
1182
1426
|
kind === "short"
|
|
@@ -1185,18 +1429,21 @@ registerTool(
|
|
|
1185
1429
|
let estimated_credits: number | null = null;
|
|
1186
1430
|
let available_credits: number | null = null;
|
|
1187
1431
|
let authorizedCredits: number | undefined;
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1432
|
+
if (!activeReplay) {
|
|
1433
|
+
try {
|
|
1434
|
+
const cost = asRecord(
|
|
1435
|
+
asRecord(await client.get<{ data: unknown }>(costPath)).data,
|
|
1436
|
+
);
|
|
1437
|
+
estimated_credits =
|
|
1438
|
+
typeof cost.total === "number" ? cost.total : null;
|
|
1439
|
+
available_credits =
|
|
1440
|
+
typeof cost.available_credits === "number"
|
|
1441
|
+
? cost.available_credits
|
|
1442
|
+
: null;
|
|
1443
|
+
} catch {
|
|
1444
|
+
// The editor branch below fails closed unless the caller supplied an
|
|
1445
|
+
// explicit cap that the launch endpoint can enforce independently.
|
|
1446
|
+
}
|
|
1200
1447
|
}
|
|
1201
1448
|
|
|
1202
1449
|
const resume =
|
|
@@ -1218,13 +1465,14 @@ registerTool(
|
|
|
1218
1465
|
|
|
1219
1466
|
// 3) Stop BEFORE charging on dry_run, an uncapped editor with no live
|
|
1220
1467
|
// quote, an over-cap estimate, or a balance we already know is too
|
|
1221
|
-
// low.
|
|
1222
|
-
//
|
|
1468
|
+
// low. An already-active replay bypasses launch admission entirely:
|
|
1469
|
+
// it is existing paid work to resume, not a new charge.
|
|
1223
1470
|
if (
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1471
|
+
!activeReplay &&
|
|
1472
|
+
(args.dry_run ||
|
|
1473
|
+
editorPriceUnavailableWithoutCap ||
|
|
1474
|
+
overCap ||
|
|
1475
|
+
!affordable)
|
|
1228
1476
|
) {
|
|
1229
1477
|
const note = args.dry_run
|
|
1230
1478
|
? `Dry run — created a free draft and priced it (${estimated_credits ?? "?"} credits, have ${available_credits ?? "?"}). To run it: ${resume}.`
|
|
@@ -1245,69 +1493,66 @@ registerTool(
|
|
|
1245
1493
|
});
|
|
1246
1494
|
}
|
|
1247
1495
|
|
|
1248
|
-
// 4) Affordable and authorized — start
|
|
1249
|
-
//
|
|
1250
|
-
//
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
// already-running / 409 gate independently blocks a double charge) but a
|
|
1256
|
-
// per-attempt signature once terminally failed, so a genuine re-run mints
|
|
1257
|
-
// a fresh key and re-enqueues. Best-effort: if the read fails we fall
|
|
1258
|
-
// back to the stable "start" (the server gate remains the real guard).
|
|
1259
|
-
let startState = "start";
|
|
1260
|
-
try {
|
|
1261
|
-
const cur = await client.get<{ data: unknown }>(
|
|
1262
|
-
kind === "short" ? `/shorts/${slug}` : `/editor/${slug}`,
|
|
1496
|
+
// 4) Affordable and caller-authorized — start only when the canonical
|
|
1497
|
+
// preflight above advertised start_generation. An active replay has
|
|
1498
|
+
// already been accepted and proceeds directly to polling.
|
|
1499
|
+
if (!observedStart) {
|
|
1500
|
+
throw (
|
|
1501
|
+
observationError ??
|
|
1502
|
+
new Error("Missing canonical generation authority before paid launch")
|
|
1263
1503
|
);
|
|
1264
|
-
startState = startStateDiscriminator(kind, asRecord(cur).data);
|
|
1265
|
-
} catch {
|
|
1266
|
-
// status unreadable — keep the stable token (the server gate still holds)
|
|
1267
1504
|
}
|
|
1268
1505
|
|
|
1269
|
-
if (
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1506
|
+
if (!activeReplay) {
|
|
1507
|
+
const startState = startStateDiscriminator(kind, observedStart.state);
|
|
1508
|
+
|
|
1509
|
+
if (kind === "short") {
|
|
1510
|
+
await client.post(
|
|
1511
|
+
`/shorts/${slug}/generate`,
|
|
1512
|
+
// Only send a body when the caller opts out of server auto-copy —
|
|
1513
|
+
// the bare POST stays byte-identical to the pre-0.8.2 wire shape.
|
|
1514
|
+
args.skip_auto_text ? { skip_auto_text: true } : undefined,
|
|
1515
|
+
// gen-short:<slug> + the failure-state discriminator: a fresh/in-flight
|
|
1516
|
+
// short reuses "gen-short:<slug>:start" (transport-retry dedupe), a
|
|
1517
|
+
// re-run after a failed render gets a fresh key and re-renders.
|
|
1518
|
+
// skip_auto_text is folded in so a bare-clip run never replays a
|
|
1519
|
+
// cached auto-copy run's response (or vice versa) on the same slug.
|
|
1520
|
+
`gen-short:${slug}:${startState}${args.skip_auto_text ? ":bare" : ""}`,
|
|
1521
|
+
);
|
|
1522
|
+
} else {
|
|
1523
|
+
const approvedCap = args.max_credits ?? estimated_credits;
|
|
1524
|
+
if (approvedCap === null) {
|
|
1525
|
+
// Belt-and-suspenders invariant behind the fail-closed branch above:
|
|
1526
|
+
// never let a future condition refactor revive an uncapped editor POST.
|
|
1527
|
+
throw new Error(
|
|
1528
|
+
"Editor autopilot launch requires max_credits or a live quote",
|
|
1529
|
+
);
|
|
1530
|
+
}
|
|
1531
|
+
authorizedCredits = approvedCap;
|
|
1532
|
+
await client.post(
|
|
1533
|
+
`/editor/${slug}/autopilot`,
|
|
1534
|
+
// Use the caller's explicit cap when given, otherwise the live
|
|
1535
|
+
// estimate. The API recomputes under its launch lock and rejects a
|
|
1536
|
+
// price increase before any state change or charge.
|
|
1537
|
+
{ max_credits: approvedCap },
|
|
1538
|
+
// Fold the create-affecting params AND the failure-state discriminator
|
|
1539
|
+
// into the start key so it tracks the inputs + current state, not just
|
|
1540
|
+
// the slug. This key is only compared against a retry of *this* tool on
|
|
1541
|
+
// *this* slug; the other autopilot entry points use a different field
|
|
1542
|
+
// set/order on purpose (the server idempotency cache is scoped to
|
|
1543
|
+
// path+key, and the slug — already in the path — is unique per draft),
|
|
1544
|
+
// so the shapes are not meant to be interchangeable across tools.
|
|
1545
|
+
// Composition lives in core.ts, shared with the golden-pin test.
|
|
1546
|
+
makeVideoAutopilotKey(slug, args, startState),
|
|
1289
1547
|
);
|
|
1290
1548
|
}
|
|
1291
|
-
authorizedCredits = approvedCap;
|
|
1292
|
-
await client.post(
|
|
1293
|
-
`/editor/${slug}/autopilot`,
|
|
1294
|
-
// Use the caller's explicit cap when given, otherwise the live
|
|
1295
|
-
// estimate. The API recomputes under its launch lock and rejects a
|
|
1296
|
-
// price increase before any state change or charge.
|
|
1297
|
-
{ max_credits: approvedCap },
|
|
1298
|
-
// Fold the create-affecting params AND the failure-state discriminator
|
|
1299
|
-
// into the start key so it tracks the inputs + current state, not just
|
|
1300
|
-
// the slug. This key is only compared against a retry of *this* tool on
|
|
1301
|
-
// *this* slug; the other autopilot entry points use a different field
|
|
1302
|
-
// set/order on purpose (the server idempotency cache is scoped to
|
|
1303
|
-
// path+key, and the slug — already in the path — is unique per draft),
|
|
1304
|
-
// so the shapes are not meant to be interchangeable across tools.
|
|
1305
|
-
// Composition lives in core.ts, shared with the golden-pin test.
|
|
1306
|
-
makeVideoAutopilotKey(slug, args, startState),
|
|
1307
|
-
);
|
|
1308
1549
|
}
|
|
1309
1550
|
|
|
1310
|
-
await reportProgress(
|
|
1551
|
+
await reportProgress(
|
|
1552
|
+
extra,
|
|
1553
|
+
0,
|
|
1554
|
+
`${activeReplay ? "resumed" : "started"} ${kind} ${slug}`,
|
|
1555
|
+
);
|
|
1311
1556
|
const status = await pollToTerminal(
|
|
1312
1557
|
client,
|
|
1313
1558
|
kind,
|
|
@@ -1349,10 +1594,12 @@ registerTool(
|
|
|
1349
1594
|
|
|
1350
1595
|
const billing =
|
|
1351
1596
|
kind === "short"
|
|
1352
|
-
? { charged: true }
|
|
1597
|
+
? { charged: true, ...(activeReplay ? { replayed: true } : {}) }
|
|
1353
1598
|
: {
|
|
1354
|
-
launched:
|
|
1355
|
-
|
|
1599
|
+
launched: !activeReplay,
|
|
1600
|
+
...(activeReplay
|
|
1601
|
+
? { replayed: true }
|
|
1602
|
+
: { authorized_credits: authorizedCredits }),
|
|
1356
1603
|
...(editorCreditsSpent === undefined
|
|
1357
1604
|
? {}
|
|
1358
1605
|
: {
|
|
@@ -1361,6 +1608,7 @@ registerTool(
|
|
|
1361
1608
|
}),
|
|
1362
1609
|
};
|
|
1363
1610
|
|
|
1611
|
+
const pollingStopped = serverStoppedPolling(kind, status);
|
|
1364
1612
|
const result = {
|
|
1365
1613
|
...status,
|
|
1366
1614
|
kind_inferred: requestedKind === undefined,
|
|
@@ -1368,7 +1616,8 @@ registerTool(
|
|
|
1368
1616
|
available_credits,
|
|
1369
1617
|
...billing,
|
|
1370
1618
|
saved_to,
|
|
1371
|
-
timed_out: !status.terminal,
|
|
1619
|
+
timed_out: !status.terminal && !pollingStopped,
|
|
1620
|
+
polling_stopped: pollingStopped,
|
|
1372
1621
|
};
|
|
1373
1622
|
// Thread the caller's save_path into the resume hint so the one-shot
|
|
1374
1623
|
// "download to this path" intent survives a mid-render timeout —
|
|
@@ -1385,17 +1634,19 @@ registerTool(
|
|
|
1385
1634
|
...result,
|
|
1386
1635
|
note: status.ready
|
|
1387
1636
|
? "Done. Presigned ~24h URL — download promptly."
|
|
1388
|
-
:
|
|
1389
|
-
? status
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
//
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1637
|
+
: pollingStopped
|
|
1638
|
+
? makeVideoPollingStopNote(status)
|
|
1639
|
+
: status.terminal
|
|
1640
|
+
? status.stage === "batch_retry_required"
|
|
1641
|
+
? `Terminal (${status.stage}). ${status.error ?? ""} Resolve the paused batch before restarting Autopilot.`.trim()
|
|
1642
|
+
: // A terminal-but-not-ready make_video is normally a failed/cancelled run.
|
|
1643
|
+
// Point at a restart: re-running make_video now genuinely restarts
|
|
1644
|
+
// (the start key folds the failure state, so it no longer replays the
|
|
1645
|
+
// dead 202), or resume the pipeline directly. Fix the cause (often
|
|
1646
|
+
// credits) first.
|
|
1647
|
+
`Terminal (${status.stage}). ${status.error ?? ""}`.trim() +
|
|
1648
|
+
` To restart, fix the cause (often credits) then re-run make_video, or resume with ${resume}.`
|
|
1649
|
+
: `Still rendering — call wait_for_completion(${resumeArgs}).`,
|
|
1399
1650
|
},
|
|
1400
1651
|
links,
|
|
1401
1652
|
);
|
|
@@ -1490,8 +1741,9 @@ registerTool(
|
|
|
1490
1741
|
{
|
|
1491
1742
|
title: "List recent projects",
|
|
1492
1743
|
description:
|
|
1493
|
-
"Lists the caller's recent shorts
|
|
1494
|
-
"
|
|
1744
|
+
"Lists the caller's recent shorts plus lightweight editor/video-factory discovery rows. Shorts " +
|
|
1745
|
+
"carry normalized lifecycle state; generic factory rows carry {slug, kind_detail, stage, video_url} " +
|
|
1746
|
+
"because that mixed-kind index has no generation_summary. By default the shorts listing is limited to " +
|
|
1495
1747
|
"in-progress/failed; pass include_completed:true to also surface finished shorts — useful to " +
|
|
1496
1748
|
"recover a lost slug. Capped to the most recent items per kind (use get_status for authoritative state).",
|
|
1497
1749
|
inputSchema: {
|
|
@@ -1528,23 +1780,35 @@ registerTool(
|
|
|
1528
1780
|
};
|
|
1529
1781
|
const shortRows = rows(shortsRaw);
|
|
1530
1782
|
const factoryRows = rows(factoriesRaw);
|
|
1783
|
+
// Shorts already have a dedicated, summary-bearing lane above. The
|
|
1784
|
+
// generic endpoint also returns their backing video_factory rows, so
|
|
1785
|
+
// filter them before applying the per-kind cap: otherwise one Short is
|
|
1786
|
+
// duplicated and a page of recent Shorts can crowd Editors/campaigns
|
|
1787
|
+
// out of lightweight discovery entirely.
|
|
1788
|
+
const genericFactoryRows = factoryRows.filter(
|
|
1789
|
+
(row) => row.kind !== "short",
|
|
1790
|
+
);
|
|
1791
|
+
const shortTotal = asRecord(asRecord(shortsRaw).meta).total;
|
|
1792
|
+
const shortsTruncated =
|
|
1793
|
+
(typeof shortTotal === "number" && shortTotal > cap) ||
|
|
1794
|
+
shortRows.length > cap;
|
|
1531
1795
|
|
|
1532
1796
|
const shorts = shortRows
|
|
1533
1797
|
.slice(0, cap)
|
|
1534
1798
|
.map((s) => normalizeStatus("short", String(s.slug ?? ""), s));
|
|
1535
|
-
// /video-factories spans editor + campaign + creative kinds
|
|
1536
|
-
//
|
|
1537
|
-
//
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1799
|
+
// /video-factories spans editor + campaign + creative kinds and its real
|
|
1800
|
+
// index serializer deliberately omits generation_summary. This lane is
|
|
1801
|
+
// discovery-only; never feed these rows through the fail-closed product
|
|
1802
|
+
// lifecycle normalizer.
|
|
1803
|
+
const projects = genericFactoryRows
|
|
1804
|
+
.slice(0, cap)
|
|
1805
|
+
.map(normalizeFactoryIndexProject);
|
|
1542
1806
|
|
|
1543
1807
|
return ok({
|
|
1544
1808
|
shorts,
|
|
1545
1809
|
projects,
|
|
1546
1810
|
note:
|
|
1547
|
-
|
|
1811
|
+
shortsTruncated || genericFactoryRows.length > cap
|
|
1548
1812
|
? `Showing up to ${cap} per kind; more exist — raise limit or use get_status.`
|
|
1549
1813
|
: undefined,
|
|
1550
1814
|
});
|
|
@@ -2570,7 +2834,7 @@ registerTool(
|
|
|
2570
2834
|
"use rerender_short instead: it re-applies the overlay over the existing footage for 0 credits. " +
|
|
2571
2835
|
"Safe to call again: a " +
|
|
2572
2836
|
"duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " +
|
|
2573
|
-
"short can be re-generated. If the draft's headline/subheadline/text_beats are all blank the server " +
|
|
2837
|
+
"or cancelled short can be re-generated. If the draft's headline/subheadline/text_beats are all blank the server " +
|
|
2574
2838
|
"auto-writes the headline + caption beats before rendering (pass skip_auto_text:true for a deliberately " +
|
|
2575
2839
|
"bare clip). A transient 5xx is reconciled against the project state: accepted work is returned as " +
|
|
2576
2840
|
"in-progress, while an unchanged draft is retried once. Then poll with get_status or " +
|
|
@@ -2591,17 +2855,38 @@ registerTool(
|
|
|
2591
2855
|
annotations: { title: "Generate short", ...WRITE },
|
|
2592
2856
|
},
|
|
2593
2857
|
tool(async (args: { slug: string; skip_auto_text?: boolean }, client) => {
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2858
|
+
const observedStart = await observedGenerationAction(
|
|
2859
|
+
client,
|
|
2860
|
+
"short",
|
|
2861
|
+
args.slug,
|
|
2862
|
+
"start_generation",
|
|
2863
|
+
{
|
|
2864
|
+
workflows: ["short.generation"],
|
|
2865
|
+
statuses: ["idle", "queued", "running", "failed", "cancelled"],
|
|
2866
|
+
allowIdleAuthority: true,
|
|
2867
|
+
recoverActive: true,
|
|
2868
|
+
},
|
|
2869
|
+
);
|
|
2870
|
+
if (observedStart.activeReplay) {
|
|
2871
|
+
return ok(normalizeStatus("short", args.slug, observedStart.state));
|
|
2872
|
+
}
|
|
2873
|
+
// This observed attempt owns one stable request key. A transport retry
|
|
2874
|
+
// after a lost response sees the active run above; a genuine later rerun
|
|
2875
|
+
// observes a different terminal run/revision and derives a fresh key.
|
|
2876
|
+
const requestKey = idemKey(
|
|
2877
|
+
"generate-short",
|
|
2878
|
+
args.slug,
|
|
2879
|
+
args.skip_auto_text ? "bare" : "auto-copy",
|
|
2880
|
+
observedStart.summary.run_id ?? "idle",
|
|
2881
|
+
String(observedStart.summary.revision ?? "idle"),
|
|
2882
|
+
);
|
|
2599
2883
|
const submit = () =>
|
|
2600
2884
|
client.post<{ data: unknown }>(
|
|
2601
2885
|
`/shorts/${args.slug}/generate`,
|
|
2602
2886
|
// Only send a body when the caller opts out of server auto-copy —
|
|
2603
2887
|
// the bare POST stays byte-identical to the pre-0.8.2 wire shape.
|
|
2604
2888
|
args.skip_auto_text ? { skip_auto_text: true } : undefined,
|
|
2889
|
+
requestKey,
|
|
2605
2890
|
);
|
|
2606
2891
|
|
|
2607
2892
|
try {
|
|
@@ -2611,15 +2896,10 @@ registerTool(
|
|
|
2611
2896
|
} catch (e) {
|
|
2612
2897
|
const statusCode = (e as { status?: number }).status;
|
|
2613
2898
|
if (statusCode === 409) {
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
terminal: false,
|
|
2619
|
-
ready: false,
|
|
2620
|
-
video_url: null,
|
|
2621
|
-
error: null,
|
|
2622
|
-
});
|
|
2899
|
+
// A conflict says only that the POST did not start new work. Return
|
|
2900
|
+
// the validated server-owned lifecycle instead of fabricating a
|
|
2901
|
+
// summary-less "processing" state from the HTTP code.
|
|
2902
|
+
return ok(await fetchStatus(client, "short", args.slug));
|
|
2623
2903
|
}
|
|
2624
2904
|
|
|
2625
2905
|
if (statusCode !== undefined && statusCode >= 500) {
|
|
@@ -2632,12 +2912,20 @@ registerTool(
|
|
|
2632
2912
|
throw e;
|
|
2633
2913
|
}
|
|
2634
2914
|
|
|
2635
|
-
//
|
|
2636
|
-
//
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2640
|
-
|
|
2915
|
+
// Any canonical active summary makes a blind retry unsafe: it may be
|
|
2916
|
+
// the just-accepted debit or work that already owned the product.
|
|
2917
|
+
if (
|
|
2918
|
+
reconciled.generation_summary?.status === "queued" ||
|
|
2919
|
+
reconciled.generation_summary?.status === "running"
|
|
2920
|
+
) {
|
|
2921
|
+
return ok(reconciled);
|
|
2922
|
+
}
|
|
2923
|
+
if (
|
|
2924
|
+
reconciled.generation_summary?.status !== "idle" ||
|
|
2925
|
+
!reconciled.available_actions?.includes("start_generation")
|
|
2926
|
+
) {
|
|
2927
|
+
throw e;
|
|
2928
|
+
}
|
|
2641
2929
|
|
|
2642
2930
|
// A Short launch changes the durable factory out of draft in the
|
|
2643
2931
|
// same transaction as the debit. Still-draft therefore proves the
|
|
@@ -2649,15 +2937,7 @@ registerTool(
|
|
|
2649
2937
|
);
|
|
2650
2938
|
} catch (retryError) {
|
|
2651
2939
|
if ((retryError as { status?: number }).status === 409) {
|
|
2652
|
-
return ok(
|
|
2653
|
-
kind: "short",
|
|
2654
|
-
slug: args.slug,
|
|
2655
|
-
stage: "processing",
|
|
2656
|
-
terminal: false,
|
|
2657
|
-
ready: false,
|
|
2658
|
-
video_url: null,
|
|
2659
|
-
error: null,
|
|
2660
|
-
});
|
|
2940
|
+
return ok(await fetchStatus(client, "short", args.slug));
|
|
2661
2941
|
}
|
|
2662
2942
|
throw retryError;
|
|
2663
2943
|
}
|
|
@@ -2667,55 +2947,6 @@ registerTool(
|
|
|
2667
2947
|
}),
|
|
2668
2948
|
);
|
|
2669
2949
|
|
|
2670
|
-
registerTool(
|
|
2671
|
-
"cancel_short_generation",
|
|
2672
|
-
{
|
|
2673
|
-
title: "Cancel a running short generation",
|
|
2674
|
-
description:
|
|
2675
|
-
"Stops the caller-observed paid Short attempt, fences late provider callbacks, and refunds the 15-credit " +
|
|
2676
|
-
"generation exactly once. Pass generation_attempt from get_status so a delayed command cannot cancel a " +
|
|
2677
|
-
"newer run on the same draft. The draft is preserved for editing and regeneration. Safe to repeat with " +
|
|
2678
|
-
"the same attempt after a successful cancellation. Scope: video:generate.",
|
|
2679
|
-
inputSchema: {
|
|
2680
|
-
slug: z.string().describe("Slug of the processing short"),
|
|
2681
|
-
generation_attempt: z
|
|
2682
|
-
.number()
|
|
2683
|
-
.int()
|
|
2684
|
-
.min(0)
|
|
2685
|
-
.describe("generation_attempt from the latest get_status response"),
|
|
2686
|
-
},
|
|
2687
|
-
annotations: {
|
|
2688
|
-
title: "Cancel short generation",
|
|
2689
|
-
readOnlyHint: false,
|
|
2690
|
-
destructiveHint: true,
|
|
2691
|
-
openWorldHint: true,
|
|
2692
|
-
idempotentHint: true,
|
|
2693
|
-
},
|
|
2694
|
-
},
|
|
2695
|
-
tool(async (args: { slug: string; generation_attempt: number }, client) => {
|
|
2696
|
-
const res = await client.post<{ data: unknown }>(
|
|
2697
|
-
`/shorts/${args.slug}/cancel`,
|
|
2698
|
-
{ generation_attempt: args.generation_attempt },
|
|
2699
|
-
undefined,
|
|
2700
|
-
);
|
|
2701
|
-
const data = asRecord(asRecord(res).data ?? res);
|
|
2702
|
-
return ok({
|
|
2703
|
-
...data,
|
|
2704
|
-
slug: args.slug,
|
|
2705
|
-
charged: false,
|
|
2706
|
-
// The cancel endpoint returns the short's STATE, not a refund receipt,
|
|
2707
|
-
// so don't assert refunded:true on every call — a repeat cancel is a
|
|
2708
|
-
// no-op that mints no new credits. Echo the server's own flag if it ever
|
|
2709
|
-
// sends one; otherwise describe the honest idempotent settlement.
|
|
2710
|
-
refund:
|
|
2711
|
-
"refunded" in data
|
|
2712
|
-
? data.refunded
|
|
2713
|
-
: "settled (idempotent — no new credits minted on repeat)",
|
|
2714
|
-
next: "Edit the preserved draft, then call generate_short when ready.",
|
|
2715
|
-
});
|
|
2716
|
-
}),
|
|
2717
|
-
);
|
|
2718
|
-
|
|
2719
2950
|
registerTool(
|
|
2720
2951
|
"rerender_short",
|
|
2721
2952
|
{
|
|
@@ -3205,7 +3436,8 @@ registerTool(
|
|
|
3205
3436
|
"and prices autopilot (server-orchestrated " +
|
|
3206
3437
|
"scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " +
|
|
3207
3438
|
"Without max_credits it STOPS after the free draft/configuration and returns the live estimate. It starts " +
|
|
3208
|
-
"the paid pipeline only when max_credits is supplied
|
|
3439
|
+
"the paid pipeline only when max_credits is supplied, covers the estimate, and the canonical generation_summary " +
|
|
3440
|
+
"advertises start_generation. Then poll with get_status / " +
|
|
3209
3441
|
"wait_for_completion (kind=editor). Prefer make_video for a prompt-only one-shot. " +
|
|
3210
3442
|
"A product image is analyzed into semantic project context and sent as a product-identity reference to every " +
|
|
3211
3443
|
"generated Editor scene. The exact photo is also linked as the closing image unless a separate closing image " +
|
|
@@ -3535,6 +3767,26 @@ registerTool(
|
|
|
3535
3767
|
});
|
|
3536
3768
|
}
|
|
3537
3769
|
|
|
3770
|
+
// POST /editor is idempotent and may return an existing terminal draft.
|
|
3771
|
+
// Re-read the canonical product authority after the quote and immediately
|
|
3772
|
+
// before the chargeable launch. Legacy lifecycle columns never authorize
|
|
3773
|
+
// this write, and a completed project requires the explicit restart flow.
|
|
3774
|
+
const observedStart = await observedGenerationAction(
|
|
3775
|
+
client,
|
|
3776
|
+
"editor",
|
|
3777
|
+
slug,
|
|
3778
|
+
"start_generation",
|
|
3779
|
+
{
|
|
3780
|
+
workflows: ["editor.autopilot"],
|
|
3781
|
+
statuses: ["idle", "failed", "cancelled"],
|
|
3782
|
+
allowIdleAuthority: true,
|
|
3783
|
+
},
|
|
3784
|
+
);
|
|
3785
|
+
const startState = startStateDiscriminator(
|
|
3786
|
+
"editor",
|
|
3787
|
+
observedStart.state,
|
|
3788
|
+
);
|
|
3789
|
+
|
|
3538
3790
|
const started = await client.post<{ data: unknown }>(
|
|
3539
3791
|
`/editor/${slug}/autopilot`,
|
|
3540
3792
|
{
|
|
@@ -3542,13 +3794,14 @@ registerTool(
|
|
|
3542
3794
|
segments_count: args.segments_count,
|
|
3543
3795
|
},
|
|
3544
3796
|
// Fold the create-affecting params into the start key so it tracks
|
|
3545
|
-
// inputs, not just the slug. Self-consistent
|
|
3546
|
-
// is compared against retries of *this* tool
|
|
3797
|
+
// inputs plus the observed attempt, not just the slug. Self-consistent
|
|
3798
|
+
// per-tool only: this key is compared against retries of *this* tool
|
|
3799
|
+
// on *this* slug. The
|
|
3547
3800
|
// field set/order differs from make_video / start_autopilot on
|
|
3548
3801
|
// purpose (server cache is path+key scoped, slug is unique per
|
|
3549
3802
|
// draft), so the keys are not interchangeable across tools.
|
|
3550
3803
|
// Composition lives in core.ts, shared with the golden-pin test.
|
|
3551
|
-
editorAdAutopilotKey(slug, args),
|
|
3804
|
+
editorAdAutopilotKey(slug, args, startState),
|
|
3552
3805
|
);
|
|
3553
3806
|
const status = normalizeStatus("editor", slug, asRecord(started).data);
|
|
3554
3807
|
return ok({
|
|
@@ -3578,10 +3831,10 @@ registerTool(
|
|
|
3578
3831
|
"EXISTING editor project/draft. Without max_credits it returns the live quote and charges nothing. Supply an " +
|
|
3579
3832
|
"explicit max_credits cap to authorize launch; the tool refuses if pricing is unavailable, the estimate exceeds " +
|
|
3580
3833
|
"the cap, or the balance is insufficient. Each authorized call is a genuine start attempt (like tapping Launch in the app): " +
|
|
3581
|
-
"
|
|
3582
|
-
"or double-
|
|
3583
|
-
"
|
|
3584
|
-
"
|
|
3834
|
+
"the tool re-reads generation_summary and starts only when start_generation is advertised, so an active run " +
|
|
3835
|
+
"cannot be double-started or double-charged. Resolve any advertised retry/input action first. For a completed " +
|
|
3836
|
+
"Autopilot run, set restart:true to quote and build a fresh creative run; restart:true is rejected for every " +
|
|
3837
|
+
"non-completed state. Then poll with " +
|
|
3585
3838
|
'wait_for_completion({ slug, kind: "editor" }).',
|
|
3586
3839
|
inputSchema: {
|
|
3587
3840
|
slug: z.string().describe("Editor project slug"),
|
|
@@ -3695,17 +3948,47 @@ registerTool(
|
|
|
3695
3948
|
});
|
|
3696
3949
|
}
|
|
3697
3950
|
|
|
3951
|
+
const observedStart = await observedGenerationAction(
|
|
3952
|
+
client,
|
|
3953
|
+
"editor",
|
|
3954
|
+
slug,
|
|
3955
|
+
"start_generation",
|
|
3956
|
+
{
|
|
3957
|
+
workflows: ["editor.autopilot"],
|
|
3958
|
+
statuses: ["idle", "failed", "cancelled", "completed"],
|
|
3959
|
+
allowIdleAuthority: true,
|
|
3960
|
+
},
|
|
3961
|
+
);
|
|
3962
|
+
if (
|
|
3963
|
+
observedStart.summary.status === "completed" &&
|
|
3964
|
+
args.restart !== true
|
|
3965
|
+
) {
|
|
3966
|
+
throw new Error(
|
|
3967
|
+
"A completed Autopilot project requires restart:true for the advertised fresh start.",
|
|
3968
|
+
);
|
|
3969
|
+
}
|
|
3970
|
+
if (
|
|
3971
|
+
observedStart.summary.status !== "completed" &&
|
|
3972
|
+
args.restart === true
|
|
3973
|
+
) {
|
|
3974
|
+
throw new Error(
|
|
3975
|
+
"restart:true is valid only for a completed Autopilot generation; refresh before continuing.",
|
|
3976
|
+
);
|
|
3977
|
+
}
|
|
3978
|
+
|
|
3698
3979
|
const started = await client.post<{ data: unknown }>(
|
|
3699
3980
|
`/editor/${slug}/autopilot`,
|
|
3700
3981
|
{ ...overrides, max_credits: args.max_credits },
|
|
3701
|
-
//
|
|
3702
|
-
//
|
|
3703
|
-
//
|
|
3704
|
-
|
|
3705
|
-
|
|
3706
|
-
|
|
3707
|
-
|
|
3708
|
-
|
|
3982
|
+
// The observed canonical run/revision (or stable idle identity) rotates
|
|
3983
|
+
// after every accepted attempt. This keeps transport retries stable while
|
|
3984
|
+
// preventing an older 24h receipt from replaying over a later fresh start.
|
|
3985
|
+
idemKey(
|
|
3986
|
+
"autopilot",
|
|
3987
|
+
slug,
|
|
3988
|
+
observedStart.summary.run_id ?? "idle",
|
|
3989
|
+
String(observedStart.summary.revision ?? "idle"),
|
|
3990
|
+
JSON.stringify({ ...overrides, max_credits: args.max_credits }),
|
|
3991
|
+
),
|
|
3709
3992
|
);
|
|
3710
3993
|
const status = normalizeStatus("editor", slug, asRecord(started).data);
|
|
3711
3994
|
return ok({
|
|
@@ -3728,12 +4011,13 @@ registerTool(
|
|
|
3728
4011
|
{
|
|
3729
4012
|
title: "Retry a paused editor batch",
|
|
3730
4013
|
description:
|
|
3731
|
-
"
|
|
3732
|
-
"scene and
|
|
3733
|
-
"paid.
|
|
4014
|
+
"Runs only when generation_summary advertises retry_generation for the observed Editor batch, then atomically re-queues its exact failed " +
|
|
4015
|
+
"scene, preview, render, or delivery step and resumes the prepaid batch. This spends 0 additional credits: the batch generation was already " +
|
|
4016
|
+
"paid. When batch_generation_failed_position names a scene, you may REPAIR that one failed scene before retrying: while paused_for_retry, set_segment_prompt can " +
|
|
3734
4017
|
"edit that scene's prompt (and use_previous_frame) — but only for the exact failed position the server " +
|
|
3735
4018
|
"names in batch_generation_failed_position (from get_editor); the rest of the paused batch stays " +
|
|
3736
|
-
"mutation-locked.
|
|
4019
|
+
"mutation-locked. A null position identifies positionless render/delivery work and is not authoring permission. " +
|
|
4020
|
+
"Explicit paused scene retries are capped — once the failure code is free_retries_exhausted a " +
|
|
3737
4021
|
"further retry is rejected (start a fresh run instead). It does not authorize or restart a failed parent " +
|
|
3738
4022
|
"Autopilot run; after retrying, wait for the batch " +
|
|
3739
4023
|
"to settle. If the parent is then terminal, call start_autopilot without max_credits for a fresh quote and " +
|
|
@@ -3747,18 +4031,26 @@ registerTool(
|
|
|
3747
4031
|
},
|
|
3748
4032
|
},
|
|
3749
4033
|
tool(async (args: { slug: string }, client) => {
|
|
3750
|
-
const command = await observedEditorBatchCommand(
|
|
3751
|
-
|
|
3752
|
-
|
|
4034
|
+
const command = await observedEditorBatchCommand(
|
|
4035
|
+
client,
|
|
4036
|
+
args.slug,
|
|
4037
|
+
"retry_generation",
|
|
4038
|
+
);
|
|
3753
4039
|
const res = await client.post<{ data: unknown }>(
|
|
3754
|
-
`/
|
|
4040
|
+
`/editor/${args.slug}/generation/batch/retry`,
|
|
3755
4041
|
command,
|
|
4042
|
+
idemKey(
|
|
4043
|
+
"editor-batch-retry",
|
|
4044
|
+
args.slug,
|
|
4045
|
+
command.generation_run_id,
|
|
4046
|
+
String(command.generation_revision),
|
|
4047
|
+
),
|
|
3756
4048
|
);
|
|
3757
4049
|
return ok({
|
|
3758
4050
|
...asRecord(asRecord(res).data ?? res),
|
|
3759
4051
|
slug: args.slug,
|
|
3760
4052
|
charged: false,
|
|
3761
|
-
note: "The failed
|
|
4053
|
+
note: "The exact failed batch step is queued and the prepaid batch is running. The route returns only the updated generation_summary. Wait for it to settle; if the parent Autopilot run is then terminal, quote start_autopilot again and relaunch only with an approved max_credits cap.",
|
|
3762
4054
|
next: "wait_for_completion",
|
|
3763
4055
|
});
|
|
3764
4056
|
}),
|
|
@@ -3769,10 +4061,11 @@ registerTool(
|
|
|
3769
4061
|
{
|
|
3770
4062
|
title: "Cancel an editor batch",
|
|
3771
4063
|
description:
|
|
3772
|
-
"Cancels
|
|
3773
|
-
"
|
|
3774
|
-
"
|
|
3775
|
-
"0 additional credits.
|
|
4064
|
+
"Cancels the current editor batch — pay-as-you-go or prepaid — while it is active or failed and parked for an in-place retry, including a batch parked " +
|
|
4065
|
+
"needs-input on a scene review. Use this to abandon a permanent failure and unlock the project without paying for another provider retry. Completed scenes remain settled; charges for scenes that never delivered are " +
|
|
4066
|
+
"refunded on settlement. Spends " +
|
|
4067
|
+
"0 additional credits. The raw REST route requires Idempotency-Key; reuse one only for transport retries of " +
|
|
4068
|
+
"the same observed batch command. Scope: video:generate.",
|
|
3776
4069
|
inputSchema: { slug: z.string().describe("Editor project slug") },
|
|
3777
4070
|
annotations: {
|
|
3778
4071
|
title: "Cancel editor batch",
|
|
@@ -3783,13 +4076,15 @@ registerTool(
|
|
|
3783
4076
|
},
|
|
3784
4077
|
},
|
|
3785
4078
|
tool(async (args: { slug: string }, client) => {
|
|
3786
|
-
const command = await observedEditorBatchCommand(
|
|
3787
|
-
|
|
3788
|
-
|
|
3789
|
-
|
|
4079
|
+
const command = await observedEditorBatchCommand(
|
|
4080
|
+
client,
|
|
4081
|
+
args.slug,
|
|
4082
|
+
"cancel_generation",
|
|
4083
|
+
);
|
|
3790
4084
|
const res = await client.post<{ data: unknown }>(
|
|
3791
|
-
`/
|
|
3792
|
-
command,
|
|
4085
|
+
`/editor/${args.slug}/generation/batch/cancel`,
|
|
4086
|
+
{ generation_run_id: command.generation_run_id },
|
|
4087
|
+
idemKey("cancel-editor-batch", args.slug, command.generation_run_id),
|
|
3793
4088
|
);
|
|
3794
4089
|
return ok({
|
|
3795
4090
|
...asRecord(asRecord(res).data ?? res),
|
|
@@ -3800,50 +4095,171 @@ registerTool(
|
|
|
3800
4095
|
}),
|
|
3801
4096
|
);
|
|
3802
4097
|
|
|
3803
|
-
type EditorBatchCommandStatus = "preparing" | "paused_for_retry";
|
|
3804
|
-
|
|
3805
4098
|
/**
|
|
3806
|
-
* Reads the
|
|
3807
|
-
* recovery command. The API validates
|
|
3808
|
-
* so a replacement run or phase transition
|
|
3809
|
-
* safe `batch_command_stale` conflict rather than acting on newer work.
|
|
4099
|
+
* Reads the canonical Editor batch run fence immediately before issuing a
|
|
4100
|
+
* recovery command. The API validates the run and revision under the run lock,
|
|
4101
|
+
* so a replacement run or phase transition is a safe stale-command conflict.
|
|
3810
4102
|
*/
|
|
3811
4103
|
async function observedEditorBatchCommand(
|
|
3812
4104
|
client: HubfluencerClient,
|
|
3813
4105
|
slug: string,
|
|
3814
|
-
|
|
4106
|
+
actionType: "retry_generation" | "cancel_generation",
|
|
3815
4107
|
): Promise<{
|
|
3816
|
-
|
|
3817
|
-
|
|
4108
|
+
generation_run_id: string;
|
|
4109
|
+
generation_revision: number;
|
|
3818
4110
|
}> {
|
|
3819
|
-
const
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
4111
|
+
const observed = await observedGenerationAction(
|
|
4112
|
+
client,
|
|
4113
|
+
"editor",
|
|
4114
|
+
slug,
|
|
4115
|
+
actionType,
|
|
4116
|
+
{
|
|
4117
|
+
workflows: ["editor.batch"],
|
|
4118
|
+
statuses:
|
|
4119
|
+
actionType === "retry_generation"
|
|
4120
|
+
? ["failed", "needs_input"]
|
|
4121
|
+
: ["running", "needs_input", "failed"],
|
|
4122
|
+
},
|
|
4123
|
+
);
|
|
4124
|
+
const { summary } = observed;
|
|
4125
|
+
const runId = summary.run_id;
|
|
4126
|
+
if (typeof runId !== "string" || runId.trim().length === 0) {
|
|
3826
4127
|
throw new Error(
|
|
3827
|
-
|
|
4128
|
+
"Editor generation summary did not include a canonical run id; refresh status before continuing.",
|
|
3828
4129
|
);
|
|
3829
4130
|
}
|
|
3830
4131
|
|
|
3831
|
-
|
|
3832
|
-
if (
|
|
3833
|
-
runId !== null &&
|
|
3834
|
-
(typeof runId !== "string" || runId.trim().length === 0)
|
|
3835
|
-
) {
|
|
4132
|
+
if (summary.workflow !== "editor.batch") {
|
|
3836
4133
|
throw new Error(
|
|
3837
|
-
"
|
|
4134
|
+
"The current generation workflow is not the observed Editor batch; refresh before continuing.",
|
|
3838
4135
|
);
|
|
3839
4136
|
}
|
|
3840
4137
|
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
4138
|
+
const command: {
|
|
4139
|
+
generation_run_id: string;
|
|
4140
|
+
generation_revision: number;
|
|
4141
|
+
} = {
|
|
4142
|
+
generation_run_id: runId,
|
|
4143
|
+
generation_revision: summary.revision as number,
|
|
3844
4144
|
};
|
|
4145
|
+
|
|
4146
|
+
return command;
|
|
3845
4147
|
}
|
|
3846
4148
|
|
|
4149
|
+
registerTool(
|
|
4150
|
+
"provide_generation_input",
|
|
4151
|
+
{
|
|
4152
|
+
title: "Resolve required generation input",
|
|
4153
|
+
description:
|
|
4154
|
+
"Resumes the exact Editor or Short needs-input run advertised by generation_summary. " +
|
|
4155
|
+
"Use action=edit_required_input with a non-empty input object, or action=review_spend_cap " +
|
|
4156
|
+
"with a higher max_credits value. The tool re-reads and sends the current run, revision, " +
|
|
4157
|
+
"and blocking-step fences; stale actions fail without mutating newer work.",
|
|
4158
|
+
inputSchema: {
|
|
4159
|
+
kind: z.enum(["editor", "short"]),
|
|
4160
|
+
slug: z.string().describe("Editor or Short project slug"),
|
|
4161
|
+
action: z.enum(["edit_required_input", "review_spend_cap"]),
|
|
4162
|
+
input: z
|
|
4163
|
+
.record(z.string(), z.unknown())
|
|
4164
|
+
.optional()
|
|
4165
|
+
.describe("Required product input for edit_required_input"),
|
|
4166
|
+
max_credits: z
|
|
4167
|
+
.number()
|
|
4168
|
+
.int()
|
|
4169
|
+
.positive()
|
|
4170
|
+
.max(1_000_000)
|
|
4171
|
+
.optional()
|
|
4172
|
+
.describe("Higher approved cap for review_spend_cap"),
|
|
4173
|
+
},
|
|
4174
|
+
annotations: {
|
|
4175
|
+
title: "Resolve generation input",
|
|
4176
|
+
...WRITE,
|
|
4177
|
+
idempotentHint: true,
|
|
4178
|
+
},
|
|
4179
|
+
},
|
|
4180
|
+
tool(
|
|
4181
|
+
async (
|
|
4182
|
+
args: {
|
|
4183
|
+
kind: ProductKind;
|
|
4184
|
+
slug: string;
|
|
4185
|
+
action: "edit_required_input" | "review_spend_cap";
|
|
4186
|
+
input?: Record<string, unknown>;
|
|
4187
|
+
max_credits?: number;
|
|
4188
|
+
},
|
|
4189
|
+
client,
|
|
4190
|
+
) => {
|
|
4191
|
+
if (
|
|
4192
|
+
args.action === "edit_required_input" &&
|
|
4193
|
+
(!args.input || Object.keys(args.input).length === 0)
|
|
4194
|
+
) {
|
|
4195
|
+
return fail("edit_required_input requires a non-empty input object.");
|
|
4196
|
+
}
|
|
4197
|
+
if (
|
|
4198
|
+
args.action === "review_spend_cap" &&
|
|
4199
|
+
args.max_credits === undefined
|
|
4200
|
+
) {
|
|
4201
|
+
return fail("review_spend_cap requires max_credits.");
|
|
4202
|
+
}
|
|
4203
|
+
|
|
4204
|
+
const observed = await observedGenerationAction(
|
|
4205
|
+
client,
|
|
4206
|
+
args.kind,
|
|
4207
|
+
args.slug,
|
|
4208
|
+
args.action,
|
|
4209
|
+
{ statuses: ["needs_input"], requireStep: true },
|
|
4210
|
+
);
|
|
4211
|
+
const runId = observed.summary.run_id as string;
|
|
4212
|
+
const revision = observed.summary.revision as number;
|
|
4213
|
+
const stepKey = observed.stepKey as string;
|
|
4214
|
+
const body: Record<string, unknown> = {
|
|
4215
|
+
generation_run_id: runId,
|
|
4216
|
+
generation_revision: revision,
|
|
4217
|
+
step_key: stepKey,
|
|
4218
|
+
};
|
|
4219
|
+
if (args.action === "edit_required_input") body.input = args.input;
|
|
4220
|
+
else body.max_credits = args.max_credits;
|
|
4221
|
+
|
|
4222
|
+
const endpoint =
|
|
4223
|
+
args.action === "edit_required_input"
|
|
4224
|
+
? "generation/input"
|
|
4225
|
+
: "generation/spend-cap";
|
|
4226
|
+
const productPath = args.kind === "short" ? "shorts" : "editor";
|
|
4227
|
+
const requestKey = idemKey(
|
|
4228
|
+
"provide-generation-input",
|
|
4229
|
+
args.kind,
|
|
4230
|
+
args.slug,
|
|
4231
|
+
args.action,
|
|
4232
|
+
runId,
|
|
4233
|
+
String(revision),
|
|
4234
|
+
stepKey,
|
|
4235
|
+
JSON.stringify(args.input ?? {}),
|
|
4236
|
+
String(args.max_credits ?? ""),
|
|
4237
|
+
);
|
|
4238
|
+
const response = await client.post<{ data: unknown }>(
|
|
4239
|
+
`/${productPath}/${args.slug}/${endpoint}`,
|
|
4240
|
+
body,
|
|
4241
|
+
requestKey,
|
|
4242
|
+
);
|
|
4243
|
+
const resumedSummary = generationSummarySchema.safeParse(
|
|
4244
|
+
asRecord(asRecord(response).data).generation_summary,
|
|
4245
|
+
);
|
|
4246
|
+
if (!resumedSummary.success) {
|
|
4247
|
+
throw new Error(
|
|
4248
|
+
`The Hubfluencer ${args.kind} recovery response did not contain a valid generation_summary. The idempotent command may already have been accepted; re-read status before continuing.`,
|
|
4249
|
+
);
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4252
|
+
return ok({
|
|
4253
|
+
slug: args.slug,
|
|
4254
|
+
kind: args.kind,
|
|
4255
|
+
action: args.action,
|
|
4256
|
+
generation_summary: resumedSummary.data,
|
|
4257
|
+
next: "wait_for_completion",
|
|
4258
|
+
});
|
|
4259
|
+
},
|
|
4260
|
+
),
|
|
4261
|
+
);
|
|
4262
|
+
|
|
3847
4263
|
// ── AI assists (free daily quota) ───────────────────────────────────────────
|
|
3848
4264
|
|
|
3849
4265
|
registerTool(
|
|
@@ -4031,6 +4447,7 @@ registerTool(
|
|
|
4031
4447
|
function conciseEditorState(value: unknown): Record<string, unknown> {
|
|
4032
4448
|
const data = asRecord(value);
|
|
4033
4449
|
const keys = [
|
|
4450
|
+
"generation_summary",
|
|
4034
4451
|
"slug",
|
|
4035
4452
|
"status",
|
|
4036
4453
|
"autopilot_status",
|
|
@@ -4086,11 +4503,12 @@ registerTool(
|
|
|
4086
4503
|
{
|
|
4087
4504
|
title: "Get editor state (review step)",
|
|
4088
4505
|
description:
|
|
4089
|
-
"Returns
|
|
4506
|
+
"Returns a concise editor review projection by default. Both formats validate and include the required generation_summary lifecycle authority. Pass response_format:'full' only when all authoring details are required.",
|
|
4090
4507
|
inputSchema: {
|
|
4091
4508
|
slug: z.string().describe("Editor project slug"),
|
|
4092
|
-
response_format: z.enum(["concise", "full"]).default("
|
|
4509
|
+
response_format: z.enum(["concise", "full"]).default("concise"),
|
|
4093
4510
|
},
|
|
4511
|
+
outputSchema: getEditorOutput,
|
|
4094
4512
|
annotations: { title: "Get editor", ...RO },
|
|
4095
4513
|
},
|
|
4096
4514
|
tool(
|
|
@@ -4099,9 +4517,23 @@ registerTool(
|
|
|
4099
4517
|
client,
|
|
4100
4518
|
) => {
|
|
4101
4519
|
const res = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
|
|
4102
|
-
const data = asRecord(res).data;
|
|
4520
|
+
const data = asRecord(asRecord(res).data);
|
|
4521
|
+
const summary = generationSummarySchema.safeParse(
|
|
4522
|
+
data.generation_summary,
|
|
4523
|
+
);
|
|
4524
|
+
if (!summary.success) {
|
|
4525
|
+
throw new Error(
|
|
4526
|
+
"The Hubfluencer Editor response did not contain a valid generation_summary. Refresh after the API and MCP contracts are upgraded together.",
|
|
4527
|
+
);
|
|
4528
|
+
}
|
|
4529
|
+
const authoritativeData = {
|
|
4530
|
+
...data,
|
|
4531
|
+
generation_summary: summary.data,
|
|
4532
|
+
};
|
|
4103
4533
|
return ok(
|
|
4104
|
-
args.response_format === "full"
|
|
4534
|
+
args.response_format === "full"
|
|
4535
|
+
? authoritativeData
|
|
4536
|
+
: conciseEditorState(authoritativeData),
|
|
4105
4537
|
);
|
|
4106
4538
|
},
|
|
4107
4539
|
),
|
|
@@ -4468,15 +4900,87 @@ registerTool(
|
|
|
4468
4900
|
annotations: { title: "Generate segment", ...WRITE, idempotentHint: false },
|
|
4469
4901
|
},
|
|
4470
4902
|
tool(async (args: { slug: string; segment_id: number | string }, client) => {
|
|
4903
|
+
const requestKey = idemKey(
|
|
4904
|
+
"generate-segment",
|
|
4905
|
+
args.slug,
|
|
4906
|
+
String(args.segment_id),
|
|
4907
|
+
randomUUID(),
|
|
4908
|
+
);
|
|
4471
4909
|
const res = await client.post<{ data: unknown }>(
|
|
4472
4910
|
`/editor/${args.slug}/segments/${String(args.segment_id)}/generate`,
|
|
4473
4911
|
undefined,
|
|
4474
|
-
|
|
4912
|
+
requestKey,
|
|
4475
4913
|
);
|
|
4476
4914
|
return ok(asRecord(res).data ?? res);
|
|
4477
4915
|
}),
|
|
4478
4916
|
);
|
|
4479
4917
|
|
|
4918
|
+
registerTool(
|
|
4919
|
+
"retry_segment_generation",
|
|
4920
|
+
{
|
|
4921
|
+
title: "Retry the current failed segment generation",
|
|
4922
|
+
description:
|
|
4923
|
+
"Retries the exact failed Editor scene step in place for 0 additional credits. This is only " +
|
|
4924
|
+
"available when generation_summary advertises retry_generation for editor.scene.generate or " +
|
|
4925
|
+
"editor.scene.regenerate. The tool re-reads and submits the current run, revision, and step fences, " +
|
|
4926
|
+
"so it cannot retry a stale attempt or a different current scene.",
|
|
4927
|
+
inputSchema: {
|
|
4928
|
+
slug: z.string().describe("Editor project slug"),
|
|
4929
|
+
segment_id: z
|
|
4930
|
+
.union([z.number(), z.string()])
|
|
4931
|
+
.describe("Failed current segment id from get_editor"),
|
|
4932
|
+
},
|
|
4933
|
+
annotations: {
|
|
4934
|
+
title: "Retry segment generation",
|
|
4935
|
+
...WRITE,
|
|
4936
|
+
idempotentHint: true,
|
|
4937
|
+
},
|
|
4938
|
+
},
|
|
4939
|
+
tool(async (args: { slug: string; segment_id: number | string }, client) => {
|
|
4940
|
+
const observed = await observedGenerationAction(
|
|
4941
|
+
client,
|
|
4942
|
+
"editor",
|
|
4943
|
+
args.slug,
|
|
4944
|
+
"retry_generation",
|
|
4945
|
+
{
|
|
4946
|
+
workflows: ["editor.scene.generate", "editor.scene.regenerate"],
|
|
4947
|
+
statuses: ["failed"],
|
|
4948
|
+
requireStep: true,
|
|
4949
|
+
},
|
|
4950
|
+
);
|
|
4951
|
+
const runId = observed.summary.run_id as string;
|
|
4952
|
+
const revision = observed.summary.revision as number;
|
|
4953
|
+
const stepKey = observed.stepKey as string;
|
|
4954
|
+
const requestKey = idemKey(
|
|
4955
|
+
"retry-segment-generation",
|
|
4956
|
+
args.slug,
|
|
4957
|
+
String(args.segment_id),
|
|
4958
|
+
runId,
|
|
4959
|
+
String(revision),
|
|
4960
|
+
stepKey,
|
|
4961
|
+
);
|
|
4962
|
+
const response = await client.post<{ data: unknown }>(
|
|
4963
|
+
`/editor/${args.slug}/segments/${String(args.segment_id)}/retry`,
|
|
4964
|
+
{
|
|
4965
|
+
generation_run_id: runId,
|
|
4966
|
+
generation_revision: revision,
|
|
4967
|
+
step_key: stepKey,
|
|
4968
|
+
},
|
|
4969
|
+
requestKey,
|
|
4970
|
+
);
|
|
4971
|
+
const data = asRecord(asRecord(response).data);
|
|
4972
|
+
const retrySummary = generationSummarySchema.safeParse(
|
|
4973
|
+
data.generation_summary,
|
|
4974
|
+
);
|
|
4975
|
+
if (!retrySummary.success) {
|
|
4976
|
+
throw new Error(
|
|
4977
|
+
"The Hubfluencer Editor scene-retry response did not contain a valid generation_summary. The idempotent retry may already have been accepted; re-read the Editor before continuing.",
|
|
4978
|
+
);
|
|
4979
|
+
}
|
|
4980
|
+
return ok({ ...data, generation_summary: retrySummary.data });
|
|
4981
|
+
}),
|
|
4982
|
+
);
|
|
4983
|
+
|
|
4480
4984
|
registerTool(
|
|
4481
4985
|
"cancel_segment_generation",
|
|
4482
4986
|
{
|
|
@@ -4486,7 +4990,8 @@ registerTool(
|
|
|
4486
4990
|
"prevents a delayed command from cancelling a newer attempt. The token is returned by get_editor. " +
|
|
4487
4991
|
"Refunds that scene's charge idempotently (a free retry creates no refund). Use cancel_editor_batch or " +
|
|
4488
4992
|
"cancel_autopilot for workflow-owned scenes. After cancellation, edit the prompt if needed and call " +
|
|
4489
|
-
"generate_segment again.
|
|
4993
|
+
"generate_segment again. The raw REST route requires Idempotency-Key; reuse one only for transport retries " +
|
|
4994
|
+
"of this exact fenced cancellation. Scope: video:generate.",
|
|
4490
4995
|
inputSchema: {
|
|
4491
4996
|
slug: z.string().describe("Editor project slug"),
|
|
4492
4997
|
segment_id: z
|
|
@@ -4514,10 +5019,41 @@ registerTool(
|
|
|
4514
5019
|
},
|
|
4515
5020
|
client,
|
|
4516
5021
|
) => {
|
|
5022
|
+
const observed = await observedGenerationAction(
|
|
5023
|
+
client,
|
|
5024
|
+
"editor",
|
|
5025
|
+
args.slug,
|
|
5026
|
+
"cancel_generation",
|
|
5027
|
+
{
|
|
5028
|
+
workflows: ["editor.scene.generate", "editor.scene.regenerate"],
|
|
5029
|
+
statuses: ["queued", "running"],
|
|
5030
|
+
},
|
|
5031
|
+
);
|
|
5032
|
+
const segments = Array.isArray(observed.state.segments)
|
|
5033
|
+
? observed.state.segments.map(asRecord)
|
|
5034
|
+
: [];
|
|
5035
|
+
const segment = segments.find(
|
|
5036
|
+
(candidate) => String(candidate.id) === String(args.segment_id),
|
|
5037
|
+
);
|
|
5038
|
+
if (
|
|
5039
|
+
!segment ||
|
|
5040
|
+
segment.generation_claim_token !== args.generation_claim_token
|
|
5041
|
+
) {
|
|
5042
|
+
throw new Error(
|
|
5043
|
+
"The supplied generation_claim_token does not match the scene owned by the authoritative generation action; refresh get_editor before cancelling.",
|
|
5044
|
+
);
|
|
5045
|
+
}
|
|
4517
5046
|
const res = await client.post<{ data: unknown }>(
|
|
4518
5047
|
`/editor/${args.slug}/segments/${String(args.segment_id)}/cancel`,
|
|
4519
5048
|
{ expected_generation_claim_token: args.generation_claim_token },
|
|
4520
|
-
|
|
5049
|
+
idemKey(
|
|
5050
|
+
"cancel-segment-generation",
|
|
5051
|
+
args.slug,
|
|
5052
|
+
String(args.segment_id),
|
|
5053
|
+
observed.summary.run_id as string,
|
|
5054
|
+
String(observed.summary.revision),
|
|
5055
|
+
args.generation_claim_token ?? "none",
|
|
5056
|
+
),
|
|
4521
5057
|
);
|
|
4522
5058
|
return ok({
|
|
4523
5059
|
...asRecord(asRecord(res).data ?? res),
|
|
@@ -4534,15 +5070,14 @@ registerTool(
|
|
|
4534
5070
|
{
|
|
4535
5071
|
title: "Cancel a running Autopilot run",
|
|
4536
5072
|
description:
|
|
4537
|
-
"Stops an in-flight Autopilot run
|
|
4538
|
-
"orchestration chain (scenario → scenes → narration → voice → music → render) advances no further.
|
|
4539
|
-
"this when Autopilot is still running and you want to halt it. Any scenes already charged stay charged and " +
|
|
5073
|
+
"Stops an in-flight Autopilot run only when generation_summary advertises cancel_generation for the " +
|
|
5074
|
+
"editor.autopilot workflow. The orchestration chain (scenario → scenes → narration → voice → music → render) advances no further. Any scenes already charged stay charged and " +
|
|
4540
5075
|
"all completed work (generated scenes, voice, music, a finished render) is kept — a step already enqueued " +
|
|
4541
|
-
"may still finish, but nothing new is started.
|
|
4542
|
-
"
|
|
4543
|
-
"
|
|
4544
|
-
"
|
|
4545
|
-
"
|
|
5076
|
+
"may still finish, but nothing new is started. Transport retries are idempotent for the same observed run; " +
|
|
5077
|
+
"if cancellation is no longer advertised the tool fails closed without POST. It does NOT itself refund or " +
|
|
5078
|
+
"re-run anything. For an advertised Editor batch cancellation use cancel_editor_batch; to stop one " +
|
|
5079
|
+
"individually-generated scene use cancel_segment_generation. The raw REST route requires Idempotency-Key. " +
|
|
5080
|
+
"Scope: video:generate.",
|
|
4546
5081
|
inputSchema: { slug: z.string().describe("Editor project slug") },
|
|
4547
5082
|
annotations: {
|
|
4548
5083
|
title: "Cancel autopilot",
|
|
@@ -4553,14 +5088,35 @@ registerTool(
|
|
|
4553
5088
|
},
|
|
4554
5089
|
},
|
|
4555
5090
|
tool(async (args: { slug: string }, client) => {
|
|
5091
|
+
const observed = await observedGenerationAction(
|
|
5092
|
+
client,
|
|
5093
|
+
"editor",
|
|
5094
|
+
args.slug,
|
|
5095
|
+
"cancel_generation",
|
|
5096
|
+
{
|
|
5097
|
+
workflows: ["editor.autopilot"],
|
|
5098
|
+
statuses: ["queued", "running"],
|
|
5099
|
+
},
|
|
5100
|
+
);
|
|
5101
|
+
const receiptKey = idemKey(
|
|
5102
|
+
"cancel-autopilot",
|
|
5103
|
+
args.slug,
|
|
5104
|
+
observed.summary.run_id ?? randomUUID(),
|
|
5105
|
+
String(observed.summary.revision),
|
|
5106
|
+
);
|
|
5107
|
+
|
|
4556
5108
|
const res = await client.post<{ data: unknown }>(
|
|
4557
5109
|
`/editor/${args.slug}/autopilot/cancel`,
|
|
4558
|
-
|
|
4559
|
-
|
|
5110
|
+
{
|
|
5111
|
+
generation_run_id: observed.summary.run_id,
|
|
5112
|
+
},
|
|
5113
|
+
receiptKey,
|
|
4560
5114
|
);
|
|
4561
5115
|
return ok({
|
|
4562
5116
|
...asRecord(asRecord(res).data ?? res),
|
|
4563
5117
|
slug: args.slug,
|
|
5118
|
+
run_id: observed.summary.run_id,
|
|
5119
|
+
workflow: observed.summary.workflow,
|
|
4564
5120
|
charged: false,
|
|
4565
5121
|
next: "get_status",
|
|
4566
5122
|
});
|
|
@@ -4605,10 +5161,16 @@ registerTool(
|
|
|
4605
5161
|
args: { slug: string; segment_id: number | string; prompt?: string },
|
|
4606
5162
|
client,
|
|
4607
5163
|
) => {
|
|
5164
|
+
const requestKey = idemKey(
|
|
5165
|
+
"regenerate-segment",
|
|
5166
|
+
args.slug,
|
|
5167
|
+
String(args.segment_id),
|
|
5168
|
+
randomUUID(),
|
|
5169
|
+
);
|
|
4608
5170
|
const res = await client.post<{ data: unknown }>(
|
|
4609
5171
|
`/editor/${args.slug}/segments/${String(args.segment_id)}/regenerate`,
|
|
4610
5172
|
args.prompt !== undefined ? { prompt: args.prompt } : undefined,
|
|
4611
|
-
|
|
5173
|
+
requestKey,
|
|
4612
5174
|
);
|
|
4613
5175
|
return ok({
|
|
4614
5176
|
...asRecord(asRecord(res).data ?? res),
|
|
@@ -4647,6 +5209,7 @@ registerTool(
|
|
|
4647
5209
|
// Start it before the initial retrying GET so no preparatory request sits
|
|
4648
5210
|
// outside the advertised ~280s tool budget.
|
|
4649
5211
|
const overallDeadline = Date.now() + 280_000;
|
|
5212
|
+
const requestNonce = randomUUID();
|
|
4650
5213
|
const res = await client.get<{ data: unknown }>(
|
|
4651
5214
|
`/editor/${args.slug}`,
|
|
4652
5215
|
undefined,
|
|
@@ -4721,7 +5284,12 @@ registerTool(
|
|
|
4721
5284
|
await client.post(
|
|
4722
5285
|
`/editor/${args.slug}/segments/${String(sid)}/generate`,
|
|
4723
5286
|
undefined,
|
|
4724
|
-
|
|
5287
|
+
idemKey(
|
|
5288
|
+
"generate-all-segment",
|
|
5289
|
+
args.slug,
|
|
5290
|
+
String(sid),
|
|
5291
|
+
requestNonce,
|
|
5292
|
+
),
|
|
4725
5293
|
);
|
|
4726
5294
|
} catch (e) {
|
|
4727
5295
|
return ok({
|
|
@@ -4871,8 +5439,17 @@ registerTool(
|
|
|
4871
5439
|
},
|
|
4872
5440
|
},
|
|
4873
5441
|
tool(async (args: { slug: string; auto_unlock?: boolean }, client) => {
|
|
5442
|
+
// Narration is non-deterministic, so each explicit tool invocation owns a
|
|
5443
|
+
// fresh logical-attempt key. Keep that key outside withAssist so its one
|
|
5444
|
+
// post-unlock retry reuses the same replay fence instead of forking a
|
|
5445
|
+
// second narration attempt.
|
|
5446
|
+
const requestKey = idemKey("generate-narration", args.slug, randomUUID());
|
|
4874
5447
|
const res = await withAssist(client, args.auto_unlock ?? false, () =>
|
|
4875
|
-
client.post<{ data: unknown }>(
|
|
5448
|
+
client.post<{ data: unknown }>(
|
|
5449
|
+
`/editor/${args.slug}/generate-narration`,
|
|
5450
|
+
undefined,
|
|
5451
|
+
requestKey,
|
|
5452
|
+
),
|
|
4876
5453
|
);
|
|
4877
5454
|
return ok(asRecord(res).data ?? res);
|
|
4878
5455
|
}),
|
|
@@ -4957,10 +5534,11 @@ registerTool(
|
|
|
4957
5534
|
if (args.genre !== undefined) body.genre = args.genre;
|
|
4958
5535
|
if (args.tempo !== undefined) body.tempo = args.tempo;
|
|
4959
5536
|
if (args.instruments !== undefined) body.instruments = args.instruments;
|
|
5537
|
+
const requestKey = idemKey("generate-music", args.slug, randomUUID());
|
|
4960
5538
|
const res = await client.post<{ data: unknown }>(
|
|
4961
5539
|
`/editor/${args.slug}/generate-music`,
|
|
4962
5540
|
body,
|
|
4963
|
-
|
|
5541
|
+
requestKey,
|
|
4964
5542
|
);
|
|
4965
5543
|
return ok({
|
|
4966
5544
|
...asRecord(asRecord(res).data ?? res),
|
|
@@ -4990,10 +5568,11 @@ registerTool(
|
|
|
4990
5568
|
},
|
|
4991
5569
|
tool(async (args: { slug: string }, client) => {
|
|
4992
5570
|
try {
|
|
5571
|
+
const requestKey = idemKey("render-editor", args.slug, randomUUID());
|
|
4993
5572
|
const res = await client.post<{ data: unknown }>(
|
|
4994
5573
|
`/editor/${args.slug}/render`,
|
|
4995
5574
|
undefined,
|
|
4996
|
-
|
|
5575
|
+
requestKey,
|
|
4997
5576
|
);
|
|
4998
5577
|
return ok(asRecord(res).data ?? res);
|
|
4999
5578
|
} catch (e) {
|
|
@@ -5160,8 +5739,44 @@ registerTool(
|
|
|
5160
5739
|
annotations: { title: "Retry render", ...WRITE, idempotentHint: false },
|
|
5161
5740
|
},
|
|
5162
5741
|
tool(async (args: { slug: string; render_id: number | string }, client) => {
|
|
5742
|
+
const observed = await observedGenerationAction(
|
|
5743
|
+
client,
|
|
5744
|
+
"editor",
|
|
5745
|
+
args.slug,
|
|
5746
|
+
"retry_generation",
|
|
5747
|
+
{
|
|
5748
|
+
workflows: ["editor.render", "editor.render.retry"],
|
|
5749
|
+
statuses: ["failed"],
|
|
5750
|
+
},
|
|
5751
|
+
);
|
|
5752
|
+
const runId = observed.summary.run_id as string;
|
|
5753
|
+
const revision = observed.summary.revision as number;
|
|
5754
|
+
const rendersResponse = await client.get<{ data: unknown }>(
|
|
5755
|
+
`/editor/${args.slug}/renders`,
|
|
5756
|
+
);
|
|
5757
|
+
const renders = asRecord(rendersResponse).data;
|
|
5758
|
+
const render = Array.isArray(renders)
|
|
5759
|
+
? renders
|
|
5760
|
+
.map(asRecord)
|
|
5761
|
+
.find((candidate) => String(candidate.id) === String(args.render_id))
|
|
5762
|
+
: undefined;
|
|
5763
|
+
if (render?.status !== "failed" || render.generation_run_id !== runId) {
|
|
5764
|
+
throw new Error(
|
|
5765
|
+
"The selected render is not the failed render owned by the current retry action; refresh list_renders before continuing.",
|
|
5766
|
+
);
|
|
5767
|
+
}
|
|
5768
|
+
|
|
5769
|
+
const requestKey = idemKey(
|
|
5770
|
+
"retry-render",
|
|
5771
|
+
args.slug,
|
|
5772
|
+
String(args.render_id),
|
|
5773
|
+
runId,
|
|
5774
|
+
String(revision),
|
|
5775
|
+
);
|
|
5163
5776
|
const res = await client.post<{ data: unknown }>(
|
|
5164
5777
|
`/editor/${args.slug}/renders/${String(args.render_id)}/retry`,
|
|
5778
|
+
{ generation_revision: revision },
|
|
5779
|
+
requestKey,
|
|
5165
5780
|
);
|
|
5166
5781
|
return ok(asRecord(res).data ?? res);
|
|
5167
5782
|
}),
|
|
@@ -5820,7 +6435,10 @@ registerTool(
|
|
|
5820
6435
|
"Returns a normalized status {stage, terminal, ready, video_url, error} for a short, editor, " +
|
|
5821
6436
|
"tracking, or slider project. ready:true means the post-ready MP4 is at video_url — except for a " +
|
|
5822
6437
|
"slider (a carousel of stills, not a video): its video_url is always null, ready:true means every " +
|
|
5823
|
-
"slide is composited, and you read the per-slide image URLs with get_slider.
|
|
6438
|
+
"slide is composited, and you read the per-slide image URLs with get_slider. Editor/Short results also " +
|
|
6439
|
+
"include the required generation_summary; normalized lifecycle fields are derived from that authority. Editor " +
|
|
6440
|
+
"delivery freshness is separate: a completed stale timeline returns stage=editing_required and ready=false even " +
|
|
6441
|
+
"when a historical MP4 remains. During paid short " +
|
|
5824
6442
|
"generation, segments_completed / segments_total and active_segment_positions report real clip progress.",
|
|
5825
6443
|
inputSchema: { slug: z.string(), kind: kindSchema },
|
|
5826
6444
|
outputSchema: getStatusOutput,
|
|
@@ -5837,13 +6455,11 @@ registerTool(
|
|
|
5837
6455
|
{
|
|
5838
6456
|
title: "Wait for a render to finish",
|
|
5839
6457
|
description:
|
|
5840
|
-
"Polls status (emitting progress) until terminal or the wait budget is exhausted. " +
|
|
5841
|
-
"
|
|
5842
|
-
"
|
|
5843
|
-
|
|
5844
|
-
|
|
5845
|
-
'stage "idle" — a draft with nothing generating, start generation first) that only a further tool call ' +
|
|
5846
|
-
"can advance — do NOT just re-poll those. " +
|
|
6458
|
+
"Polls status (emitting progress) until terminal, the server withdraws polling, or the wait budget is exhausted. " +
|
|
6459
|
+
"For Editor/Short it polls only while generation_summary.timing.poll_after_ms is a positive integer; null stops polling. The " +
|
|
6460
|
+
"poll_interval_seconds argument is only a fallback for tracking/slider. timed_out is true only when the client budget " +
|
|
6461
|
+
"expires; polling_stopped is true when the server requests a stop so callers can invoke the advertised action immediately. " +
|
|
6462
|
+
"When polling stops, inspect generation_summary.available_actions and invoke only an advertised start/retry/input/review/download action; do not derive the next command from normalized stage. " +
|
|
5847
6463
|
"Pass save_path to download the finished MP4 when it's ready (e.g. to resume a make_video that timed out mid-render). " +
|
|
5848
6464
|
'Works for kind:"slider" too, but a carousel has no MP4 — it just goes terminal when composited; read the slide ' +
|
|
5849
6465
|
"images with get_slider (save_path is ignored for sliders).",
|
|
@@ -5863,7 +6479,9 @@ registerTool(
|
|
|
5863
6479
|
.min(5)
|
|
5864
6480
|
.max(60)
|
|
5865
6481
|
.optional()
|
|
5866
|
-
.describe(
|
|
6482
|
+
.describe(
|
|
6483
|
+
"Tracking/slider fallback interval (default 15s). Editor/Short use generation_summary.timing.poll_after_ms.",
|
|
6484
|
+
),
|
|
5867
6485
|
save_path: z
|
|
5868
6486
|
.string()
|
|
5869
6487
|
.optional()
|
|
@@ -5909,7 +6527,13 @@ registerTool(
|
|
|
5909
6527
|
saved_to = (await downloadTo(status.video_url, args.save_path))
|
|
5910
6528
|
.saved_to;
|
|
5911
6529
|
}
|
|
5912
|
-
const
|
|
6530
|
+
const polling_stopped = serverStoppedPolling(args.kind, status);
|
|
6531
|
+
const out = {
|
|
6532
|
+
...status,
|
|
6533
|
+
saved_to,
|
|
6534
|
+
timed_out: !status.terminal && !polling_stopped,
|
|
6535
|
+
polling_stopped,
|
|
6536
|
+
};
|
|
5913
6537
|
const links =
|
|
5914
6538
|
status.ready && status.video_url
|
|
5915
6539
|
? [mp4Link(status.video_url, args.slug)]
|
|
@@ -6725,7 +7349,7 @@ registerTool(
|
|
|
6725
7349
|
draft_script: z.string().optional(),
|
|
6726
7350
|
caption: z.string().optional(),
|
|
6727
7351
|
hashtags: z.array(z.string()).optional(),
|
|
6728
|
-
credit_estimate: z.record(z.unknown()).optional(),
|
|
7352
|
+
credit_estimate: z.record(z.string(), z.unknown()).optional(),
|
|
6729
7353
|
}),
|
|
6730
7354
|
)
|
|
6731
7355
|
.describe("1-12 variations to persist (echo them from the board)"),
|
|
@@ -8085,17 +8709,28 @@ registerTool(
|
|
|
8085
8709
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
8086
8710
|
|
|
8087
8711
|
async function main() {
|
|
8712
|
+
const args = withoutProfileArgs(process.argv.slice(2));
|
|
8088
8713
|
// `hubfluencer-mcp login [name]` runs the device-link login instead of the
|
|
8089
8714
|
// server. Single bin keeps the install idiomatic: `npx -y @hubfluencer/mcp`
|
|
8090
8715
|
// starts the server; `npx -y @hubfluencer/mcp login` connects an account.
|
|
8091
|
-
if (
|
|
8092
|
-
await runLogin(
|
|
8716
|
+
if (args[0] === "login") {
|
|
8717
|
+
await runLogin(args[1]);
|
|
8718
|
+
return;
|
|
8719
|
+
}
|
|
8720
|
+
if (args[0] === "doctor") {
|
|
8721
|
+
await runDoctor(activeToolProfile, args.includes("--json"));
|
|
8722
|
+
return;
|
|
8723
|
+
}
|
|
8724
|
+
if (args[0] === "install-skill") {
|
|
8725
|
+
await runInstallSkill(args);
|
|
8093
8726
|
return;
|
|
8094
8727
|
}
|
|
8095
8728
|
const transport = new StdioServerTransport();
|
|
8096
8729
|
await server.connect(transport);
|
|
8097
8730
|
// stderr is safe; stdout is reserved for the MCP protocol.
|
|
8098
|
-
console.error(
|
|
8731
|
+
console.error(
|
|
8732
|
+
`hubfluencer-mcp running on stdio (profile: ${activeToolProfile})`,
|
|
8733
|
+
);
|
|
8099
8734
|
}
|
|
8100
8735
|
|
|
8101
8736
|
main().catch((e) => {
|