@hubfluencer/mcp 0.15.0 → 0.17.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 +107 -50
- package/dist/index.js +13071 -7803
- 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 +311 -398
- package/src/doctor.ts +96 -0
- package/src/generation-summary.ts +262 -0
- package/src/index.ts +1075 -290
- package/src/output-schemas.ts +85 -12
- package/src/polling.ts +64 -0
- package/src/skill-installer.ts +100 -0
- package/src/tool-profile.ts +93 -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,
|
|
@@ -101,6 +109,7 @@ import {
|
|
|
101
109
|
validatePerformanceMetrics,
|
|
102
110
|
validateReviewOutput,
|
|
103
111
|
} from "./perf-review-assets.js";
|
|
112
|
+
import { pollWithFinalReconciliation } from "./polling.js";
|
|
104
113
|
import {
|
|
105
114
|
buildScheduledPostBody,
|
|
106
115
|
calendarScopeErrorText,
|
|
@@ -114,6 +123,12 @@ import {
|
|
|
114
123
|
summarizeSeries,
|
|
115
124
|
validateScheduledPostCopy,
|
|
116
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";
|
|
117
132
|
import {
|
|
118
133
|
CATALOG_ASSET_EXTS,
|
|
119
134
|
closePreparedUploadFile,
|
|
@@ -154,6 +169,115 @@ async function fetchStatus(
|
|
|
154
169
|
return normalizeStatus(kind, slug, asRecord(res).data);
|
|
155
170
|
}
|
|
156
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
|
+
|
|
157
281
|
async function editorAutopilotQuote(
|
|
158
282
|
client: HubfluencerClient,
|
|
159
283
|
slug: string,
|
|
@@ -161,11 +285,14 @@ async function editorAutopilotQuote(
|
|
|
161
285
|
language?: string;
|
|
162
286
|
product_subject?: string;
|
|
163
287
|
product_prompt?: string;
|
|
288
|
+
segments_count?: number;
|
|
164
289
|
restart?: boolean;
|
|
165
290
|
} = {},
|
|
166
291
|
): Promise<{
|
|
167
292
|
estimated_credits: number | null;
|
|
168
293
|
available_credits: number | null;
|
|
294
|
+
scene_count: number | null;
|
|
295
|
+
target_duration_seconds: number | null;
|
|
169
296
|
affordable: boolean;
|
|
170
297
|
}> {
|
|
171
298
|
try {
|
|
@@ -175,6 +302,8 @@ async function editorAutopilotQuote(
|
|
|
175
302
|
params.set("product_subject", overrides.product_subject);
|
|
176
303
|
if (overrides.product_prompt !== undefined)
|
|
177
304
|
params.set("product_prompt", overrides.product_prompt);
|
|
305
|
+
if (overrides.segments_count !== undefined)
|
|
306
|
+
params.set("segments_count", String(overrides.segments_count));
|
|
178
307
|
if (overrides.restart) params.set("restart", "true");
|
|
179
308
|
const query = params.toString();
|
|
180
309
|
const response = await client.get<{ data: unknown }>(
|
|
@@ -187,9 +316,17 @@ async function editorAutopilotQuote(
|
|
|
187
316
|
typeof cost.available_credits === "number"
|
|
188
317
|
? cost.available_credits
|
|
189
318
|
: null;
|
|
319
|
+
const scene_count =
|
|
320
|
+
typeof cost.scene_count === "number" ? cost.scene_count : null;
|
|
321
|
+
const target_duration_seconds =
|
|
322
|
+
typeof cost.target_duration_seconds === "number"
|
|
323
|
+
? cost.target_duration_seconds
|
|
324
|
+
: null;
|
|
190
325
|
return {
|
|
191
326
|
estimated_credits,
|
|
192
327
|
available_credits,
|
|
328
|
+
scene_count,
|
|
329
|
+
target_duration_seconds,
|
|
193
330
|
affordable:
|
|
194
331
|
estimated_credits !== null &&
|
|
195
332
|
available_credits !== null &&
|
|
@@ -213,6 +350,8 @@ async function editorAutopilotQuote(
|
|
|
213
350
|
return {
|
|
214
351
|
estimated_credits: null,
|
|
215
352
|
available_credits: null,
|
|
353
|
+
scene_count: null,
|
|
354
|
+
target_duration_seconds: null,
|
|
216
355
|
affordable: false,
|
|
217
356
|
};
|
|
218
357
|
}
|
|
@@ -363,10 +502,13 @@ function ok(payload: unknown, links: ResourceLink[] = []) {
|
|
|
363
502
|
const result: {
|
|
364
503
|
content: typeof content;
|
|
365
504
|
structuredContent?: Record<string, unknown>;
|
|
366
|
-
} = {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
505
|
+
} = {
|
|
506
|
+
content,
|
|
507
|
+
structuredContent:
|
|
508
|
+
payload && typeof payload === "object" && !Array.isArray(payload)
|
|
509
|
+
? (payload as Record<string, unknown>)
|
|
510
|
+
: { value: payload },
|
|
511
|
+
};
|
|
370
512
|
return result;
|
|
371
513
|
}
|
|
372
514
|
|
|
@@ -411,21 +553,18 @@ function errMessage(e: unknown): string {
|
|
|
411
553
|
* silently no-op'ing the purchase (no credit spent, no assists added) while
|
|
412
554
|
* still returning 200. Snapshotting means a transport retry of the SAME unlock
|
|
413
555
|
* replays safely (no double-charge), but a DISTINCT "buy another batch" call
|
|
414
|
-
* gets a fresh key and executes.
|
|
415
|
-
* 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.
|
|
416
559
|
*/
|
|
417
560
|
async function unlockKey(client: HubfluencerClient): Promise<string> {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
);
|
|
426
|
-
} catch {
|
|
427
|
-
return "";
|
|
428
|
-
}
|
|
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
|
+
);
|
|
429
568
|
}
|
|
430
569
|
|
|
431
570
|
/**
|
|
@@ -434,25 +573,17 @@ async function unlockKey(client: HubfluencerClient): Promise<string> {
|
|
|
434
573
|
* deterministic, so replaying an identical (narration, voice) call is correct
|
|
435
574
|
* and free, while regenerating after the narration or voice changes gets a
|
|
436
575
|
* fresh key and actually re-runs — a fixed slug+voice key would replay stale
|
|
437
|
-
* 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.
|
|
438
578
|
*/
|
|
439
579
|
async function voiceKey(
|
|
440
580
|
client: HubfluencerClient,
|
|
441
581
|
slug: string,
|
|
442
582
|
voiceId: string,
|
|
443
583
|
): Promise<string> {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
return idemKey(
|
|
448
|
-
"gen-voice",
|
|
449
|
-
slug,
|
|
450
|
-
voiceId,
|
|
451
|
-
String(d.narration_script ?? ""),
|
|
452
|
-
);
|
|
453
|
-
} catch {
|
|
454
|
-
return "";
|
|
455
|
-
}
|
|
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 ?? ""));
|
|
456
587
|
}
|
|
457
588
|
|
|
458
589
|
/**
|
|
@@ -481,7 +612,7 @@ async function withAssist<T>(
|
|
|
481
612
|
return await fn();
|
|
482
613
|
} catch (e) {
|
|
483
614
|
const he = e as HubfluencerError;
|
|
484
|
-
if (
|
|
615
|
+
if (he?.status !== 429) throw e;
|
|
485
616
|
if (!autoUnlock) throw e;
|
|
486
617
|
// One unlock (1 credit → +10 assists), then one retry. A 402 here means
|
|
487
618
|
// the account is out of credits — let it propagate to a clean fail().
|
|
@@ -617,6 +748,8 @@ const WRITE = {
|
|
|
617
748
|
// call (the per-tool `description`s carry the specifics). Kept concise: the two
|
|
618
749
|
// altitudes (one-shot asset vs. the full delegate-my-content-operation loop),
|
|
619
750
|
// money, how waiting/resuming works, and the local-file sandbox.
|
|
751
|
+
const activeToolProfile = profileFromArgs(process.argv.slice(2));
|
|
752
|
+
|
|
620
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).
|
|
621
754
|
|
|
622
755
|
MAKE ONE ASSET (simplest)
|
|
@@ -639,13 +772,16 @@ CREDITS (spent server-side; each tool prices before charging — see its descrip
|
|
|
639
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.
|
|
640
773
|
|
|
641
774
|
WAITING & RESUMING
|
|
642
|
-
-
|
|
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.
|
|
643
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.
|
|
644
778
|
|
|
645
779
|
LOCAL FILES (sandboxed)
|
|
646
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.
|
|
647
781
|
|
|
648
|
-
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.`;
|
|
649
785
|
|
|
650
786
|
const server = new McpServer(
|
|
651
787
|
{ name: "hubfluencer", version: VERSION },
|
|
@@ -662,9 +798,69 @@ type LooseRegister = (
|
|
|
662
798
|
config: object,
|
|
663
799
|
handler: (...args: never[]) => unknown,
|
|
664
800
|
) => void;
|
|
665
|
-
const
|
|
801
|
+
const registerToolRaw = server.registerTool.bind(
|
|
666
802
|
server,
|
|
667
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
|
+
}
|
|
668
864
|
|
|
669
865
|
async function pollToTerminal(
|
|
670
866
|
client: HubfluencerClient,
|
|
@@ -674,15 +870,20 @@ async function pollToTerminal(
|
|
|
674
870
|
budgetMs: number,
|
|
675
871
|
intervalMs: number,
|
|
676
872
|
): Promise<NormalizedStatus> {
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
873
|
+
return pollWithFinalReconciliation({
|
|
874
|
+
read: (deadline) => fetchStatus(client, kind, slug, deadline),
|
|
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),
|
|
879
|
+
budgetMs,
|
|
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,
|
|
884
|
+
onPoll: (status, pollNumber) =>
|
|
885
|
+
reportProgress(extra, pollNumber, `stage: ${status.stage}`),
|
|
886
|
+
});
|
|
686
887
|
}
|
|
687
888
|
|
|
688
889
|
/**
|
|
@@ -749,7 +950,8 @@ registerTool(
|
|
|
749
950
|
title: "Make a video from a prompt (one shot)",
|
|
750
951
|
description:
|
|
751
952
|
"The simplest path: give a prompt, get a finished MP4. Creates the project (free), PRICES it against " +
|
|
752
|
-
"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, " +
|
|
753
955
|
"polls to completion (emitting progress), and (if save_path is given) downloads the result. " +
|
|
754
956
|
"Spends credits (15 for a short; a multi-scene editor ad ~28). Pass dry_run:true to preview the cost " +
|
|
755
957
|
"WITHOUT charging (returns {estimated_credits, available_credits, slug}); pass max_credits to cap the " +
|
|
@@ -799,6 +1001,15 @@ registerTool(
|
|
|
799
1001
|
.describe(
|
|
800
1002
|
"EDITOR only: social_ad for promotional content (product focus + CTA) or creative_story (default).",
|
|
801
1003
|
),
|
|
1004
|
+
segments_count: z
|
|
1005
|
+
.number()
|
|
1006
|
+
.int()
|
|
1007
|
+
.min(3)
|
|
1008
|
+
.max(10)
|
|
1009
|
+
.optional()
|
|
1010
|
+
.describe(
|
|
1011
|
+
"EDITOR only: exact number of 8-second AI scenes (3–10). Use 3 for a concise ~24s ad.",
|
|
1012
|
+
),
|
|
802
1013
|
language: z
|
|
803
1014
|
.enum(LANGUAGES)
|
|
804
1015
|
.optional()
|
|
@@ -978,6 +1189,7 @@ registerTool(
|
|
|
978
1189
|
kind?: string;
|
|
979
1190
|
product_subject?: string;
|
|
980
1191
|
project_intent?: string;
|
|
1192
|
+
segments_count?: number;
|
|
981
1193
|
language?: string;
|
|
982
1194
|
aspect?: string;
|
|
983
1195
|
voice_id?: string;
|
|
@@ -1022,6 +1234,9 @@ registerTool(
|
|
|
1022
1234
|
const requestedKind =
|
|
1023
1235
|
args.kind === "short" || args.kind === "editor" ? args.kind : undefined;
|
|
1024
1236
|
const kind: Kind = requestedKind ?? inferKind(args.prompt);
|
|
1237
|
+
if (kind !== "editor" && args.segments_count !== undefined) {
|
|
1238
|
+
return fail("segments_count is only supported for editor videos.");
|
|
1239
|
+
}
|
|
1025
1240
|
const waitSeconds = Math.min(
|
|
1026
1241
|
280,
|
|
1027
1242
|
Math.max(10, args.max_wait_seconds ?? 240),
|
|
@@ -1128,6 +1343,7 @@ registerTool(
|
|
|
1128
1343
|
{
|
|
1129
1344
|
language: args.language ?? "en",
|
|
1130
1345
|
product_prompt: args.prompt,
|
|
1346
|
+
segments_count: args.segments_count,
|
|
1131
1347
|
product_subject: args.product_subject,
|
|
1132
1348
|
project_intent: args.project_intent,
|
|
1133
1349
|
export_aspect_ratio: args.aspect,
|
|
@@ -1145,9 +1361,66 @@ registerTool(
|
|
|
1145
1361
|
);
|
|
1146
1362
|
slug = created.data.slug;
|
|
1147
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
|
+
}
|
|
1148
1421
|
|
|
1149
1422
|
// 2) Preflight cost vs the live balance BEFORE charging. Shorts retain
|
|
1150
|
-
// their fixed-price
|
|
1423
|
+
// their fixed-price behavior, but an editor without a
|
|
1151
1424
|
// readable quote must never fall through to the API's uncapped launch.
|
|
1152
1425
|
const costPath =
|
|
1153
1426
|
kind === "short"
|
|
@@ -1156,18 +1429,21 @@ registerTool(
|
|
|
1156
1429
|
let estimated_credits: number | null = null;
|
|
1157
1430
|
let available_credits: number | null = null;
|
|
1158
1431
|
let authorizedCredits: number | undefined;
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
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
|
+
}
|
|
1171
1447
|
}
|
|
1172
1448
|
|
|
1173
1449
|
const resume =
|
|
@@ -1189,13 +1465,14 @@ registerTool(
|
|
|
1189
1465
|
|
|
1190
1466
|
// 3) Stop BEFORE charging on dry_run, an uncapped editor with no live
|
|
1191
1467
|
// quote, an over-cap estimate, or a balance we already know is too
|
|
1192
|
-
// low.
|
|
1193
|
-
//
|
|
1468
|
+
// low. An already-active replay bypasses launch admission entirely:
|
|
1469
|
+
// it is existing paid work to resume, not a new charge.
|
|
1194
1470
|
if (
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1471
|
+
!activeReplay &&
|
|
1472
|
+
(args.dry_run ||
|
|
1473
|
+
editorPriceUnavailableWithoutCap ||
|
|
1474
|
+
overCap ||
|
|
1475
|
+
!affordable)
|
|
1199
1476
|
) {
|
|
1200
1477
|
const note = args.dry_run
|
|
1201
1478
|
? `Dry run — created a free draft and priced it (${estimated_credits ?? "?"} credits, have ${available_credits ?? "?"}). To run it: ${resume}.`
|
|
@@ -1216,69 +1493,66 @@ registerTool(
|
|
|
1216
1493
|
});
|
|
1217
1494
|
}
|
|
1218
1495
|
|
|
1219
|
-
// 4) Affordable and authorized — start
|
|
1220
|
-
//
|
|
1221
|
-
//
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
// already-running / 409 gate independently blocks a double charge) but a
|
|
1227
|
-
// per-attempt signature once terminally failed, so a genuine re-run mints
|
|
1228
|
-
// a fresh key and re-enqueues. Best-effort: if the read fails we fall
|
|
1229
|
-
// back to the stable "start" (the server gate remains the real guard).
|
|
1230
|
-
let startState = "start";
|
|
1231
|
-
try {
|
|
1232
|
-
const cur = await client.get<{ data: unknown }>(
|
|
1233
|
-
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")
|
|
1234
1503
|
);
|
|
1235
|
-
startState = startStateDiscriminator(kind, asRecord(cur).data);
|
|
1236
|
-
} catch {
|
|
1237
|
-
// status unreadable — keep the stable token (the server gate still holds)
|
|
1238
1504
|
}
|
|
1239
1505
|
|
|
1240
|
-
if (
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
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),
|
|
1260
1547
|
);
|
|
1261
1548
|
}
|
|
1262
|
-
authorizedCredits = approvedCap;
|
|
1263
|
-
await client.post(
|
|
1264
|
-
`/editor/${slug}/autopilot`,
|
|
1265
|
-
// Use the caller's explicit cap when given, otherwise the live
|
|
1266
|
-
// estimate. The API recomputes under its launch lock and rejects a
|
|
1267
|
-
// price increase before any state change or charge.
|
|
1268
|
-
{ max_credits: approvedCap },
|
|
1269
|
-
// Fold the create-affecting params AND the failure-state discriminator
|
|
1270
|
-
// into the start key so it tracks the inputs + current state, not just
|
|
1271
|
-
// the slug. This key is only compared against a retry of *this* tool on
|
|
1272
|
-
// *this* slug; the other autopilot entry points use a different field
|
|
1273
|
-
// set/order on purpose (the server idempotency cache is scoped to
|
|
1274
|
-
// path+key, and the slug — already in the path — is unique per draft),
|
|
1275
|
-
// so the shapes are not meant to be interchangeable across tools.
|
|
1276
|
-
// Composition lives in core.ts, shared with the golden-pin test.
|
|
1277
|
-
makeVideoAutopilotKey(slug, args, startState),
|
|
1278
|
-
);
|
|
1279
1549
|
}
|
|
1280
1550
|
|
|
1281
|
-
await reportProgress(
|
|
1551
|
+
await reportProgress(
|
|
1552
|
+
extra,
|
|
1553
|
+
0,
|
|
1554
|
+
`${activeReplay ? "resumed" : "started"} ${kind} ${slug}`,
|
|
1555
|
+
);
|
|
1282
1556
|
const status = await pollToTerminal(
|
|
1283
1557
|
client,
|
|
1284
1558
|
kind,
|
|
@@ -1320,10 +1594,12 @@ registerTool(
|
|
|
1320
1594
|
|
|
1321
1595
|
const billing =
|
|
1322
1596
|
kind === "short"
|
|
1323
|
-
? { charged: true }
|
|
1597
|
+
? { charged: true, ...(activeReplay ? { replayed: true } : {}) }
|
|
1324
1598
|
: {
|
|
1325
|
-
launched:
|
|
1326
|
-
|
|
1599
|
+
launched: !activeReplay,
|
|
1600
|
+
...(activeReplay
|
|
1601
|
+
? { replayed: true }
|
|
1602
|
+
: { authorized_credits: authorizedCredits }),
|
|
1327
1603
|
...(editorCreditsSpent === undefined
|
|
1328
1604
|
? {}
|
|
1329
1605
|
: {
|
|
@@ -1332,6 +1608,7 @@ registerTool(
|
|
|
1332
1608
|
}),
|
|
1333
1609
|
};
|
|
1334
1610
|
|
|
1611
|
+
const pollingStopped = serverStoppedPolling(kind, status);
|
|
1335
1612
|
const result = {
|
|
1336
1613
|
...status,
|
|
1337
1614
|
kind_inferred: requestedKind === undefined,
|
|
@@ -1339,7 +1616,8 @@ registerTool(
|
|
|
1339
1616
|
available_credits,
|
|
1340
1617
|
...billing,
|
|
1341
1618
|
saved_to,
|
|
1342
|
-
timed_out: !status.terminal,
|
|
1619
|
+
timed_out: !status.terminal && !pollingStopped,
|
|
1620
|
+
polling_stopped: pollingStopped,
|
|
1343
1621
|
};
|
|
1344
1622
|
// Thread the caller's save_path into the resume hint so the one-shot
|
|
1345
1623
|
// "download to this path" intent survives a mid-render timeout —
|
|
@@ -1356,17 +1634,19 @@ registerTool(
|
|
|
1356
1634
|
...result,
|
|
1357
1635
|
note: status.ready
|
|
1358
1636
|
? "Done. Presigned ~24h URL — download promptly."
|
|
1359
|
-
:
|
|
1360
|
-
? status
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
//
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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}).`,
|
|
1370
1650
|
},
|
|
1371
1651
|
links,
|
|
1372
1652
|
);
|
|
@@ -1461,8 +1741,9 @@ registerTool(
|
|
|
1461
1741
|
{
|
|
1462
1742
|
title: "List recent projects",
|
|
1463
1743
|
description:
|
|
1464
|
-
"Lists the caller's recent shorts
|
|
1465
|
-
"
|
|
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 " +
|
|
1466
1747
|
"in-progress/failed; pass include_completed:true to also surface finished shorts — useful to " +
|
|
1467
1748
|
"recover a lost slug. Capped to the most recent items per kind (use get_status for authoritative state).",
|
|
1468
1749
|
inputSchema: {
|
|
@@ -1499,23 +1780,35 @@ registerTool(
|
|
|
1499
1780
|
};
|
|
1500
1781
|
const shortRows = rows(shortsRaw);
|
|
1501
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;
|
|
1502
1795
|
|
|
1503
1796
|
const shorts = shortRows
|
|
1504
1797
|
.slice(0, cap)
|
|
1505
1798
|
.map((s) => normalizeStatus("short", String(s.slug ?? ""), s));
|
|
1506
|
-
// /video-factories spans editor + campaign + creative kinds
|
|
1507
|
-
//
|
|
1508
|
-
//
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
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);
|
|
1513
1806
|
|
|
1514
1807
|
return ok({
|
|
1515
1808
|
shorts,
|
|
1516
1809
|
projects,
|
|
1517
1810
|
note:
|
|
1518
|
-
|
|
1811
|
+
shortsTruncated || genericFactoryRows.length > cap
|
|
1519
1812
|
? `Showing up to ${cap} per kind; more exist — raise limit or use get_status.`
|
|
1520
1813
|
: undefined,
|
|
1521
1814
|
});
|
|
@@ -2541,7 +2834,7 @@ registerTool(
|
|
|
2541
2834
|
"use rerender_short instead: it re-applies the overlay over the existing footage for 0 credits. " +
|
|
2542
2835
|
"Safe to call again: a " +
|
|
2543
2836
|
"duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " +
|
|
2544
|
-
"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 " +
|
|
2545
2838
|
"auto-writes the headline + caption beats before rendering (pass skip_auto_text:true for a deliberately " +
|
|
2546
2839
|
"bare clip). A transient 5xx is reconciled against the project state: accepted work is returned as " +
|
|
2547
2840
|
"in-progress, while an unchanged draft is retried once. Then poll with get_status or " +
|
|
@@ -2562,17 +2855,38 @@ registerTool(
|
|
|
2562
2855
|
annotations: { title: "Generate short", ...WRITE },
|
|
2563
2856
|
},
|
|
2564
2857
|
tool(async (args: { slug: string; skip_auto_text?: boolean }, client) => {
|
|
2565
|
-
|
|
2566
|
-
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
|
|
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
|
+
);
|
|
2570
2883
|
const submit = () =>
|
|
2571
2884
|
client.post<{ data: unknown }>(
|
|
2572
2885
|
`/shorts/${args.slug}/generate`,
|
|
2573
2886
|
// Only send a body when the caller opts out of server auto-copy —
|
|
2574
2887
|
// the bare POST stays byte-identical to the pre-0.8.2 wire shape.
|
|
2575
2888
|
args.skip_auto_text ? { skip_auto_text: true } : undefined,
|
|
2889
|
+
requestKey,
|
|
2576
2890
|
);
|
|
2577
2891
|
|
|
2578
2892
|
try {
|
|
@@ -2582,15 +2896,10 @@ registerTool(
|
|
|
2582
2896
|
} catch (e) {
|
|
2583
2897
|
const statusCode = (e as { status?: number }).status;
|
|
2584
2898
|
if (statusCode === 409) {
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
terminal: false,
|
|
2590
|
-
ready: false,
|
|
2591
|
-
video_url: null,
|
|
2592
|
-
error: null,
|
|
2593
|
-
});
|
|
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));
|
|
2594
2903
|
}
|
|
2595
2904
|
|
|
2596
2905
|
if (statusCode !== undefined && statusCode >= 500) {
|
|
@@ -2603,12 +2912,20 @@ registerTool(
|
|
|
2603
2912
|
throw e;
|
|
2604
2913
|
}
|
|
2605
2914
|
|
|
2606
|
-
//
|
|
2607
|
-
//
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
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
|
+
}
|
|
2612
2929
|
|
|
2613
2930
|
// A Short launch changes the durable factory out of draft in the
|
|
2614
2931
|
// same transaction as the debit. Still-draft therefore proves the
|
|
@@ -2620,15 +2937,7 @@ registerTool(
|
|
|
2620
2937
|
);
|
|
2621
2938
|
} catch (retryError) {
|
|
2622
2939
|
if ((retryError as { status?: number }).status === 409) {
|
|
2623
|
-
return ok(
|
|
2624
|
-
kind: "short",
|
|
2625
|
-
slug: args.slug,
|
|
2626
|
-
stage: "processing",
|
|
2627
|
-
terminal: false,
|
|
2628
|
-
ready: false,
|
|
2629
|
-
video_url: null,
|
|
2630
|
-
error: null,
|
|
2631
|
-
});
|
|
2940
|
+
return ok(await fetchStatus(client, "short", args.slug));
|
|
2632
2941
|
}
|
|
2633
2942
|
throw retryError;
|
|
2634
2943
|
}
|
|
@@ -2643,17 +2952,20 @@ registerTool(
|
|
|
2643
2952
|
{
|
|
2644
2953
|
title: "Cancel a running short generation",
|
|
2645
2954
|
description:
|
|
2646
|
-
"Stops the
|
|
2647
|
-
"
|
|
2648
|
-
"
|
|
2649
|
-
"
|
|
2955
|
+
"Stops the currently observed paid Short generation through the product-scoped cancellation route, " +
|
|
2956
|
+
"fences late provider callbacks, and settles its refund exactly once. The canonical generation_run_id is " +
|
|
2957
|
+
"read from get_status when omitted. The draft is preserved for editing and " +
|
|
2958
|
+
"regeneration. Safe to repeat after a successful cancellation. The raw REST route requires Idempotency-Key; " +
|
|
2959
|
+
"reuse one only for transport retries of this exact run. Scope: video:generate.",
|
|
2650
2960
|
inputSchema: {
|
|
2651
2961
|
slug: z.string().describe("Slug of the processing short"),
|
|
2652
|
-
|
|
2653
|
-
.
|
|
2654
|
-
.
|
|
2655
|
-
.
|
|
2656
|
-
.describe(
|
|
2962
|
+
generation_run_id: z
|
|
2963
|
+
.string()
|
|
2964
|
+
.uuid()
|
|
2965
|
+
.optional()
|
|
2966
|
+
.describe(
|
|
2967
|
+
"Exact run fence from get_status; normally omitted because the tool reads current status first",
|
|
2968
|
+
),
|
|
2657
2969
|
},
|
|
2658
2970
|
annotations: {
|
|
2659
2971
|
title: "Cancel short generation",
|
|
@@ -2663,16 +2975,54 @@ registerTool(
|
|
|
2663
2975
|
idempotentHint: true,
|
|
2664
2976
|
},
|
|
2665
2977
|
},
|
|
2666
|
-
tool(async (args: { slug: string;
|
|
2667
|
-
const
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2978
|
+
tool(async (args: { slug: string; generation_run_id?: string }, client) => {
|
|
2979
|
+
const observed = await observedGenerationAction(
|
|
2980
|
+
client,
|
|
2981
|
+
"short",
|
|
2982
|
+
args.slug,
|
|
2983
|
+
"cancel_generation",
|
|
2984
|
+
{
|
|
2985
|
+
workflows: ["short.generation"],
|
|
2986
|
+
statuses: ["queued", "running", "cancelled"],
|
|
2987
|
+
recoverCancelled: true,
|
|
2988
|
+
},
|
|
2989
|
+
);
|
|
2990
|
+
const generationRunId = observed.summary.run_id;
|
|
2991
|
+
if (typeof generationRunId !== "string" || generationRunId.length === 0) {
|
|
2992
|
+
throw new Error(
|
|
2993
|
+
"The Short response did not include a canonical run_id; refresh get_status before cancelling safely.",
|
|
2994
|
+
);
|
|
2995
|
+
}
|
|
2996
|
+
if (
|
|
2997
|
+
args.generation_run_id !== undefined &&
|
|
2998
|
+
args.generation_run_id !== generationRunId
|
|
2999
|
+
) {
|
|
3000
|
+
throw new Error(
|
|
3001
|
+
"The supplied generation_run_id does not match the authoritative Short state; refresh before cancelling.",
|
|
3002
|
+
);
|
|
3003
|
+
}
|
|
3004
|
+
const receiptKey = idemKey(
|
|
3005
|
+
"cancel-short-generation",
|
|
3006
|
+
args.slug,
|
|
3007
|
+
generationRunId,
|
|
2671
3008
|
);
|
|
2672
|
-
|
|
3009
|
+
|
|
3010
|
+
const data = observed.settledReplay
|
|
3011
|
+
? observed.state
|
|
3012
|
+
: asRecord(
|
|
3013
|
+
asRecord(
|
|
3014
|
+
await client.post<{ data: unknown }>(
|
|
3015
|
+
`/shorts/${args.slug}/cancel`,
|
|
3016
|
+
{ generation_run_id: generationRunId },
|
|
3017
|
+
receiptKey,
|
|
3018
|
+
),
|
|
3019
|
+
).data,
|
|
3020
|
+
);
|
|
2673
3021
|
return ok({
|
|
2674
3022
|
...data,
|
|
2675
3023
|
slug: args.slug,
|
|
3024
|
+
run_id: observed.summary.run_id,
|
|
3025
|
+
workflow: observed.summary.workflow,
|
|
2676
3026
|
charged: false,
|
|
2677
3027
|
// The cancel endpoint returns the short's STATE, not a refund receipt,
|
|
2678
3028
|
// so don't assert refunded:true on every call — a repeat cancel is a
|
|
@@ -3176,7 +3526,8 @@ registerTool(
|
|
|
3176
3526
|
"and prices autopilot (server-orchestrated " +
|
|
3177
3527
|
"scenario → segments → narration → voice → music → render). Each AI-generated scene is a fixed 8 seconds. " +
|
|
3178
3528
|
"Without max_credits it STOPS after the free draft/configuration and returns the live estimate. It starts " +
|
|
3179
|
-
"the paid pipeline only when max_credits is supplied
|
|
3529
|
+
"the paid pipeline only when max_credits is supplied, covers the estimate, and the canonical generation_summary " +
|
|
3530
|
+
"advertises start_generation. Then poll with get_status / " +
|
|
3180
3531
|
"wait_for_completion (kind=editor). Prefer make_video for a prompt-only one-shot. " +
|
|
3181
3532
|
"A product image is analyzed into semantic project context and sent as a product-identity reference to every " +
|
|
3182
3533
|
"generated Editor scene. The exact photo is also linked as the closing image unless a separate closing image " +
|
|
@@ -3245,6 +3596,15 @@ registerTool(
|
|
|
3245
3596
|
.enum(["9:16", "16:9", "1:1"])
|
|
3246
3597
|
.optional()
|
|
3247
3598
|
.describe('Aspect ratio (default "9:16")'),
|
|
3599
|
+
segments_count: z
|
|
3600
|
+
.number()
|
|
3601
|
+
.int()
|
|
3602
|
+
.min(3)
|
|
3603
|
+
.max(10)
|
|
3604
|
+
.optional()
|
|
3605
|
+
.describe(
|
|
3606
|
+
"Exact number of 8-second AI scenes (3–10). Use 3 for a concise ~24s ad; omit to retain the server default.",
|
|
3607
|
+
),
|
|
3248
3608
|
product_image_path: z
|
|
3249
3609
|
.string()
|
|
3250
3610
|
.optional()
|
|
@@ -3310,6 +3670,7 @@ registerTool(
|
|
|
3310
3670
|
theme?: string;
|
|
3311
3671
|
voice_id?: string;
|
|
3312
3672
|
export_aspect_ratio?: string;
|
|
3673
|
+
segments_count?: number;
|
|
3313
3674
|
product_image_path?: string;
|
|
3314
3675
|
product_description?: string;
|
|
3315
3676
|
closing_image_path?: string;
|
|
@@ -3386,6 +3747,7 @@ registerTool(
|
|
|
3386
3747
|
{
|
|
3387
3748
|
language: args.language ?? "en",
|
|
3388
3749
|
product_prompt: args.product_prompt,
|
|
3750
|
+
segments_count: args.segments_count,
|
|
3389
3751
|
product_subject: args.product_subject,
|
|
3390
3752
|
project_intent: args.project_intent,
|
|
3391
3753
|
creative_format: args.creative_format,
|
|
@@ -3464,7 +3826,9 @@ registerTool(
|
|
|
3464
3826
|
if (args.logo_path && Object.keys(logoSettings).length > 0)
|
|
3465
3827
|
await client.patch(`/editor/${slug}/logo`, logoSettings);
|
|
3466
3828
|
|
|
3467
|
-
const quote = await editorAutopilotQuote(client, slug
|
|
3829
|
+
const quote = await editorAutopilotQuote(client, slug, {
|
|
3830
|
+
segments_count: args.segments_count,
|
|
3831
|
+
});
|
|
3468
3832
|
const overCap =
|
|
3469
3833
|
args.max_credits !== undefined &&
|
|
3470
3834
|
quote.estimated_credits !== null &&
|
|
@@ -3493,17 +3857,41 @@ registerTool(
|
|
|
3493
3857
|
});
|
|
3494
3858
|
}
|
|
3495
3859
|
|
|
3860
|
+
// POST /editor is idempotent and may return an existing terminal draft.
|
|
3861
|
+
// Re-read the canonical product authority after the quote and immediately
|
|
3862
|
+
// before the chargeable launch. Legacy lifecycle columns never authorize
|
|
3863
|
+
// this write, and a completed project requires the explicit restart flow.
|
|
3864
|
+
const observedStart = await observedGenerationAction(
|
|
3865
|
+
client,
|
|
3866
|
+
"editor",
|
|
3867
|
+
slug,
|
|
3868
|
+
"start_generation",
|
|
3869
|
+
{
|
|
3870
|
+
workflows: ["editor.autopilot"],
|
|
3871
|
+
statuses: ["idle", "failed", "cancelled"],
|
|
3872
|
+
allowIdleAuthority: true,
|
|
3873
|
+
},
|
|
3874
|
+
);
|
|
3875
|
+
const startState = startStateDiscriminator(
|
|
3876
|
+
"editor",
|
|
3877
|
+
observedStart.state,
|
|
3878
|
+
);
|
|
3879
|
+
|
|
3496
3880
|
const started = await client.post<{ data: unknown }>(
|
|
3497
3881
|
`/editor/${slug}/autopilot`,
|
|
3498
|
-
{
|
|
3882
|
+
{
|
|
3883
|
+
max_credits: args.max_credits,
|
|
3884
|
+
segments_count: args.segments_count,
|
|
3885
|
+
},
|
|
3499
3886
|
// Fold the create-affecting params into the start key so it tracks
|
|
3500
|
-
// inputs, not just the slug. Self-consistent
|
|
3501
|
-
// is compared against retries of *this* tool
|
|
3887
|
+
// inputs plus the observed attempt, not just the slug. Self-consistent
|
|
3888
|
+
// per-tool only: this key is compared against retries of *this* tool
|
|
3889
|
+
// on *this* slug. The
|
|
3502
3890
|
// field set/order differs from make_video / start_autopilot on
|
|
3503
3891
|
// purpose (server cache is path+key scoped, slug is unique per
|
|
3504
3892
|
// draft), so the keys are not interchangeable across tools.
|
|
3505
3893
|
// Composition lives in core.ts, shared with the golden-pin test.
|
|
3506
|
-
editorAdAutopilotKey(slug, args),
|
|
3894
|
+
editorAdAutopilotKey(slug, args, startState),
|
|
3507
3895
|
);
|
|
3508
3896
|
const status = normalizeStatus("editor", slug, asRecord(started).data);
|
|
3509
3897
|
return ok({
|
|
@@ -3533,10 +3921,10 @@ registerTool(
|
|
|
3533
3921
|
"EXISTING editor project/draft. Without max_credits it returns the live quote and charges nothing. Supply an " +
|
|
3534
3922
|
"explicit max_credits cap to authorize launch; the tool refuses if pricing is unavailable, the estimate exceeds " +
|
|
3535
3923
|
"the cap, or the balance is insufficient. Each authorized call is a genuine start attempt (like tapping Launch in the app): " +
|
|
3536
|
-
"
|
|
3537
|
-
"or double-
|
|
3538
|
-
"
|
|
3539
|
-
"
|
|
3924
|
+
"the tool re-reads generation_summary and starts only when start_generation is advertised, so an active run " +
|
|
3925
|
+
"cannot be double-started or double-charged. Resolve any advertised retry/input action first. For a completed " +
|
|
3926
|
+
"Autopilot run, set restart:true to quote and build a fresh creative run; restart:true is rejected for every " +
|
|
3927
|
+
"non-completed state. Then poll with " +
|
|
3540
3928
|
'wait_for_completion({ slug, kind: "editor" }).',
|
|
3541
3929
|
inputSchema: {
|
|
3542
3930
|
slug: z.string().describe("Editor project slug"),
|
|
@@ -3561,6 +3949,15 @@ registerTool(
|
|
|
3561
3949
|
.describe(
|
|
3562
3950
|
"Optional freeform brief to set/replace before launch. Omit to keep the stored brief; send blank to clear it.",
|
|
3563
3951
|
),
|
|
3952
|
+
segments_count: z
|
|
3953
|
+
.number()
|
|
3954
|
+
.int()
|
|
3955
|
+
.min(3)
|
|
3956
|
+
.max(10)
|
|
3957
|
+
.optional()
|
|
3958
|
+
.describe(
|
|
3959
|
+
"Optional exact number of 8-second AI scenes (3–10). Omit to keep the stored structural target.",
|
|
3960
|
+
),
|
|
3564
3961
|
max_credits: z
|
|
3565
3962
|
.number()
|
|
3566
3963
|
.int()
|
|
@@ -3586,6 +3983,7 @@ registerTool(
|
|
|
3586
3983
|
language?: string;
|
|
3587
3984
|
product_subject?: string;
|
|
3588
3985
|
product_prompt?: string;
|
|
3986
|
+
segments_count?: number;
|
|
3589
3987
|
max_credits?: number;
|
|
3590
3988
|
restart?: boolean;
|
|
3591
3989
|
},
|
|
@@ -3601,6 +3999,7 @@ registerTool(
|
|
|
3601
3999
|
language?: string;
|
|
3602
4000
|
product_subject?: string;
|
|
3603
4001
|
product_prompt?: string;
|
|
4002
|
+
segments_count?: number;
|
|
3604
4003
|
restart?: boolean;
|
|
3605
4004
|
} = {};
|
|
3606
4005
|
if (args.language !== undefined) overrides.language = args.language;
|
|
@@ -3608,6 +4007,8 @@ registerTool(
|
|
|
3608
4007
|
overrides.product_subject = args.product_subject;
|
|
3609
4008
|
if (args.product_prompt !== undefined)
|
|
3610
4009
|
overrides.product_prompt = args.product_prompt;
|
|
4010
|
+
if (args.segments_count !== undefined)
|
|
4011
|
+
overrides.segments_count = args.segments_count;
|
|
3611
4012
|
if (args.restart) overrides.restart = true;
|
|
3612
4013
|
|
|
3613
4014
|
const quote = await editorAutopilotQuote(client, slug, overrides);
|
|
@@ -3637,17 +4038,47 @@ registerTool(
|
|
|
3637
4038
|
});
|
|
3638
4039
|
}
|
|
3639
4040
|
|
|
4041
|
+
const observedStart = await observedGenerationAction(
|
|
4042
|
+
client,
|
|
4043
|
+
"editor",
|
|
4044
|
+
slug,
|
|
4045
|
+
"start_generation",
|
|
4046
|
+
{
|
|
4047
|
+
workflows: ["editor.autopilot"],
|
|
4048
|
+
statuses: ["idle", "failed", "cancelled", "completed"],
|
|
4049
|
+
allowIdleAuthority: true,
|
|
4050
|
+
},
|
|
4051
|
+
);
|
|
4052
|
+
if (
|
|
4053
|
+
observedStart.summary.status === "completed" &&
|
|
4054
|
+
args.restart !== true
|
|
4055
|
+
) {
|
|
4056
|
+
throw new Error(
|
|
4057
|
+
"A completed Autopilot project requires restart:true for the advertised fresh start.",
|
|
4058
|
+
);
|
|
4059
|
+
}
|
|
4060
|
+
if (
|
|
4061
|
+
observedStart.summary.status !== "completed" &&
|
|
4062
|
+
args.restart === true
|
|
4063
|
+
) {
|
|
4064
|
+
throw new Error(
|
|
4065
|
+
"restart:true is valid only for a completed Autopilot generation; refresh before continuing.",
|
|
4066
|
+
);
|
|
4067
|
+
}
|
|
4068
|
+
|
|
3640
4069
|
const started = await client.post<{ data: unknown }>(
|
|
3641
4070
|
`/editor/${slug}/autopilot`,
|
|
3642
4071
|
{ ...overrides, max_credits: args.max_credits },
|
|
3643
|
-
//
|
|
3644
|
-
//
|
|
3645
|
-
//
|
|
3646
|
-
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
4072
|
+
// The observed canonical run/revision (or stable idle identity) rotates
|
|
4073
|
+
// after every accepted attempt. This keeps transport retries stable while
|
|
4074
|
+
// preventing an older 24h receipt from replaying over a later fresh start.
|
|
4075
|
+
idemKey(
|
|
4076
|
+
"autopilot",
|
|
4077
|
+
slug,
|
|
4078
|
+
observedStart.summary.run_id ?? "idle",
|
|
4079
|
+
String(observedStart.summary.revision ?? "idle"),
|
|
4080
|
+
JSON.stringify({ ...overrides, max_credits: args.max_credits }),
|
|
4081
|
+
),
|
|
3651
4082
|
);
|
|
3652
4083
|
const status = normalizeStatus("editor", slug, asRecord(started).data);
|
|
3653
4084
|
return ok({
|
|
@@ -3670,12 +4101,13 @@ registerTool(
|
|
|
3670
4101
|
{
|
|
3671
4102
|
title: "Retry a paused editor batch",
|
|
3672
4103
|
description:
|
|
3673
|
-
"
|
|
3674
|
-
"scene and
|
|
3675
|
-
"paid.
|
|
4104
|
+
"Runs only when generation_summary advertises retry_generation for the observed Editor batch, then atomically re-queues its exact failed " +
|
|
4105
|
+
"scene, preview, render, or delivery step and resumes the prepaid batch. This spends 0 additional credits: the batch generation was already " +
|
|
4106
|
+
"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 " +
|
|
3676
4107
|
"edit that scene's prompt (and use_previous_frame) — but only for the exact failed position the server " +
|
|
3677
4108
|
"names in batch_generation_failed_position (from get_editor); the rest of the paused batch stays " +
|
|
3678
|
-
"mutation-locked.
|
|
4109
|
+
"mutation-locked. A null position identifies positionless render/delivery work and is not authoring permission. " +
|
|
4110
|
+
"Explicit paused scene retries are capped — once the failure code is free_retries_exhausted a " +
|
|
3679
4111
|
"further retry is rejected (start a fresh run instead). It does not authorize or restart a failed parent " +
|
|
3680
4112
|
"Autopilot run; after retrying, wait for the batch " +
|
|
3681
4113
|
"to settle. If the parent is then terminal, call start_autopilot without max_credits for a fresh quote and " +
|
|
@@ -3689,18 +4121,26 @@ registerTool(
|
|
|
3689
4121
|
},
|
|
3690
4122
|
},
|
|
3691
4123
|
tool(async (args: { slug: string }, client) => {
|
|
3692
|
-
const command = await observedEditorBatchCommand(
|
|
3693
|
-
|
|
3694
|
-
|
|
4124
|
+
const command = await observedEditorBatchCommand(
|
|
4125
|
+
client,
|
|
4126
|
+
args.slug,
|
|
4127
|
+
"retry_generation",
|
|
4128
|
+
);
|
|
3695
4129
|
const res = await client.post<{ data: unknown }>(
|
|
3696
|
-
`/
|
|
4130
|
+
`/editor/${args.slug}/generation/batch/retry`,
|
|
3697
4131
|
command,
|
|
4132
|
+
idemKey(
|
|
4133
|
+
"editor-batch-retry",
|
|
4134
|
+
args.slug,
|
|
4135
|
+
command.generation_run_id,
|
|
4136
|
+
String(command.generation_revision),
|
|
4137
|
+
),
|
|
3698
4138
|
);
|
|
3699
4139
|
return ok({
|
|
3700
4140
|
...asRecord(asRecord(res).data ?? res),
|
|
3701
4141
|
slug: args.slug,
|
|
3702
4142
|
charged: false,
|
|
3703
|
-
note: "The failed
|
|
4143
|
+
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.",
|
|
3704
4144
|
next: "wait_for_completion",
|
|
3705
4145
|
});
|
|
3706
4146
|
}),
|
|
@@ -3711,10 +4151,11 @@ registerTool(
|
|
|
3711
4151
|
{
|
|
3712
4152
|
title: "Cancel an editor batch",
|
|
3713
4153
|
description:
|
|
3714
|
-
"Cancels
|
|
3715
|
-
"
|
|
3716
|
-
"
|
|
3717
|
-
"0 additional credits.
|
|
4154
|
+
"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 " +
|
|
4155
|
+
"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 " +
|
|
4156
|
+
"refunded on settlement. Spends " +
|
|
4157
|
+
"0 additional credits. The raw REST route requires Idempotency-Key; reuse one only for transport retries of " +
|
|
4158
|
+
"the same observed batch command. Scope: video:generate.",
|
|
3718
4159
|
inputSchema: { slug: z.string().describe("Editor project slug") },
|
|
3719
4160
|
annotations: {
|
|
3720
4161
|
title: "Cancel editor batch",
|
|
@@ -3725,13 +4166,15 @@ registerTool(
|
|
|
3725
4166
|
},
|
|
3726
4167
|
},
|
|
3727
4168
|
tool(async (args: { slug: string }, client) => {
|
|
3728
|
-
const command = await observedEditorBatchCommand(
|
|
3729
|
-
|
|
3730
|
-
|
|
3731
|
-
|
|
4169
|
+
const command = await observedEditorBatchCommand(
|
|
4170
|
+
client,
|
|
4171
|
+
args.slug,
|
|
4172
|
+
"cancel_generation",
|
|
4173
|
+
);
|
|
3732
4174
|
const res = await client.post<{ data: unknown }>(
|
|
3733
|
-
`/
|
|
3734
|
-
command,
|
|
4175
|
+
`/editor/${args.slug}/generation/batch/cancel`,
|
|
4176
|
+
{ generation_run_id: command.generation_run_id },
|
|
4177
|
+
idemKey("cancel-editor-batch", args.slug, command.generation_run_id),
|
|
3735
4178
|
);
|
|
3736
4179
|
return ok({
|
|
3737
4180
|
...asRecord(asRecord(res).data ?? res),
|
|
@@ -3742,50 +4185,171 @@ registerTool(
|
|
|
3742
4185
|
}),
|
|
3743
4186
|
);
|
|
3744
4187
|
|
|
3745
|
-
type EditorBatchCommandStatus = "preparing" | "paused_for_retry";
|
|
3746
|
-
|
|
3747
4188
|
/**
|
|
3748
|
-
* Reads the
|
|
3749
|
-
* recovery command. The API validates
|
|
3750
|
-
* so a replacement run or phase transition
|
|
3751
|
-
* safe `batch_command_stale` conflict rather than acting on newer work.
|
|
4189
|
+
* Reads the canonical Editor batch run fence immediately before issuing a
|
|
4190
|
+
* recovery command. The API validates the run and revision under the run lock,
|
|
4191
|
+
* so a replacement run or phase transition is a safe stale-command conflict.
|
|
3752
4192
|
*/
|
|
3753
4193
|
async function observedEditorBatchCommand(
|
|
3754
4194
|
client: HubfluencerClient,
|
|
3755
4195
|
slug: string,
|
|
3756
|
-
|
|
4196
|
+
actionType: "retry_generation" | "cancel_generation",
|
|
3757
4197
|
): Promise<{
|
|
3758
|
-
|
|
3759
|
-
|
|
4198
|
+
generation_run_id: string;
|
|
4199
|
+
generation_revision: number;
|
|
3760
4200
|
}> {
|
|
3761
|
-
const
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3767
|
-
|
|
4201
|
+
const observed = await observedGenerationAction(
|
|
4202
|
+
client,
|
|
4203
|
+
"editor",
|
|
4204
|
+
slug,
|
|
4205
|
+
actionType,
|
|
4206
|
+
{
|
|
4207
|
+
workflows: ["editor.batch"],
|
|
4208
|
+
statuses:
|
|
4209
|
+
actionType === "retry_generation"
|
|
4210
|
+
? ["failed", "needs_input"]
|
|
4211
|
+
: ["running", "needs_input", "failed"],
|
|
4212
|
+
},
|
|
4213
|
+
);
|
|
4214
|
+
const { summary } = observed;
|
|
4215
|
+
const runId = summary.run_id;
|
|
4216
|
+
if (typeof runId !== "string" || runId.trim().length === 0) {
|
|
3768
4217
|
throw new Error(
|
|
3769
|
-
|
|
4218
|
+
"Editor generation summary did not include a canonical run id; refresh status before continuing.",
|
|
3770
4219
|
);
|
|
3771
4220
|
}
|
|
3772
4221
|
|
|
3773
|
-
|
|
3774
|
-
if (
|
|
3775
|
-
runId !== null &&
|
|
3776
|
-
(typeof runId !== "string" || runId.trim().length === 0)
|
|
3777
|
-
) {
|
|
4222
|
+
if (summary.workflow !== "editor.batch") {
|
|
3778
4223
|
throw new Error(
|
|
3779
|
-
"
|
|
4224
|
+
"The current generation workflow is not the observed Editor batch; refresh before continuing.",
|
|
3780
4225
|
);
|
|
3781
4226
|
}
|
|
3782
4227
|
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
4228
|
+
const command: {
|
|
4229
|
+
generation_run_id: string;
|
|
4230
|
+
generation_revision: number;
|
|
4231
|
+
} = {
|
|
4232
|
+
generation_run_id: runId,
|
|
4233
|
+
generation_revision: summary.revision as number,
|
|
3786
4234
|
};
|
|
4235
|
+
|
|
4236
|
+
return command;
|
|
3787
4237
|
}
|
|
3788
4238
|
|
|
4239
|
+
registerTool(
|
|
4240
|
+
"provide_generation_input",
|
|
4241
|
+
{
|
|
4242
|
+
title: "Resolve required generation input",
|
|
4243
|
+
description:
|
|
4244
|
+
"Resumes the exact Editor or Short needs-input run advertised by generation_summary. " +
|
|
4245
|
+
"Use action=edit_required_input with a non-empty input object, or action=review_spend_cap " +
|
|
4246
|
+
"with a higher max_credits value. The tool re-reads and sends the current run, revision, " +
|
|
4247
|
+
"and blocking-step fences; stale actions fail without mutating newer work.",
|
|
4248
|
+
inputSchema: {
|
|
4249
|
+
kind: z.enum(["editor", "short"]),
|
|
4250
|
+
slug: z.string().describe("Editor or Short project slug"),
|
|
4251
|
+
action: z.enum(["edit_required_input", "review_spend_cap"]),
|
|
4252
|
+
input: z
|
|
4253
|
+
.record(z.string(), z.unknown())
|
|
4254
|
+
.optional()
|
|
4255
|
+
.describe("Required product input for edit_required_input"),
|
|
4256
|
+
max_credits: z
|
|
4257
|
+
.number()
|
|
4258
|
+
.int()
|
|
4259
|
+
.positive()
|
|
4260
|
+
.max(1_000_000)
|
|
4261
|
+
.optional()
|
|
4262
|
+
.describe("Higher approved cap for review_spend_cap"),
|
|
4263
|
+
},
|
|
4264
|
+
annotations: {
|
|
4265
|
+
title: "Resolve generation input",
|
|
4266
|
+
...WRITE,
|
|
4267
|
+
idempotentHint: true,
|
|
4268
|
+
},
|
|
4269
|
+
},
|
|
4270
|
+
tool(
|
|
4271
|
+
async (
|
|
4272
|
+
args: {
|
|
4273
|
+
kind: ProductKind;
|
|
4274
|
+
slug: string;
|
|
4275
|
+
action: "edit_required_input" | "review_spend_cap";
|
|
4276
|
+
input?: Record<string, unknown>;
|
|
4277
|
+
max_credits?: number;
|
|
4278
|
+
},
|
|
4279
|
+
client,
|
|
4280
|
+
) => {
|
|
4281
|
+
if (
|
|
4282
|
+
args.action === "edit_required_input" &&
|
|
4283
|
+
(!args.input || Object.keys(args.input).length === 0)
|
|
4284
|
+
) {
|
|
4285
|
+
return fail("edit_required_input requires a non-empty input object.");
|
|
4286
|
+
}
|
|
4287
|
+
if (
|
|
4288
|
+
args.action === "review_spend_cap" &&
|
|
4289
|
+
args.max_credits === undefined
|
|
4290
|
+
) {
|
|
4291
|
+
return fail("review_spend_cap requires max_credits.");
|
|
4292
|
+
}
|
|
4293
|
+
|
|
4294
|
+
const observed = await observedGenerationAction(
|
|
4295
|
+
client,
|
|
4296
|
+
args.kind,
|
|
4297
|
+
args.slug,
|
|
4298
|
+
args.action,
|
|
4299
|
+
{ statuses: ["needs_input"], requireStep: true },
|
|
4300
|
+
);
|
|
4301
|
+
const runId = observed.summary.run_id as string;
|
|
4302
|
+
const revision = observed.summary.revision as number;
|
|
4303
|
+
const stepKey = observed.stepKey as string;
|
|
4304
|
+
const body: Record<string, unknown> = {
|
|
4305
|
+
generation_run_id: runId,
|
|
4306
|
+
generation_revision: revision,
|
|
4307
|
+
step_key: stepKey,
|
|
4308
|
+
};
|
|
4309
|
+
if (args.action === "edit_required_input") body.input = args.input;
|
|
4310
|
+
else body.max_credits = args.max_credits;
|
|
4311
|
+
|
|
4312
|
+
const endpoint =
|
|
4313
|
+
args.action === "edit_required_input"
|
|
4314
|
+
? "generation/input"
|
|
4315
|
+
: "generation/spend-cap";
|
|
4316
|
+
const productPath = args.kind === "short" ? "shorts" : "editor";
|
|
4317
|
+
const requestKey = idemKey(
|
|
4318
|
+
"provide-generation-input",
|
|
4319
|
+
args.kind,
|
|
4320
|
+
args.slug,
|
|
4321
|
+
args.action,
|
|
4322
|
+
runId,
|
|
4323
|
+
String(revision),
|
|
4324
|
+
stepKey,
|
|
4325
|
+
JSON.stringify(args.input ?? {}),
|
|
4326
|
+
String(args.max_credits ?? ""),
|
|
4327
|
+
);
|
|
4328
|
+
const response = await client.post<{ data: unknown }>(
|
|
4329
|
+
`/${productPath}/${args.slug}/${endpoint}`,
|
|
4330
|
+
body,
|
|
4331
|
+
requestKey,
|
|
4332
|
+
);
|
|
4333
|
+
const resumedSummary = generationSummarySchema.safeParse(
|
|
4334
|
+
asRecord(asRecord(response).data).generation_summary,
|
|
4335
|
+
);
|
|
4336
|
+
if (!resumedSummary.success) {
|
|
4337
|
+
throw new Error(
|
|
4338
|
+
`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.`,
|
|
4339
|
+
);
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
return ok({
|
|
4343
|
+
slug: args.slug,
|
|
4344
|
+
kind: args.kind,
|
|
4345
|
+
action: args.action,
|
|
4346
|
+
generation_summary: resumedSummary.data,
|
|
4347
|
+
next: "wait_for_completion",
|
|
4348
|
+
});
|
|
4349
|
+
},
|
|
4350
|
+
),
|
|
4351
|
+
);
|
|
4352
|
+
|
|
3789
4353
|
// ── AI assists (free daily quota) ───────────────────────────────────────────
|
|
3790
4354
|
|
|
3791
4355
|
registerTool(
|
|
@@ -3973,6 +4537,7 @@ registerTool(
|
|
|
3973
4537
|
function conciseEditorState(value: unknown): Record<string, unknown> {
|
|
3974
4538
|
const data = asRecord(value);
|
|
3975
4539
|
const keys = [
|
|
4540
|
+
"generation_summary",
|
|
3976
4541
|
"slug",
|
|
3977
4542
|
"status",
|
|
3978
4543
|
"autopilot_status",
|
|
@@ -4028,11 +4593,12 @@ registerTool(
|
|
|
4028
4593
|
{
|
|
4029
4594
|
title: "Get editor state (review step)",
|
|
4030
4595
|
description:
|
|
4031
|
-
"Returns
|
|
4596
|
+
"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.",
|
|
4032
4597
|
inputSchema: {
|
|
4033
4598
|
slug: z.string().describe("Editor project slug"),
|
|
4034
|
-
response_format: z.enum(["concise", "full"]).default("
|
|
4599
|
+
response_format: z.enum(["concise", "full"]).default("concise"),
|
|
4035
4600
|
},
|
|
4601
|
+
outputSchema: getEditorOutput,
|
|
4036
4602
|
annotations: { title: "Get editor", ...RO },
|
|
4037
4603
|
},
|
|
4038
4604
|
tool(
|
|
@@ -4041,9 +4607,23 @@ registerTool(
|
|
|
4041
4607
|
client,
|
|
4042
4608
|
) => {
|
|
4043
4609
|
const res = await client.get<{ data: unknown }>(`/editor/${args.slug}`);
|
|
4044
|
-
const data = asRecord(res).data;
|
|
4610
|
+
const data = asRecord(asRecord(res).data);
|
|
4611
|
+
const summary = generationSummarySchema.safeParse(
|
|
4612
|
+
data.generation_summary,
|
|
4613
|
+
);
|
|
4614
|
+
if (!summary.success) {
|
|
4615
|
+
throw new Error(
|
|
4616
|
+
"The Hubfluencer Editor response did not contain a valid generation_summary. Refresh after the API and MCP contracts are upgraded together.",
|
|
4617
|
+
);
|
|
4618
|
+
}
|
|
4619
|
+
const authoritativeData = {
|
|
4620
|
+
...data,
|
|
4621
|
+
generation_summary: summary.data,
|
|
4622
|
+
};
|
|
4045
4623
|
return ok(
|
|
4046
|
-
args.response_format === "full"
|
|
4624
|
+
args.response_format === "full"
|
|
4625
|
+
? authoritativeData
|
|
4626
|
+
: conciseEditorState(authoritativeData),
|
|
4047
4627
|
);
|
|
4048
4628
|
},
|
|
4049
4629
|
),
|
|
@@ -4410,15 +4990,87 @@ registerTool(
|
|
|
4410
4990
|
annotations: { title: "Generate segment", ...WRITE, idempotentHint: false },
|
|
4411
4991
|
},
|
|
4412
4992
|
tool(async (args: { slug: string; segment_id: number | string }, client) => {
|
|
4993
|
+
const requestKey = idemKey(
|
|
4994
|
+
"generate-segment",
|
|
4995
|
+
args.slug,
|
|
4996
|
+
String(args.segment_id),
|
|
4997
|
+
randomUUID(),
|
|
4998
|
+
);
|
|
4413
4999
|
const res = await client.post<{ data: unknown }>(
|
|
4414
5000
|
`/editor/${args.slug}/segments/${String(args.segment_id)}/generate`,
|
|
4415
5001
|
undefined,
|
|
4416
|
-
|
|
5002
|
+
requestKey,
|
|
4417
5003
|
);
|
|
4418
5004
|
return ok(asRecord(res).data ?? res);
|
|
4419
5005
|
}),
|
|
4420
5006
|
);
|
|
4421
5007
|
|
|
5008
|
+
registerTool(
|
|
5009
|
+
"retry_segment_generation",
|
|
5010
|
+
{
|
|
5011
|
+
title: "Retry the current failed segment generation",
|
|
5012
|
+
description:
|
|
5013
|
+
"Retries the exact failed Editor scene step in place for 0 additional credits. This is only " +
|
|
5014
|
+
"available when generation_summary advertises retry_generation for editor.scene.generate or " +
|
|
5015
|
+
"editor.scene.regenerate. The tool re-reads and submits the current run, revision, and step fences, " +
|
|
5016
|
+
"so it cannot retry a stale attempt or a different current scene.",
|
|
5017
|
+
inputSchema: {
|
|
5018
|
+
slug: z.string().describe("Editor project slug"),
|
|
5019
|
+
segment_id: z
|
|
5020
|
+
.union([z.number(), z.string()])
|
|
5021
|
+
.describe("Failed current segment id from get_editor"),
|
|
5022
|
+
},
|
|
5023
|
+
annotations: {
|
|
5024
|
+
title: "Retry segment generation",
|
|
5025
|
+
...WRITE,
|
|
5026
|
+
idempotentHint: true,
|
|
5027
|
+
},
|
|
5028
|
+
},
|
|
5029
|
+
tool(async (args: { slug: string; segment_id: number | string }, client) => {
|
|
5030
|
+
const observed = await observedGenerationAction(
|
|
5031
|
+
client,
|
|
5032
|
+
"editor",
|
|
5033
|
+
args.slug,
|
|
5034
|
+
"retry_generation",
|
|
5035
|
+
{
|
|
5036
|
+
workflows: ["editor.scene.generate", "editor.scene.regenerate"],
|
|
5037
|
+
statuses: ["failed"],
|
|
5038
|
+
requireStep: true,
|
|
5039
|
+
},
|
|
5040
|
+
);
|
|
5041
|
+
const runId = observed.summary.run_id as string;
|
|
5042
|
+
const revision = observed.summary.revision as number;
|
|
5043
|
+
const stepKey = observed.stepKey as string;
|
|
5044
|
+
const requestKey = idemKey(
|
|
5045
|
+
"retry-segment-generation",
|
|
5046
|
+
args.slug,
|
|
5047
|
+
String(args.segment_id),
|
|
5048
|
+
runId,
|
|
5049
|
+
String(revision),
|
|
5050
|
+
stepKey,
|
|
5051
|
+
);
|
|
5052
|
+
const response = await client.post<{ data: unknown }>(
|
|
5053
|
+
`/editor/${args.slug}/segments/${String(args.segment_id)}/retry`,
|
|
5054
|
+
{
|
|
5055
|
+
generation_run_id: runId,
|
|
5056
|
+
generation_revision: revision,
|
|
5057
|
+
step_key: stepKey,
|
|
5058
|
+
},
|
|
5059
|
+
requestKey,
|
|
5060
|
+
);
|
|
5061
|
+
const data = asRecord(asRecord(response).data);
|
|
5062
|
+
const retrySummary = generationSummarySchema.safeParse(
|
|
5063
|
+
data.generation_summary,
|
|
5064
|
+
);
|
|
5065
|
+
if (!retrySummary.success) {
|
|
5066
|
+
throw new Error(
|
|
5067
|
+
"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.",
|
|
5068
|
+
);
|
|
5069
|
+
}
|
|
5070
|
+
return ok({ ...data, generation_summary: retrySummary.data });
|
|
5071
|
+
}),
|
|
5072
|
+
);
|
|
5073
|
+
|
|
4422
5074
|
registerTool(
|
|
4423
5075
|
"cancel_segment_generation",
|
|
4424
5076
|
{
|
|
@@ -4428,7 +5080,8 @@ registerTool(
|
|
|
4428
5080
|
"prevents a delayed command from cancelling a newer attempt. The token is returned by get_editor. " +
|
|
4429
5081
|
"Refunds that scene's charge idempotently (a free retry creates no refund). Use cancel_editor_batch or " +
|
|
4430
5082
|
"cancel_autopilot for workflow-owned scenes. After cancellation, edit the prompt if needed and call " +
|
|
4431
|
-
"generate_segment again.
|
|
5083
|
+
"generate_segment again. The raw REST route requires Idempotency-Key; reuse one only for transport retries " +
|
|
5084
|
+
"of this exact fenced cancellation. Scope: video:generate.",
|
|
4432
5085
|
inputSchema: {
|
|
4433
5086
|
slug: z.string().describe("Editor project slug"),
|
|
4434
5087
|
segment_id: z
|
|
@@ -4456,10 +5109,41 @@ registerTool(
|
|
|
4456
5109
|
},
|
|
4457
5110
|
client,
|
|
4458
5111
|
) => {
|
|
5112
|
+
const observed = await observedGenerationAction(
|
|
5113
|
+
client,
|
|
5114
|
+
"editor",
|
|
5115
|
+
args.slug,
|
|
5116
|
+
"cancel_generation",
|
|
5117
|
+
{
|
|
5118
|
+
workflows: ["editor.scene.generate", "editor.scene.regenerate"],
|
|
5119
|
+
statuses: ["queued", "running"],
|
|
5120
|
+
},
|
|
5121
|
+
);
|
|
5122
|
+
const segments = Array.isArray(observed.state.segments)
|
|
5123
|
+
? observed.state.segments.map(asRecord)
|
|
5124
|
+
: [];
|
|
5125
|
+
const segment = segments.find(
|
|
5126
|
+
(candidate) => String(candidate.id) === String(args.segment_id),
|
|
5127
|
+
);
|
|
5128
|
+
if (
|
|
5129
|
+
!segment ||
|
|
5130
|
+
segment.generation_claim_token !== args.generation_claim_token
|
|
5131
|
+
) {
|
|
5132
|
+
throw new Error(
|
|
5133
|
+
"The supplied generation_claim_token does not match the scene owned by the authoritative generation action; refresh get_editor before cancelling.",
|
|
5134
|
+
);
|
|
5135
|
+
}
|
|
4459
5136
|
const res = await client.post<{ data: unknown }>(
|
|
4460
5137
|
`/editor/${args.slug}/segments/${String(args.segment_id)}/cancel`,
|
|
4461
5138
|
{ expected_generation_claim_token: args.generation_claim_token },
|
|
4462
|
-
|
|
5139
|
+
idemKey(
|
|
5140
|
+
"cancel-segment-generation",
|
|
5141
|
+
args.slug,
|
|
5142
|
+
String(args.segment_id),
|
|
5143
|
+
observed.summary.run_id as string,
|
|
5144
|
+
String(observed.summary.revision),
|
|
5145
|
+
args.generation_claim_token ?? "none",
|
|
5146
|
+
),
|
|
4463
5147
|
);
|
|
4464
5148
|
return ok({
|
|
4465
5149
|
...asRecord(asRecord(res).data ?? res),
|
|
@@ -4476,15 +5160,14 @@ registerTool(
|
|
|
4476
5160
|
{
|
|
4477
5161
|
title: "Cancel a running Autopilot run",
|
|
4478
5162
|
description:
|
|
4479
|
-
"Stops an in-flight Autopilot run
|
|
4480
|
-
"orchestration chain (scenario → scenes → narration → voice → music → render) advances no further.
|
|
4481
|
-
"this when Autopilot is still running and you want to halt it. Any scenes already charged stay charged and " +
|
|
5163
|
+
"Stops an in-flight Autopilot run only when generation_summary advertises cancel_generation for the " +
|
|
5164
|
+
"editor.autopilot workflow. The orchestration chain (scenario → scenes → narration → voice → music → render) advances no further. Any scenes already charged stay charged and " +
|
|
4482
5165
|
"all completed work (generated scenes, voice, music, a finished render) is kept — a step already enqueued " +
|
|
4483
|
-
"may still finish, but nothing new is started.
|
|
4484
|
-
"
|
|
4485
|
-
"
|
|
4486
|
-
"
|
|
4487
|
-
"
|
|
5166
|
+
"may still finish, but nothing new is started. Transport retries are idempotent for the same observed run; " +
|
|
5167
|
+
"if cancellation is no longer advertised the tool fails closed without POST. It does NOT itself refund or " +
|
|
5168
|
+
"re-run anything. For an advertised Editor batch cancellation use cancel_editor_batch; to stop one " +
|
|
5169
|
+
"individually-generated scene use cancel_segment_generation. The raw REST route requires Idempotency-Key. " +
|
|
5170
|
+
"Scope: video:generate.",
|
|
4488
5171
|
inputSchema: { slug: z.string().describe("Editor project slug") },
|
|
4489
5172
|
annotations: {
|
|
4490
5173
|
title: "Cancel autopilot",
|
|
@@ -4495,14 +5178,35 @@ registerTool(
|
|
|
4495
5178
|
},
|
|
4496
5179
|
},
|
|
4497
5180
|
tool(async (args: { slug: string }, client) => {
|
|
5181
|
+
const observed = await observedGenerationAction(
|
|
5182
|
+
client,
|
|
5183
|
+
"editor",
|
|
5184
|
+
args.slug,
|
|
5185
|
+
"cancel_generation",
|
|
5186
|
+
{
|
|
5187
|
+
workflows: ["editor.autopilot"],
|
|
5188
|
+
statuses: ["queued", "running"],
|
|
5189
|
+
},
|
|
5190
|
+
);
|
|
5191
|
+
const receiptKey = idemKey(
|
|
5192
|
+
"cancel-autopilot",
|
|
5193
|
+
args.slug,
|
|
5194
|
+
observed.summary.run_id ?? randomUUID(),
|
|
5195
|
+
String(observed.summary.revision),
|
|
5196
|
+
);
|
|
5197
|
+
|
|
4498
5198
|
const res = await client.post<{ data: unknown }>(
|
|
4499
5199
|
`/editor/${args.slug}/autopilot/cancel`,
|
|
4500
|
-
|
|
4501
|
-
|
|
5200
|
+
{
|
|
5201
|
+
generation_run_id: observed.summary.run_id,
|
|
5202
|
+
},
|
|
5203
|
+
receiptKey,
|
|
4502
5204
|
);
|
|
4503
5205
|
return ok({
|
|
4504
5206
|
...asRecord(asRecord(res).data ?? res),
|
|
4505
5207
|
slug: args.slug,
|
|
5208
|
+
run_id: observed.summary.run_id,
|
|
5209
|
+
workflow: observed.summary.workflow,
|
|
4506
5210
|
charged: false,
|
|
4507
5211
|
next: "get_status",
|
|
4508
5212
|
});
|
|
@@ -4547,10 +5251,16 @@ registerTool(
|
|
|
4547
5251
|
args: { slug: string; segment_id: number | string; prompt?: string },
|
|
4548
5252
|
client,
|
|
4549
5253
|
) => {
|
|
5254
|
+
const requestKey = idemKey(
|
|
5255
|
+
"regenerate-segment",
|
|
5256
|
+
args.slug,
|
|
5257
|
+
String(args.segment_id),
|
|
5258
|
+
randomUUID(),
|
|
5259
|
+
);
|
|
4550
5260
|
const res = await client.post<{ data: unknown }>(
|
|
4551
5261
|
`/editor/${args.slug}/segments/${String(args.segment_id)}/regenerate`,
|
|
4552
5262
|
args.prompt !== undefined ? { prompt: args.prompt } : undefined,
|
|
4553
|
-
|
|
5263
|
+
requestKey,
|
|
4554
5264
|
);
|
|
4555
5265
|
return ok({
|
|
4556
5266
|
...asRecord(asRecord(res).data ?? res),
|
|
@@ -4589,6 +5299,7 @@ registerTool(
|
|
|
4589
5299
|
// Start it before the initial retrying GET so no preparatory request sits
|
|
4590
5300
|
// outside the advertised ~280s tool budget.
|
|
4591
5301
|
const overallDeadline = Date.now() + 280_000;
|
|
5302
|
+
const requestNonce = randomUUID();
|
|
4592
5303
|
const res = await client.get<{ data: unknown }>(
|
|
4593
5304
|
`/editor/${args.slug}`,
|
|
4594
5305
|
undefined,
|
|
@@ -4663,7 +5374,12 @@ registerTool(
|
|
|
4663
5374
|
await client.post(
|
|
4664
5375
|
`/editor/${args.slug}/segments/${String(sid)}/generate`,
|
|
4665
5376
|
undefined,
|
|
4666
|
-
|
|
5377
|
+
idemKey(
|
|
5378
|
+
"generate-all-segment",
|
|
5379
|
+
args.slug,
|
|
5380
|
+
String(sid),
|
|
5381
|
+
requestNonce,
|
|
5382
|
+
),
|
|
4667
5383
|
);
|
|
4668
5384
|
} catch (e) {
|
|
4669
5385
|
return ok({
|
|
@@ -4813,8 +5529,17 @@ registerTool(
|
|
|
4813
5529
|
},
|
|
4814
5530
|
},
|
|
4815
5531
|
tool(async (args: { slug: string; auto_unlock?: boolean }, client) => {
|
|
5532
|
+
// Narration is non-deterministic, so each explicit tool invocation owns a
|
|
5533
|
+
// fresh logical-attempt key. Keep that key outside withAssist so its one
|
|
5534
|
+
// post-unlock retry reuses the same replay fence instead of forking a
|
|
5535
|
+
// second narration attempt.
|
|
5536
|
+
const requestKey = idemKey("generate-narration", args.slug, randomUUID());
|
|
4816
5537
|
const res = await withAssist(client, args.auto_unlock ?? false, () =>
|
|
4817
|
-
client.post<{ data: unknown }>(
|
|
5538
|
+
client.post<{ data: unknown }>(
|
|
5539
|
+
`/editor/${args.slug}/generate-narration`,
|
|
5540
|
+
undefined,
|
|
5541
|
+
requestKey,
|
|
5542
|
+
),
|
|
4818
5543
|
);
|
|
4819
5544
|
return ok(asRecord(res).data ?? res);
|
|
4820
5545
|
}),
|
|
@@ -4899,10 +5624,11 @@ registerTool(
|
|
|
4899
5624
|
if (args.genre !== undefined) body.genre = args.genre;
|
|
4900
5625
|
if (args.tempo !== undefined) body.tempo = args.tempo;
|
|
4901
5626
|
if (args.instruments !== undefined) body.instruments = args.instruments;
|
|
5627
|
+
const requestKey = idemKey("generate-music", args.slug, randomUUID());
|
|
4902
5628
|
const res = await client.post<{ data: unknown }>(
|
|
4903
5629
|
`/editor/${args.slug}/generate-music`,
|
|
4904
5630
|
body,
|
|
4905
|
-
|
|
5631
|
+
requestKey,
|
|
4906
5632
|
);
|
|
4907
5633
|
return ok({
|
|
4908
5634
|
...asRecord(asRecord(res).data ?? res),
|
|
@@ -4932,10 +5658,11 @@ registerTool(
|
|
|
4932
5658
|
},
|
|
4933
5659
|
tool(async (args: { slug: string }, client) => {
|
|
4934
5660
|
try {
|
|
5661
|
+
const requestKey = idemKey("render-editor", args.slug, randomUUID());
|
|
4935
5662
|
const res = await client.post<{ data: unknown }>(
|
|
4936
5663
|
`/editor/${args.slug}/render`,
|
|
4937
5664
|
undefined,
|
|
4938
|
-
|
|
5665
|
+
requestKey,
|
|
4939
5666
|
);
|
|
4940
5667
|
return ok(asRecord(res).data ?? res);
|
|
4941
5668
|
} catch (e) {
|
|
@@ -5102,8 +5829,44 @@ registerTool(
|
|
|
5102
5829
|
annotations: { title: "Retry render", ...WRITE, idempotentHint: false },
|
|
5103
5830
|
},
|
|
5104
5831
|
tool(async (args: { slug: string; render_id: number | string }, client) => {
|
|
5832
|
+
const observed = await observedGenerationAction(
|
|
5833
|
+
client,
|
|
5834
|
+
"editor",
|
|
5835
|
+
args.slug,
|
|
5836
|
+
"retry_generation",
|
|
5837
|
+
{
|
|
5838
|
+
workflows: ["editor.render", "editor.render.retry"],
|
|
5839
|
+
statuses: ["failed"],
|
|
5840
|
+
},
|
|
5841
|
+
);
|
|
5842
|
+
const runId = observed.summary.run_id as string;
|
|
5843
|
+
const revision = observed.summary.revision as number;
|
|
5844
|
+
const rendersResponse = await client.get<{ data: unknown }>(
|
|
5845
|
+
`/editor/${args.slug}/renders`,
|
|
5846
|
+
);
|
|
5847
|
+
const renders = asRecord(rendersResponse).data;
|
|
5848
|
+
const render = Array.isArray(renders)
|
|
5849
|
+
? renders
|
|
5850
|
+
.map(asRecord)
|
|
5851
|
+
.find((candidate) => String(candidate.id) === String(args.render_id))
|
|
5852
|
+
: undefined;
|
|
5853
|
+
if (render?.status !== "failed" || render.generation_run_id !== runId) {
|
|
5854
|
+
throw new Error(
|
|
5855
|
+
"The selected render is not the failed render owned by the current retry action; refresh list_renders before continuing.",
|
|
5856
|
+
);
|
|
5857
|
+
}
|
|
5858
|
+
|
|
5859
|
+
const requestKey = idemKey(
|
|
5860
|
+
"retry-render",
|
|
5861
|
+
args.slug,
|
|
5862
|
+
String(args.render_id),
|
|
5863
|
+
runId,
|
|
5864
|
+
String(revision),
|
|
5865
|
+
);
|
|
5105
5866
|
const res = await client.post<{ data: unknown }>(
|
|
5106
5867
|
`/editor/${args.slug}/renders/${String(args.render_id)}/retry`,
|
|
5868
|
+
{ generation_revision: revision },
|
|
5869
|
+
requestKey,
|
|
5107
5870
|
);
|
|
5108
5871
|
return ok(asRecord(res).data ?? res);
|
|
5109
5872
|
}),
|
|
@@ -5665,7 +6428,9 @@ registerTool(
|
|
|
5665
6428
|
treatment: z
|
|
5666
6429
|
.enum(["throughout", "end_card", "none"])
|
|
5667
6430
|
.optional()
|
|
5668
|
-
.describe(
|
|
6431
|
+
.describe(
|
|
6432
|
+
"When the logo appears. Omit for automatic treatment: transparent wordmarks run throughout; opaque square artwork moves to the end card.",
|
|
6433
|
+
),
|
|
5669
6434
|
},
|
|
5670
6435
|
annotations: { title: "Set Short logo", ...WRITE, idempotentHint: false },
|
|
5671
6436
|
},
|
|
@@ -5690,7 +6455,7 @@ registerTool(
|
|
|
5690
6455
|
{
|
|
5691
6456
|
s3_key,
|
|
5692
6457
|
position: args.position ?? "top-right",
|
|
5693
|
-
treatment: args.treatment
|
|
6458
|
+
...(args.treatment ? { treatment: args.treatment } : {}),
|
|
5694
6459
|
},
|
|
5695
6460
|
);
|
|
5696
6461
|
return ok(asRecord(res).data ?? res);
|
|
@@ -5760,7 +6525,10 @@ registerTool(
|
|
|
5760
6525
|
"Returns a normalized status {stage, terminal, ready, video_url, error} for a short, editor, " +
|
|
5761
6526
|
"tracking, or slider project. ready:true means the post-ready MP4 is at video_url — except for a " +
|
|
5762
6527
|
"slider (a carousel of stills, not a video): its video_url is always null, ready:true means every " +
|
|
5763
|
-
"slide is composited, and you read the per-slide image URLs with get_slider.
|
|
6528
|
+
"slide is composited, and you read the per-slide image URLs with get_slider. Editor/Short results also " +
|
|
6529
|
+
"include the required generation_summary; normalized lifecycle fields are derived from that authority. Editor " +
|
|
6530
|
+
"delivery freshness is separate: a completed stale timeline returns stage=editing_required and ready=false even " +
|
|
6531
|
+
"when a historical MP4 remains. During paid short " +
|
|
5764
6532
|
"generation, segments_completed / segments_total and active_segment_positions report real clip progress.",
|
|
5765
6533
|
inputSchema: { slug: z.string(), kind: kindSchema },
|
|
5766
6534
|
outputSchema: getStatusOutput,
|
|
@@ -5777,13 +6545,11 @@ registerTool(
|
|
|
5777
6545
|
{
|
|
5778
6546
|
title: "Wait for a render to finish",
|
|
5779
6547
|
description:
|
|
5780
|
-
"Polls status (emitting progress) until terminal or the wait budget is exhausted. " +
|
|
5781
|
-
"
|
|
5782
|
-
"
|
|
5783
|
-
|
|
5784
|
-
|
|
5785
|
-
'stage "idle" — a draft with nothing generating, start generation first) that only a further tool call ' +
|
|
5786
|
-
"can advance — do NOT just re-poll those. " +
|
|
6548
|
+
"Polls status (emitting progress) until terminal, the server withdraws polling, or the wait budget is exhausted. " +
|
|
6549
|
+
"For Editor/Short it polls only while generation_summary.timing.poll_after_ms is a positive integer; null stops polling. The " +
|
|
6550
|
+
"poll_interval_seconds argument is only a fallback for tracking/slider. timed_out is true only when the client budget " +
|
|
6551
|
+
"expires; polling_stopped is true when the server requests a stop so callers can invoke the advertised action immediately. " +
|
|
6552
|
+
"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. " +
|
|
5787
6553
|
"Pass save_path to download the finished MP4 when it's ready (e.g. to resume a make_video that timed out mid-render). " +
|
|
5788
6554
|
'Works for kind:"slider" too, but a carousel has no MP4 — it just goes terminal when composited; read the slide ' +
|
|
5789
6555
|
"images with get_slider (save_path is ignored for sliders).",
|
|
@@ -5803,7 +6569,9 @@ registerTool(
|
|
|
5803
6569
|
.min(5)
|
|
5804
6570
|
.max(60)
|
|
5805
6571
|
.optional()
|
|
5806
|
-
.describe(
|
|
6572
|
+
.describe(
|
|
6573
|
+
"Tracking/slider fallback interval (default 15s). Editor/Short use generation_summary.timing.poll_after_ms.",
|
|
6574
|
+
),
|
|
5807
6575
|
save_path: z
|
|
5808
6576
|
.string()
|
|
5809
6577
|
.optional()
|
|
@@ -5849,7 +6617,13 @@ registerTool(
|
|
|
5849
6617
|
saved_to = (await downloadTo(status.video_url, args.save_path))
|
|
5850
6618
|
.saved_to;
|
|
5851
6619
|
}
|
|
5852
|
-
const
|
|
6620
|
+
const polling_stopped = serverStoppedPolling(args.kind, status);
|
|
6621
|
+
const out = {
|
|
6622
|
+
...status,
|
|
6623
|
+
saved_to,
|
|
6624
|
+
timed_out: !status.terminal && !polling_stopped,
|
|
6625
|
+
polling_stopped,
|
|
6626
|
+
};
|
|
5853
6627
|
const links =
|
|
5854
6628
|
status.ready && status.video_url
|
|
5855
6629
|
? [mp4Link(status.video_url, args.slug)]
|
|
@@ -6665,7 +7439,7 @@ registerTool(
|
|
|
6665
7439
|
draft_script: z.string().optional(),
|
|
6666
7440
|
caption: z.string().optional(),
|
|
6667
7441
|
hashtags: z.array(z.string()).optional(),
|
|
6668
|
-
credit_estimate: z.record(z.unknown()).optional(),
|
|
7442
|
+
credit_estimate: z.record(z.string(), z.unknown()).optional(),
|
|
6669
7443
|
}),
|
|
6670
7444
|
)
|
|
6671
7445
|
.describe("1-12 variations to persist (echo them from the board)"),
|
|
@@ -8025,17 +8799,28 @@ registerTool(
|
|
|
8025
8799
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
8026
8800
|
|
|
8027
8801
|
async function main() {
|
|
8802
|
+
const args = withoutProfileArgs(process.argv.slice(2));
|
|
8028
8803
|
// `hubfluencer-mcp login [name]` runs the device-link login instead of the
|
|
8029
8804
|
// server. Single bin keeps the install idiomatic: `npx -y @hubfluencer/mcp`
|
|
8030
8805
|
// starts the server; `npx -y @hubfluencer/mcp login` connects an account.
|
|
8031
|
-
if (
|
|
8032
|
-
await runLogin(
|
|
8806
|
+
if (args[0] === "login") {
|
|
8807
|
+
await runLogin(args[1]);
|
|
8808
|
+
return;
|
|
8809
|
+
}
|
|
8810
|
+
if (args[0] === "doctor") {
|
|
8811
|
+
await runDoctor(activeToolProfile, args.includes("--json"));
|
|
8812
|
+
return;
|
|
8813
|
+
}
|
|
8814
|
+
if (args[0] === "install-skill") {
|
|
8815
|
+
await runInstallSkill(args);
|
|
8033
8816
|
return;
|
|
8034
8817
|
}
|
|
8035
8818
|
const transport = new StdioServerTransport();
|
|
8036
8819
|
await server.connect(transport);
|
|
8037
8820
|
// stderr is safe; stdout is reserved for the MCP protocol.
|
|
8038
|
-
console.error(
|
|
8821
|
+
console.error(
|
|
8822
|
+
`hubfluencer-mcp running on stdio (profile: ${activeToolProfile})`,
|
|
8823
|
+
);
|
|
8039
8824
|
}
|
|
8040
8825
|
|
|
8041
8826
|
main().catch((e) => {
|