@hubfluencer/mcp 0.5.0 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -31
- package/dist/index.js +2706 -133
- package/package.json +9 -1
- package/src/campaign.ts +797 -0
- package/src/client.ts +195 -36
- package/src/core.ts +633 -24
- package/src/index.ts +3025 -121
- package/src/output-schemas.ts +360 -0
- package/src/perf-review-assets.ts +470 -0
- package/src/series-calendar.ts +351 -0
- package/src/uploads.ts +335 -42
- package/src/version.ts +18 -0
package/src/index.ts
CHANGED
|
@@ -16,44 +16,165 @@
|
|
|
16
16
|
* → render → wait_for_completion → download_result
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import { randomUUID } from "node:crypto";
|
|
20
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
21
|
+
import { dirname } from "node:path";
|
|
20
22
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
21
23
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
22
24
|
import { z } from "zod";
|
|
25
|
+
import {
|
|
26
|
+
buildPlanBody,
|
|
27
|
+
type CreateCampaignArgs,
|
|
28
|
+
pickBrandProfileFields,
|
|
29
|
+
pickProductProfileFields,
|
|
30
|
+
runCreateCampaign,
|
|
31
|
+
summarizePack,
|
|
32
|
+
summarizePackItem,
|
|
33
|
+
summarizePackListEntry,
|
|
34
|
+
validatePlanSource,
|
|
35
|
+
validateVariationsBatch,
|
|
36
|
+
} from "./campaign.js";
|
|
23
37
|
import {
|
|
24
38
|
clientFromEnv,
|
|
25
39
|
type HubfluencerClient,
|
|
26
40
|
type HubfluencerError,
|
|
27
41
|
} from "./client.js";
|
|
28
42
|
import {
|
|
43
|
+
addSegmentKey,
|
|
29
44
|
asRecord,
|
|
30
45
|
assertSafeFetchUrl,
|
|
46
|
+
decideTrackingPlan,
|
|
47
|
+
editorAdAutopilotKey,
|
|
48
|
+
editorAdCreateKey,
|
|
49
|
+
formatApiErrorMessage,
|
|
31
50
|
idemKey,
|
|
32
51
|
inferKind,
|
|
33
52
|
type Kind,
|
|
53
|
+
makeVideoAutopilotKey,
|
|
54
|
+
makeVideoEditorCreateKey,
|
|
34
55
|
type NormalizedStatus,
|
|
35
56
|
normalizeStatus,
|
|
36
57
|
resolveSavePath,
|
|
58
|
+
sliderCreateKey,
|
|
59
|
+
startStateDiscriminator,
|
|
60
|
+
type TrackingSnapshot,
|
|
61
|
+
validateKind,
|
|
62
|
+
validateLanguage,
|
|
63
|
+
validateProductPrompt,
|
|
37
64
|
} from "./core.js";
|
|
38
65
|
import { runLogin } from "./login.js";
|
|
39
66
|
import {
|
|
67
|
+
createCampaignOutput,
|
|
68
|
+
createDraftOutput,
|
|
69
|
+
createEditorOutput,
|
|
70
|
+
downloadResultOutput,
|
|
71
|
+
getCampaignPackOutput,
|
|
72
|
+
getCreditsOutput,
|
|
73
|
+
getRecommendationsOutput,
|
|
74
|
+
getSeriesDashboardOutput,
|
|
75
|
+
getSliderOutput,
|
|
76
|
+
getStatusOutput,
|
|
77
|
+
listProjectsOutput,
|
|
78
|
+
listScheduledPostsOutput,
|
|
79
|
+
makeVideoOutput,
|
|
80
|
+
waitForCompletionOutput,
|
|
81
|
+
} from "./output-schemas.js";
|
|
82
|
+
import {
|
|
83
|
+
assetSubscriptionErrorText,
|
|
84
|
+
buildPerformanceBody,
|
|
85
|
+
buildReviewLinkBody,
|
|
86
|
+
type CreateReviewLinkResult,
|
|
87
|
+
PERFORMANCE_PLATFORMS,
|
|
88
|
+
PERFORMANCE_SOURCES,
|
|
89
|
+
pickCreativeAttributeFields,
|
|
90
|
+
REVIEW_LINK_MAX_EXPIRES_IN_HOURS,
|
|
91
|
+
REVIEW_LINK_MIN_EXPIRES_IN_HOURS,
|
|
92
|
+
type RecordPerformanceArgs,
|
|
93
|
+
reviewWebOrigin,
|
|
94
|
+
shapeCreateReviewLink,
|
|
95
|
+
summarizeAssetQuota,
|
|
96
|
+
summarizeCatalogAsset,
|
|
97
|
+
summarizePerformance,
|
|
98
|
+
summarizeReviewLink,
|
|
99
|
+
validatePerformanceMetrics,
|
|
100
|
+
validateReviewOutput,
|
|
101
|
+
} from "./perf-review-assets.js";
|
|
102
|
+
import {
|
|
103
|
+
buildScheduledPostBody,
|
|
104
|
+
calendarScopeErrorText,
|
|
105
|
+
MAX_PENDING_SCHEDULED_POSTS,
|
|
106
|
+
pickSeriesFields,
|
|
107
|
+
SCHEDULED_POST_PLATFORMS,
|
|
108
|
+
SCHEDULED_POST_STATUSES,
|
|
109
|
+
summarizeDashboard,
|
|
110
|
+
summarizeEpisode,
|
|
111
|
+
summarizeScheduledPost,
|
|
112
|
+
summarizeSeries,
|
|
113
|
+
validateScheduledPostCopy,
|
|
114
|
+
} from "./series-calendar.js";
|
|
115
|
+
import {
|
|
116
|
+
CATALOG_ASSET_EXTS,
|
|
40
117
|
IMAGE_EXTS,
|
|
118
|
+
resolveVideoReadPath,
|
|
119
|
+
uploadCatalogAsset,
|
|
41
120
|
uploadImageFile,
|
|
42
121
|
uploadShortPoster,
|
|
43
122
|
uploadVideoFile,
|
|
44
123
|
VIDEO_EXTS,
|
|
45
124
|
} from "./uploads.js";
|
|
125
|
+
import { USER_AGENT, VERSION } from "./version.js";
|
|
46
126
|
|
|
47
127
|
async function fetchStatus(
|
|
48
128
|
client: HubfluencerClient,
|
|
49
129
|
kind: Kind,
|
|
50
130
|
slug: string,
|
|
131
|
+
deadline?: number,
|
|
51
132
|
): Promise<NormalizedStatus> {
|
|
52
|
-
const path =
|
|
53
|
-
|
|
133
|
+
const path =
|
|
134
|
+
kind === "short"
|
|
135
|
+
? `/shorts/${slug}`
|
|
136
|
+
: kind === "tracking"
|
|
137
|
+
? `/tracking/${slug}`
|
|
138
|
+
: kind === "slider"
|
|
139
|
+
? `/sliders/${slug}`
|
|
140
|
+
: `/editor/${slug}`;
|
|
141
|
+
// On the poll path the caller passes the loop deadline so this GET's retry
|
|
142
|
+
// sequence can't overrun the wait budget (see client.request `deadline`).
|
|
143
|
+
const res = await client.get<{ data: unknown }>(path, undefined, deadline);
|
|
54
144
|
return normalizeStatus(kind, slug, asRecord(res).data);
|
|
55
145
|
}
|
|
56
146
|
|
|
147
|
+
function isRecompositeInProgressConflict(e: unknown): boolean {
|
|
148
|
+
const err = e as Partial<HubfluencerError>;
|
|
149
|
+
if (err?.status !== 409) return false;
|
|
150
|
+
const texts: string[] = [];
|
|
151
|
+
if (typeof err.code === "string") texts.push(err.code);
|
|
152
|
+
if (typeof err.message === "string") texts.push(err.message);
|
|
153
|
+
const body = err.body;
|
|
154
|
+
if (body && typeof body === "object") {
|
|
155
|
+
const record = body as Record<string, unknown>;
|
|
156
|
+
if (typeof record.error === "string") texts.push(record.error);
|
|
157
|
+
if (typeof record.message === "string") texts.push(record.message);
|
|
158
|
+
const nested = record.error;
|
|
159
|
+
if (nested && typeof nested === "object") {
|
|
160
|
+
const nestedRecord = nested as Record<string, unknown>;
|
|
161
|
+
if (typeof nestedRecord.code === "string") texts.push(nestedRecord.code);
|
|
162
|
+
if (typeof nestedRecord.message === "string")
|
|
163
|
+
texts.push(nestedRecord.message);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return texts.some((text) => {
|
|
167
|
+
const normalized = text.toLowerCase();
|
|
168
|
+
return (
|
|
169
|
+
normalized === "recomposite_in_progress" ||
|
|
170
|
+
/\bre-?composit(?:e|ing|ion)\b.*\balready\b.*\b(progress|running)\b/.test(
|
|
171
|
+
normalized,
|
|
172
|
+
) ||
|
|
173
|
+
/\bcarousel\b.*\bre-?render\b.*\balready\b.*\bprogress\b/.test(normalized)
|
|
174
|
+
);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
57
178
|
// The video_url comes back in API JSON — allow loopback http only when the API
|
|
58
179
|
// base itself is local (dev), mirroring uploads.ts and the base-URL exception.
|
|
59
180
|
const ALLOW_LOOPBACK_FETCH =
|
|
@@ -72,7 +193,10 @@ async function downloadTo(
|
|
|
72
193
|
// Bound the download: a presigned URL could in theory hang or point at
|
|
73
194
|
// something huge, so cap both time and size instead of buffering blindly.
|
|
74
195
|
const MAX_BYTES = 1024 * 1024 * 1024; // 1 GB — generous for a short ad
|
|
75
|
-
const resp = await fetch(videoUrl, {
|
|
196
|
+
const resp = await fetch(videoUrl, {
|
|
197
|
+
headers: { "user-agent": USER_AGENT },
|
|
198
|
+
signal: AbortSignal.timeout(120_000),
|
|
199
|
+
});
|
|
76
200
|
if (!resp.ok)
|
|
77
201
|
throw new Error(`Failed to download video (HTTP ${resp.status}).`);
|
|
78
202
|
// Fast path: trust a present content-length to reject an oversize body before
|
|
@@ -106,6 +230,10 @@ async function downloadTo(
|
|
|
106
230
|
await reader.cancel().catch(() => {});
|
|
107
231
|
}
|
|
108
232
|
const buf = Buffer.concat(chunks, total);
|
|
233
|
+
// resolveSavePath accepts a nested target (e.g. "clips/out.mp4"), so create
|
|
234
|
+
// the parent chain before writing — otherwise a full spend + download lands on
|
|
235
|
+
// an ENOENT at the final writeFile. Recursive mkdir is a no-op when it exists.
|
|
236
|
+
await mkdir(dirname(target), { recursive: true });
|
|
109
237
|
await writeFile(target, buf);
|
|
110
238
|
return { saved_to: target, bytes: buf.length };
|
|
111
239
|
}
|
|
@@ -158,19 +286,15 @@ function fail(message: string) {
|
|
|
158
286
|
function errMessage(e: unknown): string {
|
|
159
287
|
const he = e as HubfluencerError;
|
|
160
288
|
if (he && typeof he.status === "number") {
|
|
161
|
-
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
const have = body.available_credits;
|
|
171
|
-
return `${base} (needs ${req ?? "?"} credits, have ${have ?? "?"}).`;
|
|
172
|
-
}
|
|
173
|
-
return base;
|
|
289
|
+
// Delegate the formatting (incl. the 402 credit-shortfall parsing) to the
|
|
290
|
+
// pure, unit-tested helper in core.ts — index.ts can't be imported from a
|
|
291
|
+
// test (it boots a live stdio server at module load).
|
|
292
|
+
return formatApiErrorMessage({
|
|
293
|
+
status: he.status,
|
|
294
|
+
code: he.code,
|
|
295
|
+
message: he.message,
|
|
296
|
+
body: he.body,
|
|
297
|
+
});
|
|
174
298
|
}
|
|
175
299
|
return e instanceof Error ? e.message : String(e);
|
|
176
300
|
}
|
|
@@ -315,7 +439,9 @@ function tool<A>(
|
|
|
315
439
|
|
|
316
440
|
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
317
441
|
|
|
318
|
-
const kindSchema = z
|
|
442
|
+
const kindSchema = z
|
|
443
|
+
.enum(["short", "editor", "tracking", "slider"])
|
|
444
|
+
.describe("Project kind");
|
|
319
445
|
|
|
320
446
|
// Creative-style value lists shared by SHORTS and EDITOR (single source of
|
|
321
447
|
// truth, mirroring the API's VideoFactory.creative_formats/0 +
|
|
@@ -345,7 +471,45 @@ const WRITE = {
|
|
|
345
471
|
openWorldHint: true,
|
|
346
472
|
};
|
|
347
473
|
|
|
348
|
-
|
|
474
|
+
// Server-level instructions surfaced to the client during the MCP handshake — a
|
|
475
|
+
// single place for the cross-tool conventions an agent needs before its first
|
|
476
|
+
// call (the per-tool `description`s carry the specifics). Kept concise: the two
|
|
477
|
+
// altitudes (one-shot asset vs. the full delegate-my-content-operation loop),
|
|
478
|
+
// money, how waiting/resuming works, and the local-file sandbox.
|
|
479
|
+
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).
|
|
480
|
+
|
|
481
|
+
MAKE ONE ASSET (simplest)
|
|
482
|
+
- make_video({ prompt }) creates, generates, and returns a finished MP4 — it picks a single-clip short or a multi-scene editor ad from the prompt (override with kind:"short"|"editor"). create_short / create_editor_ad are the per-product one-shots; create_slider makes an image carousel; create_tracking_video burns a CV overlay onto a local clip.
|
|
483
|
+
- Granular (full control): create a draft, then drive the pipeline step by step (create_editor_draft → generate_scenario → set_scene_count → set_segment_prompt → generate_segment(s) → generate_voice + generate_music → render). Poll with get_status / wait_for_completion; fetch with download_result (a slider has no MP4 — read its slides with get_slider).
|
|
484
|
+
|
|
485
|
+
DELEGATE THE CONTENT OPERATION (the full loop — steps 1-3 are $0)
|
|
486
|
+
1. ONBOARD a product: extract_product_page(url) pulls facts + imagery (SSRF-hardened, 0cr); import_product_image saves its picture/logo. Persist identity once with create_product_profile + create_brand_profile so every later draft inherits it instead of re-describing the brand.
|
|
487
|
+
2. PLAN ($0, free quota — not credits): plan_campaign proposes angles/formats + a per-item credit estimate; generate_hook_variations writes hook options. create_campaign(url | product_profile_id) does 1-2 end-to-end and returns a pack of editable DRAFTS.
|
|
488
|
+
3. PACK & MATERIALIZE ($0): create_campaign_pack / add_pack_variations assemble items; materialize_pack_item turns each into a 0-credit draft (editor_ad/short → a video project, carousel → a slider). list_campaign_packs lists packs; get_campaign_pack({ id }) shows every item's draft slug + credit estimate.
|
|
489
|
+
4. GENERATE per draft (CREDITS — a separate, explicit step): run start_autopilot/generate_short/generate_slider on each draft slug. Nothing above this line spends video credits.
|
|
490
|
+
5. REVIEW: create_review_link mints a no-login page where a human approves/comments (list_review_links / revoke_review_link manage them). Gate spend or posting on approval.
|
|
491
|
+
6. CALENDAR (reminder-only): schedule_post / list_scheduled_posts / reschedule_post / cancel_scheduled_post stage a calendar that pushes REMINDERS to the user — it does NOT auto-publish (posting to TikTok/IG stays a human step in the app). After the user posts the presigned export, call mark_scheduled_posted.
|
|
492
|
+
7. MEASURE → DOUBLE DOWN: record_performance logs views/engagement (tag creatives via set_item_attributes); get_recommendations gives a CAUTIOUS, non-causal read of your own best hook/format (present its confidence + note as-is, never upgrade them); make_more_like_winner spins fresh hooks from a proven item — feed a winner back into add_pack_variations → step 4.
|
|
493
|
+
8. RECURRING: create_series + plan_series_episodes → materialize_episode drafts each episode; get_series_dashboard shows upcoming/drafted/published lanes; mark_episode_posted closes a recurring show's loop.
|
|
494
|
+
|
|
495
|
+
CREDITS (spent server-side; each tool prices before charging — see its description)
|
|
496
|
+
- Free (0 credits): every draft create/edit, reorder, all slider re-composites, and the whole onboard/plan/pack/materialize path (steps 1-3). get_credits shows the balance.
|
|
497
|
+
- Charged: short render 15; editor AI scene 5 (4 in a batch of ≥3); voice 3; music 5; editor render 0 (auto-charges ungenerated scenes); slider 1/slide; tracking render 1 (refunded on failure). make_video/create_* report the estimate first — pass dry_run to preview, max_credits to cap.
|
|
498
|
+
- 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.
|
|
499
|
+
|
|
500
|
+
WAITING & RESUMING
|
|
501
|
+
- Renders run async and can take minutes. wait_for_completion polls a budget capped at 280s; if it returns terminal=false / timed_out, just CALL IT AGAIN with the same slug. make_video returns the slug + a resume hint on timeout.
|
|
502
|
+
- 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.
|
|
503
|
+
|
|
504
|
+
LOCAL FILES (sandboxed)
|
|
505
|
+
- 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.
|
|
506
|
+
|
|
507
|
+
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.`;
|
|
508
|
+
|
|
509
|
+
const server = new McpServer(
|
|
510
|
+
{ name: "hubfluencer", version: VERSION },
|
|
511
|
+
{ instructions: SERVER_INSTRUCTIONS },
|
|
512
|
+
);
|
|
349
513
|
|
|
350
514
|
// The SDK's `registerTool` is generic over the Zod input shape; with this many
|
|
351
515
|
// tools its conditional types hit TS2589 ("excessively deep"). Inputs are still
|
|
@@ -370,12 +534,12 @@ async function pollToTerminal(
|
|
|
370
534
|
intervalMs: number,
|
|
371
535
|
): Promise<NormalizedStatus> {
|
|
372
536
|
const deadline = Date.now() + budgetMs;
|
|
373
|
-
let status = await fetchStatus(client, kind, slug);
|
|
537
|
+
let status = await fetchStatus(client, kind, slug, deadline);
|
|
374
538
|
let n = 0;
|
|
375
539
|
while (!status.terminal && Date.now() + intervalMs <= deadline) {
|
|
376
540
|
await reportProgress(extra, ++n, `stage: ${status.stage}`);
|
|
377
541
|
await sleep(intervalMs);
|
|
378
|
-
status = await fetchStatus(client, kind, slug);
|
|
542
|
+
status = await fetchStatus(client, kind, slug, deadline);
|
|
379
543
|
}
|
|
380
544
|
return status;
|
|
381
545
|
}
|
|
@@ -403,7 +567,12 @@ async function pollSegmentToTerminal(
|
|
|
403
567
|
const deadline = Date.now() + budgetMs;
|
|
404
568
|
const sid = String(segmentId);
|
|
405
569
|
const read = async (): Promise<{ status: string; error: string | null }> => {
|
|
406
|
-
|
|
570
|
+
// Bound each poll GET's retries by the loop deadline (see fetchStatus).
|
|
571
|
+
const res = await client.get<{ data: unknown }>(
|
|
572
|
+
`/editor/${slug}`,
|
|
573
|
+
undefined,
|
|
574
|
+
deadline,
|
|
575
|
+
);
|
|
407
576
|
const data = asRecord(asRecord(res).data);
|
|
408
577
|
const segments = Array.isArray(data.segments)
|
|
409
578
|
? (data.segments as Record<string, unknown>[])
|
|
@@ -467,10 +636,27 @@ registerTool(
|
|
|
467
636
|
.describe(
|
|
468
637
|
"'short' (fast, 1 clip), 'editor' (multi-scene), or 'auto' (default — inferred)",
|
|
469
638
|
),
|
|
639
|
+
product_subject: z
|
|
640
|
+
.string()
|
|
641
|
+
.max(300)
|
|
642
|
+
.optional()
|
|
643
|
+
.describe(
|
|
644
|
+
'EDITOR only: the concrete product / main subject the video must be about (e.g. "Bordeaux red ' +
|
|
645
|
+
'wine, dark green bottle"). Hard-grounds the AI so it cannot invent an unrelated product. ' +
|
|
646
|
+
"Recommended for ads.",
|
|
647
|
+
),
|
|
648
|
+
project_intent: z
|
|
649
|
+
.enum(["social_ad", "creative_story"])
|
|
650
|
+
.optional()
|
|
651
|
+
.describe(
|
|
652
|
+
"EDITOR only: social_ad for promotional content (product focus + CTA) or creative_story (default).",
|
|
653
|
+
),
|
|
470
654
|
language: z
|
|
471
655
|
.string()
|
|
472
656
|
.optional()
|
|
473
|
-
.describe(
|
|
657
|
+
.describe(
|
|
658
|
+
'Language code, e.g. "en" (default), "fr". Drives scenario, narration, voice, and captions.',
|
|
659
|
+
),
|
|
474
660
|
aspect: z
|
|
475
661
|
.string()
|
|
476
662
|
.optional()
|
|
@@ -603,6 +789,7 @@ registerTool(
|
|
|
603
789
|
"Spend cap: refuse to start (no charge) if the priced estimate exceeds this. Returns the estimate.",
|
|
604
790
|
),
|
|
605
791
|
},
|
|
792
|
+
outputSchema: makeVideoOutput,
|
|
606
793
|
annotations: { title: "Make a video", ...WRITE, idempotentHint: false },
|
|
607
794
|
},
|
|
608
795
|
tool(
|
|
@@ -610,6 +797,8 @@ registerTool(
|
|
|
610
797
|
args: {
|
|
611
798
|
prompt: string;
|
|
612
799
|
kind?: string;
|
|
800
|
+
product_subject?: string;
|
|
801
|
+
project_intent?: string;
|
|
613
802
|
language?: string;
|
|
614
803
|
aspect?: string;
|
|
615
804
|
voice_id?: string;
|
|
@@ -638,9 +827,17 @@ registerTool(
|
|
|
638
827
|
client,
|
|
639
828
|
extra,
|
|
640
829
|
) => {
|
|
641
|
-
|
|
642
|
-
|
|
830
|
+
const promptLen = args.prompt?.trim().length ?? 0;
|
|
831
|
+
if (promptLen < 10 || promptLen > 5000) {
|
|
832
|
+
return fail("prompt must be 10–5000 characters.");
|
|
643
833
|
}
|
|
834
|
+
const langErr = validateLanguage(args.language);
|
|
835
|
+
if (langErr) return fail(langErr);
|
|
836
|
+
// kind is a flat z.string() (TS2589), so validate it in code the same way
|
|
837
|
+
// slide_count is — reject anything other than the documented values instead
|
|
838
|
+
// of silently inferring a wrong-product spend (see validateKind).
|
|
839
|
+
const kindErr = validateKind(args.kind);
|
|
840
|
+
if (kindErr) return fail(kindErr);
|
|
644
841
|
const requestedKind =
|
|
645
842
|
args.kind === "short" || args.kind === "editor" ? args.kind : undefined;
|
|
646
843
|
const kind: Kind = requestedKind ?? inferKind(args.prompt);
|
|
@@ -650,6 +847,25 @@ registerTool(
|
|
|
650
847
|
);
|
|
651
848
|
const budgetMs = waitSeconds * 1000;
|
|
652
849
|
|
|
850
|
+
// star_rating / text_beats are flat here (see the schema note on TS2589)
|
|
851
|
+
// but create_short bounds them with Zod (0..5; ≤8 beats, each ≤120). Apply
|
|
852
|
+
// the SAME bounds in code so a one-shot short can't slip past make_video an
|
|
853
|
+
// out-of-range value that create_short would have rejected.
|
|
854
|
+
if (
|
|
855
|
+
args.star_rating !== undefined &&
|
|
856
|
+
(args.star_rating < 0 || args.star_rating > 5)
|
|
857
|
+
) {
|
|
858
|
+
return fail("star_rating must be between 0 and 5.");
|
|
859
|
+
}
|
|
860
|
+
if (args.text_beats !== undefined) {
|
|
861
|
+
if (args.text_beats.length > 8) {
|
|
862
|
+
return fail("text_beats must have at most 8 entries.");
|
|
863
|
+
}
|
|
864
|
+
if (args.text_beats.some((b) => b.length > 120)) {
|
|
865
|
+
return fail("each text_beats entry must be at most 120 characters.");
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
|
|
653
869
|
// Validate the save_path up front so we fail before spending credits.
|
|
654
870
|
if (args.save_path) resolveSavePath(args.save_path);
|
|
655
871
|
|
|
@@ -718,22 +934,18 @@ registerTool(
|
|
|
718
934
|
{
|
|
719
935
|
language: args.language ?? "en",
|
|
720
936
|
product_prompt: args.prompt,
|
|
937
|
+
product_subject: args.product_subject,
|
|
938
|
+
project_intent: args.project_intent,
|
|
721
939
|
export_aspect_ratio: args.aspect,
|
|
722
940
|
voice_id: args.voice_id,
|
|
723
941
|
creative_format: args.creative_format,
|
|
724
942
|
visual_language: args.visual_language,
|
|
725
943
|
theme: args.theme,
|
|
726
944
|
},
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
args.aspect ?? "",
|
|
732
|
-
args.voice_id ?? "",
|
|
733
|
-
args.creative_format ?? "",
|
|
734
|
-
args.visual_language ?? "",
|
|
735
|
-
args.theme ?? "",
|
|
736
|
-
),
|
|
945
|
+
// Fold every create-affecting param into the key so two calls that
|
|
946
|
+
// differ only by style/theme/voice/aspect get distinct drafts (shared
|
|
947
|
+
// with the golden-pin test via core.ts).
|
|
948
|
+
makeVideoEditorCreateKey(args),
|
|
737
949
|
);
|
|
738
950
|
slug = created.data.slug;
|
|
739
951
|
}
|
|
@@ -795,18 +1007,49 @@ registerTool(
|
|
|
795
1007
|
});
|
|
796
1008
|
}
|
|
797
1009
|
|
|
798
|
-
// 4) Affordable and authorized — start the charging pipeline.
|
|
1010
|
+
// 4) Affordable and authorized — start the charging pipeline. Read the
|
|
1011
|
+
// project's current state first and fold an anti-replay discriminator
|
|
1012
|
+
// into the start key: on a re-run AFTER a terminal failure the server's
|
|
1013
|
+
// 24h idempotency cache would otherwise replay the failed run's cached
|
|
1014
|
+
// 202 and never restart (the exact trap generate_short warns about).
|
|
1015
|
+
// startStateDiscriminator returns a STABLE "start" for a fresh/in-flight
|
|
1016
|
+
// project (so a transport double-send still dedupes — and the server's
|
|
1017
|
+
// already-running / 409 gate independently blocks a double charge) but a
|
|
1018
|
+
// per-attempt signature once terminally failed, so a genuine re-run mints
|
|
1019
|
+
// a fresh key and re-enqueues. Best-effort: if the read fails we fall
|
|
1020
|
+
// back to the stable "start" (the server gate remains the real guard).
|
|
1021
|
+
let startState = "start";
|
|
1022
|
+
try {
|
|
1023
|
+
const cur = await client.get<{ data: unknown }>(
|
|
1024
|
+
kind === "short" ? `/shorts/${slug}` : `/editor/${slug}`,
|
|
1025
|
+
);
|
|
1026
|
+
startState = startStateDiscriminator(kind, asRecord(cur).data);
|
|
1027
|
+
} catch {
|
|
1028
|
+
// status unreadable — keep the stable token (the server gate still holds)
|
|
1029
|
+
}
|
|
1030
|
+
|
|
799
1031
|
if (kind === "short") {
|
|
800
1032
|
await client.post(
|
|
801
1033
|
`/shorts/${slug}/generate`,
|
|
802
1034
|
undefined,
|
|
803
|
-
|
|
1035
|
+
// gen-short:<slug> + the failure-state discriminator: a fresh/in-flight
|
|
1036
|
+
// short reuses "gen-short:<slug>:start" (transport-retry dedupe), a
|
|
1037
|
+
// re-run after a failed render gets a fresh key and re-renders.
|
|
1038
|
+
`gen-short:${slug}:${startState}`,
|
|
804
1039
|
);
|
|
805
1040
|
} else {
|
|
806
1041
|
await client.post(
|
|
807
1042
|
`/editor/${slug}/autopilot`,
|
|
808
1043
|
undefined,
|
|
809
|
-
|
|
1044
|
+
// Fold the create-affecting params AND the failure-state discriminator
|
|
1045
|
+
// into the start key so it tracks the inputs + current state, not just
|
|
1046
|
+
// the slug. This key is only compared against a retry of *this* tool on
|
|
1047
|
+
// *this* slug; the other autopilot entry points use a different field
|
|
1048
|
+
// set/order on purpose (the server idempotency cache is scoped to
|
|
1049
|
+
// path+key, and the slug — already in the path — is unique per draft),
|
|
1050
|
+
// so the shapes are not meant to be interchangeable across tools.
|
|
1051
|
+
// Composition lives in core.ts, shared with the golden-pin test.
|
|
1052
|
+
makeVideoAutopilotKey(slug, args, startState),
|
|
810
1053
|
);
|
|
811
1054
|
}
|
|
812
1055
|
|
|
@@ -851,7 +1094,13 @@ registerTool(
|
|
|
851
1094
|
note: status.ready
|
|
852
1095
|
? "Done. Presigned ~24h URL — download promptly."
|
|
853
1096
|
: status.terminal
|
|
854
|
-
?
|
|
1097
|
+
? // A terminal-but-not-ready make_video is a failed/cancelled run.
|
|
1098
|
+
// Point at a restart: re-running make_video now genuinely restarts
|
|
1099
|
+
// (the start key folds the failure state, so it no longer replays the
|
|
1100
|
+
// dead 202), or resume the pipeline directly. Fix the cause (often
|
|
1101
|
+
// credits) first.
|
|
1102
|
+
`Terminal (${status.stage}). ${status.error ?? ""}`.trim() +
|
|
1103
|
+
` To restart, fix the cause (often credits) then re-run make_video, or resume with ${resume}.`
|
|
855
1104
|
: `Still rendering — call wait_for_completion(${resumeArgs}).`,
|
|
856
1105
|
},
|
|
857
1106
|
links,
|
|
@@ -869,6 +1118,7 @@ registerTool(
|
|
|
869
1118
|
description:
|
|
870
1119
|
"Returns the authenticated account's credit balance. A short costs 15 credits.",
|
|
871
1120
|
inputSchema: {},
|
|
1121
|
+
outputSchema: getCreditsOutput,
|
|
872
1122
|
annotations: { title: "Get credits", ...RO },
|
|
873
1123
|
},
|
|
874
1124
|
tool(async (_args, client) => {
|
|
@@ -926,6 +1176,7 @@ registerTool(
|
|
|
926
1176
|
.optional()
|
|
927
1177
|
.describe("Max items per kind (default 25, capped at 100)"),
|
|
928
1178
|
},
|
|
1179
|
+
outputSchema: listProjectsOutput,
|
|
929
1180
|
annotations: { title: "List projects", ...RO },
|
|
930
1181
|
},
|
|
931
1182
|
tool(
|
|
@@ -1104,6 +1355,7 @@ registerTool(
|
|
|
1104
1355
|
"Optional caption beats shown sequentially instead of the static subheadline.",
|
|
1105
1356
|
),
|
|
1106
1357
|
},
|
|
1358
|
+
outputSchema: createDraftOutput,
|
|
1107
1359
|
annotations: { title: "Create short", ...WRITE, idempotentHint: true },
|
|
1108
1360
|
},
|
|
1109
1361
|
tool(
|
|
@@ -1200,6 +1452,372 @@ registerTool(
|
|
|
1200
1452
|
),
|
|
1201
1453
|
);
|
|
1202
1454
|
|
|
1455
|
+
interface TrackingData {
|
|
1456
|
+
slug: string;
|
|
1457
|
+
status: string;
|
|
1458
|
+
source: { status: string } | null;
|
|
1459
|
+
result: { id?: number; status: string; video_url: string | null } | null;
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
async function getTracking(
|
|
1463
|
+
client: HubfluencerClient,
|
|
1464
|
+
slug: string,
|
|
1465
|
+
deadline?: number,
|
|
1466
|
+
): Promise<TrackingData> {
|
|
1467
|
+
// On a poll loop the caller passes the loop deadline so this GET's retry
|
|
1468
|
+
// sequence stays inside the wait budget (see client.request `deadline`).
|
|
1469
|
+
const res = await client.get<{ data: TrackingData }>(
|
|
1470
|
+
`/tracking/${slug}`,
|
|
1471
|
+
undefined,
|
|
1472
|
+
deadline,
|
|
1473
|
+
);
|
|
1474
|
+
return res.data;
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
// Poll until the uploaded source clip finishes server-side processing.
|
|
1478
|
+
//
|
|
1479
|
+
// Returns "ready" when the source is usable, "failed" when the server-side
|
|
1480
|
+
// processing FAILED (the API now surfaces failed sources — see the latest_source
|
|
1481
|
+
// contract), and "timeout" when the budget elapsed while it was still in flight.
|
|
1482
|
+
// Short-circuiting on "failed" matters: a failed source is not progressing, so
|
|
1483
|
+
// spinning the full budget and then telling the agent it is "still processing"
|
|
1484
|
+
// would be both slow and wrong.
|
|
1485
|
+
async function waitForTrackingSource(
|
|
1486
|
+
client: HubfluencerClient,
|
|
1487
|
+
slug: string,
|
|
1488
|
+
extra: ToolExtra,
|
|
1489
|
+
budgetMs = 120_000,
|
|
1490
|
+
intervalMs = 3_000,
|
|
1491
|
+
): Promise<"ready" | "failed" | "timeout"> {
|
|
1492
|
+
const deadline = Date.now() + budgetMs;
|
|
1493
|
+
let n = 0;
|
|
1494
|
+
while (Date.now() + intervalMs <= deadline) {
|
|
1495
|
+
const t = await getTracking(client, slug, deadline);
|
|
1496
|
+
if (t.source?.status === "ready") return "ready";
|
|
1497
|
+
if (t.source?.status === "failed") return "failed";
|
|
1498
|
+
await reportProgress(extra, ++n, "processing source clip");
|
|
1499
|
+
await sleep(intervalMs);
|
|
1500
|
+
}
|
|
1501
|
+
return "timeout";
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
// Poll the render until the result reaches a terminal state. The budget is
|
|
1505
|
+
// generous (default 15 min) because the production Replicate Cog can cold-start a
|
|
1506
|
+
// GPU before the burn-in even begins, and worst-case 60s/1080p sources may spend
|
|
1507
|
+
// several additional minutes in Mask R-CNN + CPU x264 encode. A short budget
|
|
1508
|
+
// would routinely time the agent out mid-render even on a job that ultimately succeeds.
|
|
1509
|
+
async function pollTrackingComplete(
|
|
1510
|
+
client: HubfluencerClient,
|
|
1511
|
+
slug: string,
|
|
1512
|
+
extra: ToolExtra,
|
|
1513
|
+
budgetMs = 900_000,
|
|
1514
|
+
intervalMs = 4_000,
|
|
1515
|
+
): Promise<TrackingData> {
|
|
1516
|
+
const deadline = Date.now() + budgetMs;
|
|
1517
|
+
let n = 0;
|
|
1518
|
+
let t = await getTracking(client, slug, deadline);
|
|
1519
|
+
while (
|
|
1520
|
+
t.result?.status !== "completed" &&
|
|
1521
|
+
t.result?.status !== "failed" &&
|
|
1522
|
+
Date.now() + intervalMs <= deadline
|
|
1523
|
+
) {
|
|
1524
|
+
await reportProgress(extra, ++n, "applying tracking overlay");
|
|
1525
|
+
await sleep(intervalMs);
|
|
1526
|
+
t = await getTracking(client, slug, deadline);
|
|
1527
|
+
}
|
|
1528
|
+
return t;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
registerTool(
|
|
1532
|
+
"create_tracking_video",
|
|
1533
|
+
{
|
|
1534
|
+
title: "Create a tracking-overlay video",
|
|
1535
|
+
description:
|
|
1536
|
+
"One-shot: upload a local video and burn the computer-vision 'object tracking' overlay on top " +
|
|
1537
|
+
"(white convex-hull polygons hugging each moving subject, HUD corner brackets, a centroid crosshair, " +
|
|
1538
|
+
"and per-subject telemetry), then download the result. Costs 1 credit on render. The source clip must " +
|
|
1539
|
+
"be ≤60s and ≤1080p. This creates the project, uploads the clip, waits for it to be ready, renders, " +
|
|
1540
|
+
"polls to completion, and (when save_path is given) downloads the finished MP4. " +
|
|
1541
|
+
"RESUMING: if the call returns terminal=false (slow upload or a render still cold-starting on the GPU), " +
|
|
1542
|
+
"call this tool AGAIN with the returned slug to resume — it reuses the in-flight upload (it only (re)uploads " +
|
|
1543
|
+
"when no source exists yet or the prior upload failed) and never re-charges (a render in flight is reported, " +
|
|
1544
|
+
"not duplicated). Resuming a slug whose render already COMPLETED just returns the finished video: a " +
|
|
1545
|
+
"completed project is FINAL. The source clip is deleted on successful completion, so you cannot re-render it " +
|
|
1546
|
+
"with a different overlay: for a variant (different classes/color) start a NEW project (omit slug, pass " +
|
|
1547
|
+
"video_path). Re-uploading even the identical clip charges a fresh 1-credit render (the dedup keys on the new " +
|
|
1548
|
+
"upload). Once the project " +
|
|
1549
|
+
"exists you can also poll/download it with get_status / download_result / wait_for_completion using " +
|
|
1550
|
+
'kind:"tracking" and that slug.',
|
|
1551
|
+
inputSchema: {
|
|
1552
|
+
video_path: z
|
|
1553
|
+
.string()
|
|
1554
|
+
.optional()
|
|
1555
|
+
.describe(
|
|
1556
|
+
"Local path to the source video (mp4/mov/webm/mkv), inside HUBFLUENCER_INPUT_DIR or cwd. " +
|
|
1557
|
+
"Required on a fresh call; optional when resuming with slug (the clip is already uploaded).",
|
|
1558
|
+
),
|
|
1559
|
+
slug: z
|
|
1560
|
+
.string()
|
|
1561
|
+
.optional()
|
|
1562
|
+
.describe(
|
|
1563
|
+
"Resume an existing tracking project (from a prior timeout/partial-failure payload): skips create " +
|
|
1564
|
+
"and reuses the in-flight upload (only re-uploads when no source exists yet or the prior upload failed). " +
|
|
1565
|
+
"Resuming a project whose render already COMPLETED just returns the finished video: a completed project " +
|
|
1566
|
+
"is final (its source clip was deleted on completion), so you cannot re-render it with a different overlay. " +
|
|
1567
|
+
"For a variant, start a fresh project (omit slug, pass video_path). Pass this back to continue an in-flight " +
|
|
1568
|
+
"project rather than forking a new one; omit it to start a fresh project.",
|
|
1569
|
+
),
|
|
1570
|
+
classes: z
|
|
1571
|
+
.array(z.number().int().min(0).max(79))
|
|
1572
|
+
.optional()
|
|
1573
|
+
.describe(
|
|
1574
|
+
"COCO-80 class ids (0-79) to track (e.g. [0]=person, [16]=dog). Omit to track all subjects.",
|
|
1575
|
+
),
|
|
1576
|
+
color: z
|
|
1577
|
+
.array(z.number().int().min(0).max(255))
|
|
1578
|
+
.min(3)
|
|
1579
|
+
.max(4)
|
|
1580
|
+
.optional()
|
|
1581
|
+
.describe(
|
|
1582
|
+
"Optional overlay color as RGB [r,g,b] or RGBA [r,g,b,a] (each 0–255). Omit for the default white overlay.",
|
|
1583
|
+
),
|
|
1584
|
+
save_path: z
|
|
1585
|
+
.string()
|
|
1586
|
+
.optional()
|
|
1587
|
+
.describe(
|
|
1588
|
+
"Optional .mp4 path to download the finished video to (inside HUBFLUENCER_OUTPUT_DIR or cwd).",
|
|
1589
|
+
),
|
|
1590
|
+
},
|
|
1591
|
+
annotations: {
|
|
1592
|
+
title: "Create tracking video",
|
|
1593
|
+
...WRITE,
|
|
1594
|
+
idempotentHint: false,
|
|
1595
|
+
},
|
|
1596
|
+
},
|
|
1597
|
+
tool(
|
|
1598
|
+
async (
|
|
1599
|
+
args: {
|
|
1600
|
+
video_path?: string;
|
|
1601
|
+
slug?: string;
|
|
1602
|
+
classes?: number[];
|
|
1603
|
+
color?: number[];
|
|
1604
|
+
save_path?: string;
|
|
1605
|
+
},
|
|
1606
|
+
client,
|
|
1607
|
+
extra,
|
|
1608
|
+
) => {
|
|
1609
|
+
// Validate the save_path up front so we fail before spending a credit.
|
|
1610
|
+
if (args.save_path) resolveSavePath(args.save_path);
|
|
1611
|
+
|
|
1612
|
+
// 1. Resolve the project: resume an existing slug, or create a fresh one
|
|
1613
|
+
// (0 credits). On a resume we first read the current project so the
|
|
1614
|
+
// orchestration plan can reason about its source/result state.
|
|
1615
|
+
const existing: TrackingData | null = args.slug
|
|
1616
|
+
? await getTracking(client, args.slug)
|
|
1617
|
+
: null;
|
|
1618
|
+
|
|
1619
|
+
// Compute the whole orchestration plan — pure, side-effect-free (see
|
|
1620
|
+
// decideTrackingPlan in core.ts, which is where this branching is unit
|
|
1621
|
+
// tested). It yields the create/resume idempotency anchor, whether the
|
|
1622
|
+
// source is already live (do-NOT-reupload), whether to (re)submit a render,
|
|
1623
|
+
// and the safety-critical render key. On a FRESH call the anchor folds in a
|
|
1624
|
+
// per-invocation random nonce so two runs over the SAME local clip path
|
|
1625
|
+
// within the 24h idempotency window fork distinct projects instead of the
|
|
1626
|
+
// second replaying the first's cached 201 (then appending an upload+render
|
|
1627
|
+
// to the stale project). Mirrors start_autopilot's
|
|
1628
|
+
// idemKey("autopilot", slug, randomUUID()). The render key is derived from
|
|
1629
|
+
// the anchor PLUS a per-attempt/config discriminator, never the anchor
|
|
1630
|
+
// verbatim, or a retry-after-failure would replay the cached 202 forever
|
|
1631
|
+
// and never re-enqueue the worker.
|
|
1632
|
+
const plan = decideTrackingPlan(
|
|
1633
|
+
existing as TrackingSnapshot | null,
|
|
1634
|
+
args,
|
|
1635
|
+
randomUUID(),
|
|
1636
|
+
);
|
|
1637
|
+
|
|
1638
|
+
let slug: string;
|
|
1639
|
+
if (args.slug) {
|
|
1640
|
+
slug = args.slug;
|
|
1641
|
+
} else {
|
|
1642
|
+
if (!args.video_path) {
|
|
1643
|
+
return fail("video_path is required to start a new tracking video.");
|
|
1644
|
+
}
|
|
1645
|
+
// Validate the read path up front (ext allowlist, input-dir
|
|
1646
|
+
// confinement, exists/non-empty/size cap) BEFORE creating the project,
|
|
1647
|
+
// so a present-but-invalid path fails fast instead of orphaning an
|
|
1648
|
+
// empty draft. uploadVideoFile re-resolves the same path below.
|
|
1649
|
+
await resolveVideoReadPath(args.video_path);
|
|
1650
|
+
const created = await client.post<{ data: { slug: string } }>(
|
|
1651
|
+
"/tracking",
|
|
1652
|
+
{},
|
|
1653
|
+
plan.idemBase,
|
|
1654
|
+
);
|
|
1655
|
+
slug = created.data.slug;
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
// Resume of an already-FINISHED render: the project is final. On a
|
|
1659
|
+
// successful completion the API deletes the source upload (it has served
|
|
1660
|
+
// its purpose — the burned-in output is the only artifact the user keeps;
|
|
1661
|
+
// tracking.ex delete_source_after_completion), so a completed resume carries
|
|
1662
|
+
// source:null. We must therefore return the finished video HERE, before the
|
|
1663
|
+
// source gate below — otherwise hasLiveSource is false and that gate wrongly
|
|
1664
|
+
// reports "source not uploaded" on a paid, completed project. A different
|
|
1665
|
+
// overlay is NOT a resume: combination_hash keys on the source's s3_key,
|
|
1666
|
+
// which is gone, so any re-upload (even of the identical clip) gets a fresh
|
|
1667
|
+
// key, misses the dedup, and charges a new render — so the honest guidance
|
|
1668
|
+
// is "start a new tracking project", never "resume with new classes/color".
|
|
1669
|
+
if (existing?.result?.status === "completed") {
|
|
1670
|
+
const videoUrl = existing.result.video_url ?? null;
|
|
1671
|
+
let saved_to: string | undefined;
|
|
1672
|
+
if (videoUrl && args.save_path) {
|
|
1673
|
+
saved_to = (await downloadTo(videoUrl, args.save_path)).saved_to;
|
|
1674
|
+
}
|
|
1675
|
+
const variantNote = plan.configSupplied
|
|
1676
|
+
? " A different overlay needs a NEW tracking project (omit slug, pass video_path): the finished project's source clip was deleted on completion, so re-running can't re-render it, and re-uploading even the same clip charges a fresh 1-credit render."
|
|
1677
|
+
: "";
|
|
1678
|
+
return ok(
|
|
1679
|
+
{
|
|
1680
|
+
slug,
|
|
1681
|
+
kind: "tracking",
|
|
1682
|
+
status: "completed",
|
|
1683
|
+
terminal: true,
|
|
1684
|
+
video_url: videoUrl,
|
|
1685
|
+
...(saved_to ? { saved_to } : {}),
|
|
1686
|
+
note: `Already completed — the project is final. Presigned ~24h URL, download promptly.${variantNote}`,
|
|
1687
|
+
},
|
|
1688
|
+
videoUrl ? [mp4Link(videoUrl, slug)] : [],
|
|
1689
|
+
);
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
// 2. Upload the source clip — reuses the editor upload routes, which the
|
|
1693
|
+
// tracking factory is authorised for (single-PUT or resumable multipart).
|
|
1694
|
+
// Only (re)upload when there is NO usable source row: a fresh project has
|
|
1695
|
+
// none, and a prior upload that ended `failed` is dead. Crucially, do NOT
|
|
1696
|
+
// re-upload for pending/processing — that is exactly the state during the
|
|
1697
|
+
// waitForTrackingSource timeout window the resume note points the agent at,
|
|
1698
|
+
// and a second uploadVideoFile would fork a duplicate EditorUpload (orphan
|
|
1699
|
+
// the in-flight one, double-consume the per-user live-upload ceiling, and
|
|
1700
|
+
// let the render pick the wrong/latest source). EditorUpload statuses are
|
|
1701
|
+
// pending|processing|ready|failed; only `failed` is unusable, the rest are
|
|
1702
|
+
// progressing toward ready, so we just wait on them below.
|
|
1703
|
+
// The source is "live" (do NOT re-upload) when it exists and is not failed.
|
|
1704
|
+
// The API now surfaces pending/processing sources too, so an in-flight upload
|
|
1705
|
+
// is observed here and routed to wait-not-reupload rather than forking a
|
|
1706
|
+
// duplicate EditorUpload (which would orphan the in-flight one, double-consume
|
|
1707
|
+
// the per-user live-upload ceiling, and let the render pick the wrong source).
|
|
1708
|
+
if (!plan.hasLiveSource) {
|
|
1709
|
+
if (!args.video_path) {
|
|
1710
|
+
return fail(
|
|
1711
|
+
`Source for ${slug} is not uploaded yet (or the prior upload failed) and no ` +
|
|
1712
|
+
"video_path was given to (re)upload. Pass video_path to upload, or wait and " +
|
|
1713
|
+
"resume with the slug if an upload is already in flight.",
|
|
1714
|
+
);
|
|
1715
|
+
}
|
|
1716
|
+
await uploadVideoFile(client, slug, args.video_path, {
|
|
1717
|
+
onProgress: (completed, total, message) =>
|
|
1718
|
+
reportProgress(extra, completed, message, total),
|
|
1719
|
+
});
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
// 3. Wait for the upload to finish processing before charging a render.
|
|
1723
|
+
// Three outcomes: ready (proceed), failed (the server-side processing
|
|
1724
|
+
// failed — re-running with the slug alone would just re-observe the same
|
|
1725
|
+
// dead source and loop, so tell the agent to re-run WITH video_path to
|
|
1726
|
+
// re-upload), or timeout (still in flight — resume on the slug to keep
|
|
1727
|
+
// waiting; no re-upload, no re-charge).
|
|
1728
|
+
const sourceState = await waitForTrackingSource(client, slug, extra);
|
|
1729
|
+
if (sourceState === "failed") {
|
|
1730
|
+
return ok({
|
|
1731
|
+
slug,
|
|
1732
|
+
kind: "tracking",
|
|
1733
|
+
status: "failed",
|
|
1734
|
+
terminal: true,
|
|
1735
|
+
note: `Source upload failed for ${slug} — re-run create_tracking_video with slug:"${slug}" and video_path to re-upload the clip.`,
|
|
1736
|
+
});
|
|
1737
|
+
}
|
|
1738
|
+
if (sourceState === "timeout") {
|
|
1739
|
+
return ok({
|
|
1740
|
+
slug,
|
|
1741
|
+
kind: "tracking",
|
|
1742
|
+
status: "processing",
|
|
1743
|
+
terminal: false,
|
|
1744
|
+
note: `Source still processing — call create_tracking_video again with slug:"${slug}" to resume (no re-upload, no re-charge).`,
|
|
1745
|
+
});
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
// 4. Render (1 credit). Decide whether to (re)submit the render POST:
|
|
1749
|
+
// - no result yet, or the last one failed+refunded → render.
|
|
1750
|
+
// - a result is in flight (pending/processing) → never re-render
|
|
1751
|
+
// mid-flight (a poll-timeout resume); the server's 409 guard backs
|
|
1752
|
+
// this up anyway. Fall through to polling.
|
|
1753
|
+
// - a `completed` result already returned above (the source is deleted on
|
|
1754
|
+
// completion, so a completed project is final; a different overlay must
|
|
1755
|
+
// be a brand-new tracking project, never a resume with new config).
|
|
1756
|
+
// A completed result already short-circuited above, so reaching here means
|
|
1757
|
+
// the result is null / failed / in-flight — plan.shouldRender is true only
|
|
1758
|
+
// for "no result yet" or "failed+refunded" (a re-render of a completed
|
|
1759
|
+
// project is impossible now the source is deleted), and an in-flight render
|
|
1760
|
+
// is left to the poll below (the server's 409 guard backs this up).
|
|
1761
|
+
if (plan.shouldRender) {
|
|
1762
|
+
const body: Record<string, unknown> = {};
|
|
1763
|
+
if (args.classes !== undefined) body.classes = args.classes;
|
|
1764
|
+
if (args.color !== undefined) body.color = args.color;
|
|
1765
|
+
|
|
1766
|
+
// plan.renderKey is the anti-replay-of-a-dead-202 key (derived in
|
|
1767
|
+
// decideTrackingPlan, unit-tested in core.test.ts): a transport retry of
|
|
1768
|
+
// THIS attempt replays the cached 202, but a retry AFTER a fail+refund
|
|
1769
|
+
// mints a fresh key (the per-attempt discriminator diverges), so the
|
|
1770
|
+
// worker re-enqueues instead of replaying the dead 202 forever.
|
|
1771
|
+
try {
|
|
1772
|
+
await client.post(`/tracking/${slug}/render`, body, plan.renderKey);
|
|
1773
|
+
} catch (e) {
|
|
1774
|
+
// A 409 (tracking_render_in_progress) means a render is already
|
|
1775
|
+
// active — a race with another caller (e.g. the front-end) or a
|
|
1776
|
+
// resume where `existing.result` was momentarily null at poll time.
|
|
1777
|
+
// The render is fine; fall through to polling instead of surfacing a
|
|
1778
|
+
// hard error. Mirrors generate_short's 409 handling.
|
|
1779
|
+
if ((e as { status?: number }).status !== 409) throw e;
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
|
|
1783
|
+
// 5. Poll to completion and (optionally) download.
|
|
1784
|
+
const final = await pollTrackingComplete(client, slug, extra);
|
|
1785
|
+
const status = final.result?.status ?? final.status;
|
|
1786
|
+
const videoUrl = final.result?.video_url ?? null;
|
|
1787
|
+
const terminal = status === "completed" || status === "failed";
|
|
1788
|
+
let saved_to: string | undefined;
|
|
1789
|
+
if (videoUrl && args.save_path) {
|
|
1790
|
+
saved_to = (await downloadTo(videoUrl, args.save_path)).saved_to;
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Don't overstate success: a finished poll only means the result reached a
|
|
1794
|
+
// terminal state OR the budget ran out. A still-running render may yet
|
|
1795
|
+
// fail+refund server-side, so point the agent back at the slug either way.
|
|
1796
|
+
const note =
|
|
1797
|
+
status === "completed"
|
|
1798
|
+
? "Done. Presigned ~24h URL — download promptly."
|
|
1799
|
+
: status === "failed"
|
|
1800
|
+
? "Render failed — the 1 credit was refunded. Retry with the same slug."
|
|
1801
|
+
: `Still rendering — resume with create_tracking_video({ slug: "${slug}"${
|
|
1802
|
+
args.save_path ? `, save_path: "${args.save_path}"` : ""
|
|
1803
|
+
} }), or poll wait_for_completion({ slug: "${slug}", kind: "tracking" }). It may still fail+refund.`;
|
|
1804
|
+
|
|
1805
|
+
return ok(
|
|
1806
|
+
{
|
|
1807
|
+
slug,
|
|
1808
|
+
kind: "tracking",
|
|
1809
|
+
status,
|
|
1810
|
+
terminal,
|
|
1811
|
+
video_url: videoUrl,
|
|
1812
|
+
...(saved_to ? { saved_to } : {}),
|
|
1813
|
+
note,
|
|
1814
|
+
},
|
|
1815
|
+
videoUrl ? [mp4Link(videoUrl, slug)] : [],
|
|
1816
|
+
);
|
|
1817
|
+
},
|
|
1818
|
+
),
|
|
1819
|
+
);
|
|
1820
|
+
|
|
1203
1821
|
registerTool(
|
|
1204
1822
|
"generate_short",
|
|
1205
1823
|
{
|
|
@@ -1209,7 +1827,10 @@ registerTool(
|
|
|
1209
1827
|
"duplicate while a render is in flight is reported as in-progress (no double charge), and a failed " +
|
|
1210
1828
|
"short can be re-generated. Then poll with get_status or wait_for_completion (kind=short).",
|
|
1211
1829
|
inputSchema: { slug: z.string().describe("Short slug from create_short") },
|
|
1212
|
-
|
|
1830
|
+
// Not idempotent: a repeat after a terminal (failed) render re-charges 15
|
|
1831
|
+
// credits (the whole point of allowing a re-generation), so a blind retry is
|
|
1832
|
+
// NOT free — only an in-flight duplicate dedupes (server 409). See below.
|
|
1833
|
+
annotations: { title: "Generate short", ...WRITE },
|
|
1213
1834
|
},
|
|
1214
1835
|
tool(async (args: { slug: string }, client) => {
|
|
1215
1836
|
// No stable idempotency key (see generate_slider): a per-slug key would
|
|
@@ -1329,6 +1950,7 @@ registerTool(
|
|
|
1329
1950
|
"Vertical placement of the on-image copy across all slides. Omit for the template's natural placement.",
|
|
1330
1951
|
),
|
|
1331
1952
|
},
|
|
1953
|
+
outputSchema: createDraftOutput,
|
|
1332
1954
|
annotations: { title: "Create slider", ...WRITE, idempotentHint: true },
|
|
1333
1955
|
},
|
|
1334
1956
|
tool(
|
|
@@ -1368,12 +1990,7 @@ registerTool(
|
|
|
1368
1990
|
const res = await client.post<{ data: { slug: string } }>(
|
|
1369
1991
|
"/sliders",
|
|
1370
1992
|
body,
|
|
1371
|
-
|
|
1372
|
-
"create-slider",
|
|
1373
|
-
args.prompt,
|
|
1374
|
-
args.mode ?? "",
|
|
1375
|
-
args.template ?? "",
|
|
1376
|
-
),
|
|
1993
|
+
sliderCreateKey(args),
|
|
1377
1994
|
);
|
|
1378
1995
|
return ok({
|
|
1379
1996
|
slug: res.data.slug,
|
|
@@ -1396,7 +2013,10 @@ registerTool(
|
|
|
1396
2013
|
inputSchema: {
|
|
1397
2014
|
slug: z.string().describe("Slider slug from create_slider"),
|
|
1398
2015
|
},
|
|
1399
|
-
|
|
2016
|
+
// Not idempotent: a repeat after a terminal render re-charges 1 credit/slide
|
|
2017
|
+
// (a failed carousel can be re-generated), so a blind retry is not free —
|
|
2018
|
+
// only an in-flight duplicate dedupes (server 409). See below.
|
|
2019
|
+
annotations: { title: "Generate slider", ...WRITE },
|
|
1400
2020
|
},
|
|
1401
2021
|
tool(async (args: { slug: string }, client) => {
|
|
1402
2022
|
// No stable idempotency key on purpose: a per-slug key would replay the
|
|
@@ -1433,6 +2053,7 @@ registerTool(
|
|
|
1433
2053
|
"completed | failed. completed:true means every slide image_url is ready to save. Once completed you " +
|
|
1434
2054
|
"can restyle_slider (new template/accent) or edit_slider_slide (one slide's text) for free.",
|
|
1435
2055
|
inputSchema: { slug: z.string().describe("Slider slug") },
|
|
2056
|
+
outputSchema: getSliderOutput,
|
|
1436
2057
|
annotations: { title: "Get slider", ...RO },
|
|
1437
2058
|
},
|
|
1438
2059
|
tool(async (args: { slug: string }, client) => {
|
|
@@ -1541,8 +2162,10 @@ registerTool(
|
|
|
1541
2162
|
next: "poll get_slider until status is 'completed' (free re-composite, 0 credits)",
|
|
1542
2163
|
});
|
|
1543
2164
|
} catch (e) {
|
|
1544
|
-
//
|
|
1545
|
-
|
|
2165
|
+
// Only the active free re-composite race is idempotent from the MCP
|
|
2166
|
+
// client's perspective. A 409 saying the slider must be completed is an
|
|
2167
|
+
// actionable user error and must surface as one.
|
|
2168
|
+
if (isRecompositeInProgressConflict(e)) {
|
|
1546
2169
|
return ok({ slug: args.slug, kind: "slider", status: "processing" });
|
|
1547
2170
|
}
|
|
1548
2171
|
throw e;
|
|
@@ -1615,8 +2238,10 @@ registerTool(
|
|
|
1615
2238
|
next: "poll get_slider until the slide's status is 'completed' (free re-composite, 0 credits)",
|
|
1616
2239
|
});
|
|
1617
2240
|
} catch (e) {
|
|
1618
|
-
//
|
|
1619
|
-
|
|
2241
|
+
// Only the active free re-composite race is idempotent from the MCP
|
|
2242
|
+
// client's perspective. A 409 saying the slider must be completed is an
|
|
2243
|
+
// actionable user error and must surface as one.
|
|
2244
|
+
if (isRecompositeInProgressConflict(e)) {
|
|
1620
2245
|
return ok({
|
|
1621
2246
|
slug: args.slug,
|
|
1622
2247
|
kind: "slider",
|
|
@@ -1650,10 +2275,29 @@ registerTool(
|
|
|
1650
2275
|
.string()
|
|
1651
2276
|
.min(10)
|
|
1652
2277
|
.describe("Brief for the ad (min 10 chars)"),
|
|
2278
|
+
product_subject: z
|
|
2279
|
+
.string()
|
|
2280
|
+
.max(300)
|
|
2281
|
+
.optional()
|
|
2282
|
+
.describe(
|
|
2283
|
+
'The concrete product or main subject the video must be about (e.g. "Bordeaux red wine, dark green ' +
|
|
2284
|
+
'bottle with a gold label"). Distinct from the freeform brief: hard-grounds the AI scenario + narration ' +
|
|
2285
|
+
"so it cannot invent an unrelated product. Strongly recommended for ads.",
|
|
2286
|
+
),
|
|
2287
|
+
project_intent: z
|
|
2288
|
+
.enum(["social_ad", "creative_story"])
|
|
2289
|
+
.optional()
|
|
2290
|
+
.describe(
|
|
2291
|
+
"social_ad for promotional/ad content (product focus + CTA), creative_story for brand narratives " +
|
|
2292
|
+
"(default).",
|
|
2293
|
+
),
|
|
1653
2294
|
language: z
|
|
1654
2295
|
.string()
|
|
1655
2296
|
.optional()
|
|
1656
|
-
.describe(
|
|
2297
|
+
.describe(
|
|
2298
|
+
'Language code, e.g. "en" (default), "fr", "es". Drives the WHOLE video: scenario, narration script, ' +
|
|
2299
|
+
"narration voice, and captions. Ask the user / infer from their locale.",
|
|
2300
|
+
),
|
|
1657
2301
|
creative_format: z
|
|
1658
2302
|
.enum(CREATIVE_FORMATS)
|
|
1659
2303
|
.optional()
|
|
@@ -1681,42 +2325,64 @@ registerTool(
|
|
|
1681
2325
|
.optional()
|
|
1682
2326
|
.describe('Aspect ratio (default "9:16")'),
|
|
1683
2327
|
},
|
|
2328
|
+
outputSchema: createEditorOutput,
|
|
1684
2329
|
annotations: { title: "Create editor ad", ...WRITE, idempotentHint: true },
|
|
1685
2330
|
},
|
|
1686
|
-
tool(
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
2331
|
+
tool(
|
|
2332
|
+
async (
|
|
2333
|
+
args: {
|
|
2334
|
+
product_prompt: string;
|
|
2335
|
+
product_subject?: string;
|
|
2336
|
+
project_intent?: string;
|
|
2337
|
+
language?: string;
|
|
2338
|
+
creative_format?: string;
|
|
2339
|
+
visual_language?: string;
|
|
2340
|
+
theme?: string;
|
|
2341
|
+
voice_id?: string;
|
|
2342
|
+
export_aspect_ratio?: string;
|
|
1697
2343
|
},
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
2344
|
+
client,
|
|
2345
|
+
) => {
|
|
2346
|
+
const langErr = validateLanguage(args.language);
|
|
2347
|
+
if (langErr) return fail(langErr);
|
|
2348
|
+
const promptErr = validateProductPrompt(args.product_prompt);
|
|
2349
|
+
if (promptErr) return fail(promptErr);
|
|
2350
|
+
|
|
2351
|
+
const created = await client.post<{ data: { slug: string } }>(
|
|
2352
|
+
"/editor",
|
|
2353
|
+
{
|
|
2354
|
+
language: args.language ?? "en",
|
|
2355
|
+
product_prompt: args.product_prompt,
|
|
2356
|
+
product_subject: args.product_subject,
|
|
2357
|
+
project_intent: args.project_intent,
|
|
2358
|
+
creative_format: args.creative_format,
|
|
2359
|
+
visual_language: args.visual_language,
|
|
2360
|
+
theme: args.theme,
|
|
2361
|
+
voice_id: args.voice_id,
|
|
2362
|
+
export_aspect_ratio: args.export_aspect_ratio,
|
|
2363
|
+
},
|
|
2364
|
+
// Fold every create-affecting param into the key so two calls that
|
|
2365
|
+
// differ only by style/theme/voice/aspect get distinct drafts.
|
|
2366
|
+
// Composition lives in core.ts, shared with the golden-pin test.
|
|
2367
|
+
editorAdCreateKey(args),
|
|
2368
|
+
);
|
|
2369
|
+
const slug = created.data.slug;
|
|
2370
|
+
const started = await client.post<{ data: unknown }>(
|
|
2371
|
+
`/editor/${slug}/autopilot`,
|
|
2372
|
+
undefined,
|
|
2373
|
+
// Fold the create-affecting params into the start key so it tracks
|
|
2374
|
+
// inputs, not just the slug. Self-consistent per-tool only: this key
|
|
2375
|
+
// is compared against retries of *this* tool on *this* slug. The
|
|
2376
|
+
// field set/order differs from make_video / start_autopilot on
|
|
2377
|
+
// purpose (server cache is path+key scoped, slug is unique per
|
|
2378
|
+
// draft), so the keys are not interchangeable across tools.
|
|
2379
|
+
// Composition lives in core.ts, shared with the golden-pin test.
|
|
2380
|
+
editorAdAutopilotKey(slug, args),
|
|
2381
|
+
);
|
|
2382
|
+
const status = normalizeStatus("editor", slug, asRecord(started).data);
|
|
2383
|
+
return ok({ slug, kind: "editor", status, next: "wait_for_completion" });
|
|
2384
|
+
},
|
|
2385
|
+
),
|
|
1720
2386
|
);
|
|
1721
2387
|
|
|
1722
2388
|
registerTool(
|
|
@@ -1726,31 +2392,83 @@ registerTool(
|
|
|
1726
2392
|
description:
|
|
1727
2393
|
"Starts server-orchestrated autopilot (scenario → segments → narration → voice → music → render) on an " +
|
|
1728
2394
|
"EXISTING editor project/draft. Spends credits — preview the estimate first with the autopilot/cost endpoint " +
|
|
1729
|
-
"or make_video({ dry_run:true }).
|
|
1730
|
-
"
|
|
2395
|
+
"or make_video({ dry_run:true }). Each call is a genuine start attempt (like tapping Launch in the app): " +
|
|
2396
|
+
"if a run is already in progress for this slug the server returns already_running — it never double-starts " +
|
|
2397
|
+
"or double-charges. Once finished, failed, or cancelled, calling again relaunches it (resume after a top-up, raising your spend cap, or a mid-pipeline failure). Then poll with " +
|
|
1731
2398
|
'wait_for_completion({ slug, kind: "editor" }).',
|
|
1732
|
-
inputSchema: {
|
|
1733
|
-
|
|
2399
|
+
inputSchema: {
|
|
2400
|
+
slug: z.string().describe("Editor project slug"),
|
|
2401
|
+
language: z
|
|
2402
|
+
.string()
|
|
2403
|
+
.optional()
|
|
2404
|
+
.describe(
|
|
2405
|
+
'Optional language override applied before launch (ISO 639-1, e.g. "fr"). Drives scenario, ' +
|
|
2406
|
+
"narration, voice, and captions.",
|
|
2407
|
+
),
|
|
2408
|
+
product_subject: z
|
|
2409
|
+
.string()
|
|
2410
|
+
.max(300)
|
|
2411
|
+
.optional()
|
|
2412
|
+
.describe(
|
|
2413
|
+
"Optional locked product / main subject applied before launch so the AI cannot invent an " +
|
|
2414
|
+
"unrelated product.",
|
|
2415
|
+
),
|
|
2416
|
+
product_prompt: z
|
|
2417
|
+
.string()
|
|
2418
|
+
.optional()
|
|
2419
|
+
.describe("Optional freeform brief to set/replace before launch."),
|
|
2420
|
+
},
|
|
2421
|
+
annotations: { title: "Start autopilot", ...WRITE, idempotentHint: false },
|
|
1734
2422
|
},
|
|
1735
|
-
tool(
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
);
|
|
2423
|
+
tool(
|
|
2424
|
+
async (
|
|
2425
|
+
args: {
|
|
2426
|
+
slug: string;
|
|
2427
|
+
language?: string;
|
|
2428
|
+
product_subject?: string;
|
|
2429
|
+
product_prompt?: string;
|
|
2430
|
+
},
|
|
2431
|
+
client,
|
|
2432
|
+
) => {
|
|
2433
|
+
const langErr = validateLanguage(args.language);
|
|
2434
|
+
if (langErr) return fail(langErr);
|
|
2435
|
+
const promptErr = validateProductPrompt(args.product_prompt);
|
|
2436
|
+
if (promptErr) return fail(promptErr);
|
|
1750
2437
|
|
|
1751
|
-
|
|
2438
|
+
const slug = args.slug;
|
|
2439
|
+
const overrides: Record<string, unknown> = {};
|
|
2440
|
+
if (args.language !== undefined) overrides.language = args.language;
|
|
2441
|
+
if (args.product_subject !== undefined)
|
|
2442
|
+
overrides.product_subject = args.product_subject;
|
|
2443
|
+
if (args.product_prompt !== undefined)
|
|
2444
|
+
overrides.product_prompt = args.product_prompt;
|
|
1752
2445
|
|
|
1753
|
-
|
|
2446
|
+
const started = await client.post<{ data: unknown }>(
|
|
2447
|
+
`/editor/${slug}/autopilot`,
|
|
2448
|
+
Object.keys(overrides).length > 0 ? overrides : undefined,
|
|
2449
|
+
// Each call is a genuine new start attempt (matching the in-app Launch
|
|
2450
|
+
// button). A per-invocation nonce makes the idempotency key unique so the
|
|
2451
|
+
// server's 24h idempotency cache can't replay a PRIOR start's response on
|
|
2452
|
+
// a deliberate relaunch (resume after a failure / top-up). Transport-level
|
|
2453
|
+
// retries of THIS call reuse this one computed key, so an accidental
|
|
2454
|
+
// double-send is still deduped; a genuine double-start is independently
|
|
2455
|
+
// prevented server-side by the already-running gate.
|
|
2456
|
+
idemKey("autopilot", slug, randomUUID()),
|
|
2457
|
+
);
|
|
2458
|
+
const status = normalizeStatus("editor", slug, asRecord(started).data);
|
|
2459
|
+
return ok({
|
|
2460
|
+
slug,
|
|
2461
|
+
kind: "editor",
|
|
2462
|
+
status,
|
|
2463
|
+
next: "wait_for_completion",
|
|
2464
|
+
});
|
|
2465
|
+
},
|
|
2466
|
+
),
|
|
2467
|
+
);
|
|
2468
|
+
|
|
2469
|
+
// ── AI assists (free daily quota) ───────────────────────────────────────────
|
|
2470
|
+
|
|
2471
|
+
registerTool(
|
|
1754
2472
|
"get_ai_assists",
|
|
1755
2473
|
{
|
|
1756
2474
|
title: "Get AI assist quota",
|
|
@@ -1818,12 +2536,22 @@ registerTool(
|
|
|
1818
2536
|
.max(5000)
|
|
1819
2537
|
.optional()
|
|
1820
2538
|
.describe("Brief for the ad — 10–5000 chars, or omit entirely"),
|
|
2539
|
+
product_subject: z
|
|
2540
|
+
.string()
|
|
2541
|
+
.max(300)
|
|
2542
|
+
.optional()
|
|
2543
|
+
.describe(
|
|
2544
|
+
"The concrete product / main subject the video must be about. Hard-grounds the AI so it cannot " +
|
|
2545
|
+
"invent an unrelated product. Recommended for ads.",
|
|
2546
|
+
),
|
|
1821
2547
|
language: z
|
|
1822
2548
|
.string()
|
|
1823
2549
|
.min(2)
|
|
1824
2550
|
.max(10)
|
|
1825
2551
|
.optional()
|
|
1826
|
-
.describe(
|
|
2552
|
+
.describe(
|
|
2553
|
+
'Language code, e.g. "en" (default). Drives scenario, narration, voice, and captions.',
|
|
2554
|
+
),
|
|
1827
2555
|
creative_format: z
|
|
1828
2556
|
.enum(CREATIVE_FORMATS)
|
|
1829
2557
|
.optional()
|
|
@@ -1855,8 +2583,12 @@ registerTool(
|
|
|
1855
2583
|
project_intent: z
|
|
1856
2584
|
.enum(["social_ad", "creative_story"])
|
|
1857
2585
|
.optional()
|
|
1858
|
-
.describe(
|
|
2586
|
+
.describe(
|
|
2587
|
+
"social_ad for promotional/ad content (product focus + CTA), creative_story for brand " +
|
|
2588
|
+
"narratives (default).",
|
|
2589
|
+
),
|
|
1859
2590
|
},
|
|
2591
|
+
outputSchema: createEditorOutput,
|
|
1860
2592
|
annotations: {
|
|
1861
2593
|
title: "Create editor draft",
|
|
1862
2594
|
...WRITE,
|
|
@@ -1867,6 +2599,7 @@ registerTool(
|
|
|
1867
2599
|
async (
|
|
1868
2600
|
args: {
|
|
1869
2601
|
product_prompt?: string;
|
|
2602
|
+
product_subject?: string;
|
|
1870
2603
|
language?: string;
|
|
1871
2604
|
creative_format?: string;
|
|
1872
2605
|
visual_language?: string;
|
|
@@ -1880,6 +2613,7 @@ registerTool(
|
|
|
1880
2613
|
const prompt = args.product_prompt?.trim();
|
|
1881
2614
|
const body: Record<string, unknown> = {
|
|
1882
2615
|
language: args.language ?? "en",
|
|
2616
|
+
product_subject: args.product_subject,
|
|
1883
2617
|
creative_format: args.creative_format,
|
|
1884
2618
|
visual_language: args.visual_language,
|
|
1885
2619
|
theme: args.theme,
|
|
@@ -1891,13 +2625,20 @@ registerTool(
|
|
|
1891
2625
|
const created = await client.post<{ data: { slug: string } }>(
|
|
1892
2626
|
"/editor",
|
|
1893
2627
|
body,
|
|
2628
|
+
// Fold every create-affecting param into the key so two calls that
|
|
2629
|
+
// differ only by intent/style/theme/voice/aspect get distinct
|
|
2630
|
+
// drafts; a transport retry of an identical call still reuses one.
|
|
1894
2631
|
idemKey(
|
|
1895
2632
|
"create-editor-draft",
|
|
1896
2633
|
prompt ?? "",
|
|
2634
|
+
args.product_subject ?? "",
|
|
2635
|
+
args.project_intent ?? "",
|
|
1897
2636
|
args.language ?? "en",
|
|
1898
2637
|
args.creative_format ?? "",
|
|
1899
2638
|
args.visual_language ?? "",
|
|
1900
2639
|
args.theme ?? "",
|
|
2640
|
+
args.voice_id ?? "",
|
|
2641
|
+
args.export_aspect_ratio ?? "",
|
|
1901
2642
|
),
|
|
1902
2643
|
);
|
|
1903
2644
|
return ok({
|
|
@@ -2039,7 +2780,7 @@ registerTool(
|
|
|
2039
2780
|
title: "Set the number of scenes",
|
|
2040
2781
|
description:
|
|
2041
2782
|
"Adjusts the editor to exactly `count` scenes (1–20) by adding pending segments to grow, or deleting " +
|
|
2042
|
-
"only TRAILING un-generated (pending) segments to shrink. Never deletes completed/processing segments — " +
|
|
2783
|
+
"only TRAILING un-generated (pending or failed) segments to shrink. Never deletes completed/processing segments — " +
|
|
2043
2784
|
"if a shrink would require that, it stops and reports. Free (no credits, no assist). Each AI-generated " +
|
|
2044
2785
|
"scene is a fixed 8 seconds, so `count` scenes ≈ count×8s of AI footage (uploaded clips keep their own length).",
|
|
2045
2786
|
inputSchema: {
|
|
@@ -2063,15 +2804,22 @@ registerTool(
|
|
|
2063
2804
|
const target = args.count;
|
|
2064
2805
|
|
|
2065
2806
|
if (target > current) {
|
|
2807
|
+
// Salt the add-segment idempotency keys with ONE fresh nonce per call so a
|
|
2808
|
+
// grow→shrink→grow on this draft inside the server's 24h dedupe window
|
|
2809
|
+
// doesn't replay an earlier round's cached 201s (which would add nothing).
|
|
2810
|
+
// Safe: the tool re-derives `current` from live state every call, so a
|
|
2811
|
+
// whole-tool retry with the same target still adds nothing (target ≤ current).
|
|
2812
|
+
const growNonce = randomUUID();
|
|
2066
2813
|
for (let i = current; i < target; i++) {
|
|
2067
2814
|
await client.post(
|
|
2068
2815
|
`/editor/${args.slug}/segments`,
|
|
2069
2816
|
{},
|
|
2070
|
-
|
|
2817
|
+
addSegmentKey(args.slug, i, growNonce),
|
|
2071
2818
|
);
|
|
2072
2819
|
}
|
|
2073
2820
|
} else if (target < current) {
|
|
2074
|
-
// Delete only trailing
|
|
2821
|
+
// Delete only trailing un-generated segments — pending/draft/unset OR a
|
|
2822
|
+
// previously-failed scene (never completed/processing).
|
|
2075
2823
|
const isPending = (s: Record<string, unknown>) => {
|
|
2076
2824
|
const st = (s.status as string) ?? (s.generation_status as string);
|
|
2077
2825
|
return (
|
|
@@ -2088,8 +2836,8 @@ registerTool(
|
|
|
2088
2836
|
}
|
|
2089
2837
|
if (current - removable > target) {
|
|
2090
2838
|
return fail(
|
|
2091
|
-
`Cannot shrink to ${target} scenes: only ${removable} trailing pending segment(s) are safe
|
|
2092
|
-
`delete (the rest are completed/processing). Currently ${current} scenes.`,
|
|
2839
|
+
`Cannot shrink to ${target} scenes: only ${removable} trailing un-generated (pending/failed) segment(s) are safe ` +
|
|
2840
|
+
`to delete (the rest are completed/processing). Currently ${current} scenes.`,
|
|
2093
2841
|
);
|
|
2094
2842
|
}
|
|
2095
2843
|
for (let i = segments.length - 1; i >= target; i--) {
|
|
@@ -2188,7 +2936,9 @@ registerTool(
|
|
|
2188
2936
|
"the next (preserves visual continuity). Each AI-generated scene is a fixed 8 seconds, so N scenes ≈ N×8s of footage. Costs 5 credits per segment generated. Stops and reports what " +
|
|
2189
2937
|
"completed if a scene errors on submit (e.g. 402 credits_insufficient) or fails while rendering; if a " +
|
|
2190
2938
|
"scene is still rendering past the budget it returns timed_out so you can re-run to continue (completed " +
|
|
2191
|
-
"scenes are skipped).
|
|
2939
|
+
"scenes are skipped). The whole call is capped at ~280s: on a project with many scenes it processes as " +
|
|
2940
|
+
"many as fit, returns timed_out, and you re-run to continue (it picks up the remaining pending scenes). " +
|
|
2941
|
+
"Tip: to generate everything at lowest cost, skip this and just call render — it auto-generates any ungenerated scenes at the batch rate (4 credits/scene for ≥3); use this tool when you want to review or stop per scene. May take several minutes.",
|
|
2192
2942
|
inputSchema: { slug: z.string().describe("Editor project slug") },
|
|
2193
2943
|
annotations: {
|
|
2194
2944
|
title: "Generate all segments",
|
|
@@ -2212,12 +2962,34 @@ registerTool(
|
|
|
2212
2962
|
);
|
|
2213
2963
|
});
|
|
2214
2964
|
|
|
2965
|
+
// Overall wall-clock budget: cap the whole call at the same 280s ceiling the
|
|
2966
|
+
// other wait loops use (under a typical 300s client timeout). Without it a
|
|
2967
|
+
// project with many pending scenes could run unbounded (~8 min/scene × 20 ≈
|
|
2968
|
+
// hours) in a single call. When the budget runs out mid-batch we stop and
|
|
2969
|
+
// return the standard resume contract — call again and it naturally continues
|
|
2970
|
+
// with whatever scenes are still pending (completed ones are skipped).
|
|
2971
|
+
const overallDeadline = Date.now() + 280_000;
|
|
2972
|
+
// Each scene's own poll budget stays generous (a Veo scene can take minutes)
|
|
2973
|
+
// but is clamped to the time left overall so one scene can't overrun 280s.
|
|
2974
|
+
const PER_SEGMENT_MAX_MS = 6 * 60 * 1000;
|
|
2975
|
+
|
|
2215
2976
|
const generated: Array<string | number> = [];
|
|
2216
2977
|
let n = 0;
|
|
2217
2978
|
for (const seg of pending) {
|
|
2218
2979
|
const id = seg.id;
|
|
2219
2980
|
if (id === undefined) continue;
|
|
2220
2981
|
const sid = id as string | number;
|
|
2982
|
+
// Stop before submitting another scene once the overall budget is spent, so
|
|
2983
|
+
// the call returns instead of charging + waiting past the deadline.
|
|
2984
|
+
const remainingMs = overallDeadline - Date.now();
|
|
2985
|
+
if (remainingMs <= 0) {
|
|
2986
|
+
return ok({
|
|
2987
|
+
slug: args.slug,
|
|
2988
|
+
generated,
|
|
2989
|
+
timed_out: true,
|
|
2990
|
+
note: "Wall-clock budget reached — stopped before the next scene. Re-run generate_all_segments to continue (it retries failed/pending scenes and skips completed ones).",
|
|
2991
|
+
});
|
|
2992
|
+
}
|
|
2221
2993
|
await reportProgress(
|
|
2222
2994
|
extra,
|
|
2223
2995
|
++n,
|
|
@@ -2242,12 +3014,13 @@ registerTool(
|
|
|
2242
3014
|
// Gate on THIS segment finishing before starting the next, so the next
|
|
2243
3015
|
// scene can build on this one's last frame (visual continuity). Poll the
|
|
2244
3016
|
// segment itself — the project-level status never goes terminal mid-batch.
|
|
3017
|
+
// Clamp its budget to the time left overall so the whole call honours 280s.
|
|
2245
3018
|
const seg_result = await pollSegmentToTerminal(
|
|
2246
3019
|
client,
|
|
2247
3020
|
args.slug,
|
|
2248
3021
|
sid,
|
|
2249
3022
|
extra,
|
|
2250
|
-
|
|
3023
|
+
Math.min(PER_SEGMENT_MAX_MS, remainingMs),
|
|
2251
3024
|
15000,
|
|
2252
3025
|
);
|
|
2253
3026
|
if (seg_result.status === "failed") {
|
|
@@ -2707,6 +3480,8 @@ registerTool(
|
|
|
2707
3480
|
args.file_path,
|
|
2708
3481
|
{
|
|
2709
3482
|
fitMode: args.fit_mode,
|
|
3483
|
+
onProgress: (completed, total, message) =>
|
|
3484
|
+
reportProgress(extra, completed, message, total),
|
|
2710
3485
|
},
|
|
2711
3486
|
);
|
|
2712
3487
|
await reportProgress(
|
|
@@ -3140,9 +3915,12 @@ registerTool(
|
|
|
3140
3915
|
{
|
|
3141
3916
|
title: "Get generation status",
|
|
3142
3917
|
description:
|
|
3143
|
-
"Returns a normalized status {stage, terminal, ready, video_url, error} for a short
|
|
3144
|
-
"project. ready:true means the post-ready MP4 is at video_url
|
|
3918
|
+
"Returns a normalized status {stage, terminal, ready, video_url, error} for a short, editor, " +
|
|
3919
|
+
"tracking, or slider project. ready:true means the post-ready MP4 is at video_url — except for a " +
|
|
3920
|
+
"slider (a carousel of stills, not a video): its video_url is always null, ready:true means every " +
|
|
3921
|
+
"slide is composited, and you read the per-slide image URLs with get_slider.",
|
|
3145
3922
|
inputSchema: { slug: z.string(), kind: kindSchema },
|
|
3923
|
+
outputSchema: getStatusOutput,
|
|
3146
3924
|
annotations: { title: "Get status", ...RO },
|
|
3147
3925
|
},
|
|
3148
3926
|
tool(async (args: { slug: string; kind: Kind }, client) => {
|
|
@@ -3158,7 +3936,9 @@ registerTool(
|
|
|
3158
3936
|
description:
|
|
3159
3937
|
"Polls status (emitting progress) until terminal (ready/failed) or the wait budget is exhausted. " +
|
|
3160
3938
|
"Generation can take several minutes; if it returns terminal=false, call again to keep waiting. " +
|
|
3161
|
-
"Pass save_path to download the finished MP4 when it's ready (e.g. to resume a make_video that timed out mid-render)."
|
|
3939
|
+
"Pass save_path to download the finished MP4 when it's ready (e.g. to resume a make_video that timed out mid-render). " +
|
|
3940
|
+
'Works for kind:"slider" too, but a carousel has no MP4 — it just goes terminal when composited; read the slide ' +
|
|
3941
|
+
"images with get_slider (save_path is ignored for sliders).",
|
|
3162
3942
|
inputSchema: {
|
|
3163
3943
|
slug: z.string(),
|
|
3164
3944
|
kind: kindSchema,
|
|
@@ -3183,7 +3963,14 @@ registerTool(
|
|
|
3183
3963
|
"Optional .mp4 path to download to when ready (confined to HUBFLUENCER_OUTPUT_DIR or cwd)",
|
|
3184
3964
|
),
|
|
3185
3965
|
},
|
|
3186
|
-
|
|
3966
|
+
outputSchema: waitForCompletionOutput,
|
|
3967
|
+
// Not readOnly: when save_path is set this writes the finished MP4 to disk,
|
|
3968
|
+
// so a client that auto-approves readOnly tools must not treat it as one.
|
|
3969
|
+
annotations: {
|
|
3970
|
+
title: "Wait for completion",
|
|
3971
|
+
...WRITE,
|
|
3972
|
+
idempotentHint: true,
|
|
3973
|
+
},
|
|
3187
3974
|
},
|
|
3188
3975
|
tool(
|
|
3189
3976
|
async (
|
|
@@ -3232,7 +4019,8 @@ registerTool(
|
|
|
3232
4019
|
title: "Get / download the finished video",
|
|
3233
4020
|
description:
|
|
3234
4021
|
"Returns the post-ready MP4 URL for a completed project (presigned, ~24h TTL — use promptly). " +
|
|
3235
|
-
"If save_path is given (confined to HUBFLUENCER_OUTPUT_DIR or cwd), the file is downloaded there."
|
|
4022
|
+
"If save_path is given (confined to HUBFLUENCER_OUTPUT_DIR or cwd), the file is downloaded there. " +
|
|
4023
|
+
"Not for sliders — a carousel has no MP4; use get_slider for its slide images.",
|
|
3236
4024
|
inputSchema: {
|
|
3237
4025
|
slug: z.string(),
|
|
3238
4026
|
kind: kindSchema,
|
|
@@ -3241,10 +4029,21 @@ registerTool(
|
|
|
3241
4029
|
.optional()
|
|
3242
4030
|
.describe("Optional .mp4 path inside the output base"),
|
|
3243
4031
|
},
|
|
3244
|
-
|
|
4032
|
+
// Not readOnly: with save_path it writes the MP4 to disk. Reading the URL is
|
|
4033
|
+
// side-effect-free but the tool as a whole can write, so it isn't readOnly.
|
|
4034
|
+
outputSchema: downloadResultOutput,
|
|
4035
|
+
annotations: { title: "Download result", ...WRITE, idempotentHint: true },
|
|
3245
4036
|
},
|
|
3246
4037
|
tool(
|
|
3247
4038
|
async (args: { slug: string; kind: Kind; save_path?: string }, client) => {
|
|
4039
|
+
// A slider has no MP4 deliverable — steer the agent to get_slider instead
|
|
4040
|
+
// of the misleading "not ready" path (a completed slider IS ready, but its
|
|
4041
|
+
// video_url is null by design).
|
|
4042
|
+
if (args.kind === "slider") {
|
|
4043
|
+
return fail(
|
|
4044
|
+
"A slider (carousel) has no MP4 to download. Use get_slider to read the per-slide image URLs.",
|
|
4045
|
+
);
|
|
4046
|
+
}
|
|
3248
4047
|
const status = await fetchStatus(client, args.kind, args.slug);
|
|
3249
4048
|
if (!status.ready || !status.video_url) {
|
|
3250
4049
|
return fail(
|
|
@@ -3270,6 +4069,2111 @@ registerTool(
|
|
|
3270
4069
|
),
|
|
3271
4070
|
);
|
|
3272
4071
|
|
|
4072
|
+
// ── Campaign engine: product sources ─────────────────────────────────────────
|
|
4073
|
+
//
|
|
4074
|
+
// The "delegate my social content" surface. An agent ingests a product page,
|
|
4075
|
+
// persists a durable product/brand identity, plans a campaign, and creates a
|
|
4076
|
+
// pack full of editable DRAFTS — all at $0 (the planning tools meter the FREE
|
|
4077
|
+
// daily AI-assist quota; materialize creates 0-credit drafts). Generation/render
|
|
4078
|
+
// stays a SEPARATE, explicit, PAID step (autopilot/generate per draft).
|
|
4079
|
+
//
|
|
4080
|
+
// GROUND TRUTH: none of the campaign controllers mount the Idempotency plug, so
|
|
4081
|
+
// these tools send NO Idempotency-Key and their idempotentHint reflects the
|
|
4082
|
+
// server's real behavior (creates fork on retry; materialize is idempotent via a
|
|
4083
|
+
// server-side row lock; extract is stateless).
|
|
4084
|
+
|
|
4085
|
+
registerTool(
|
|
4086
|
+
"extract_product_page",
|
|
4087
|
+
{
|
|
4088
|
+
title: "Extract product facts from a URL",
|
|
4089
|
+
description:
|
|
4090
|
+
"Fetches a public product page SERVER-SIDE (SSRF-guarded, HTML-only, no redirects to internal ranges) " +
|
|
4091
|
+
"and returns parsed product facts: name, description, benefits, price, brand, plus candidate product " +
|
|
4092
|
+
"image URLs and a site/brand logo URL (URLs only — images are NOT downloaded here). Stateless: writes " +
|
|
4093
|
+
"nothing and spends 0 credits. Scope: video:generate. Rate limit: 30/min. " +
|
|
4094
|
+
"Next: create_product_profile from these facts, then import_product_image to pull the primary image + logo " +
|
|
4095
|
+
"into the profile — or just call create_campaign to do all of that end to end.",
|
|
4096
|
+
inputSchema: {
|
|
4097
|
+
url: z
|
|
4098
|
+
.string()
|
|
4099
|
+
.max(2048)
|
|
4100
|
+
.describe("Absolute http(s) URL of a product page (≤2048 chars)"),
|
|
4101
|
+
},
|
|
4102
|
+
annotations: {
|
|
4103
|
+
title: "Extract product page",
|
|
4104
|
+
...WRITE,
|
|
4105
|
+
// Stateless (no DB write, no credit) → replaying the same URL is safe.
|
|
4106
|
+
idempotentHint: true,
|
|
4107
|
+
},
|
|
4108
|
+
},
|
|
4109
|
+
tool(async (args: { url: string }, client) => {
|
|
4110
|
+
const url = args.url?.trim();
|
|
4111
|
+
if (!url) return fail("url is required.");
|
|
4112
|
+
const res = await client.post<{ data: unknown }>(
|
|
4113
|
+
"/product-sources/extract",
|
|
4114
|
+
{ url },
|
|
4115
|
+
);
|
|
4116
|
+
return ok(asRecord(res).data ?? res);
|
|
4117
|
+
}),
|
|
4118
|
+
);
|
|
4119
|
+
|
|
4120
|
+
registerTool(
|
|
4121
|
+
"import_product_image",
|
|
4122
|
+
{
|
|
4123
|
+
title: "Import a product image URL into a profile",
|
|
4124
|
+
description:
|
|
4125
|
+
"Fetches an image at a public URL SERVER-SIDE (SSRF-guarded, JPEG/PNG only — verified by magic bytes, not " +
|
|
4126
|
+
"just the Content-Type — size-capped) and stores it as a ready catalog asset, linking it to a product " +
|
|
4127
|
+
"profile. link_as 'primary_image' (default) sets the profile's product image; 'logo' sets its logo. " +
|
|
4128
|
+
"Spends 0 credits. Scope: video:generate. Rate limit: 30/min. Use the image/logo URLs from " +
|
|
4129
|
+
"extract_product_page. 404 if the product profile isn't yours; 422 if the URL isn't a safe JPG/PNG.",
|
|
4130
|
+
inputSchema: {
|
|
4131
|
+
product_profile_id: z
|
|
4132
|
+
.number()
|
|
4133
|
+
.int()
|
|
4134
|
+
.describe("Id of one of YOUR product profiles to link the image to"),
|
|
4135
|
+
url: z
|
|
4136
|
+
.string()
|
|
4137
|
+
.max(2048)
|
|
4138
|
+
.describe("Absolute http(s) URL of a JPG/PNG image (≤2048 chars)"),
|
|
4139
|
+
link_as: z
|
|
4140
|
+
.enum(["primary_image", "logo"])
|
|
4141
|
+
.optional()
|
|
4142
|
+
.describe(
|
|
4143
|
+
"Which profile field to set: 'primary_image' (default) or 'logo'",
|
|
4144
|
+
),
|
|
4145
|
+
},
|
|
4146
|
+
annotations: {
|
|
4147
|
+
title: "Import product image",
|
|
4148
|
+
...WRITE,
|
|
4149
|
+
// Each call stores a NEW catalog asset (no idempotency plug) → not idempotent.
|
|
4150
|
+
idempotentHint: false,
|
|
4151
|
+
},
|
|
4152
|
+
},
|
|
4153
|
+
tool(
|
|
4154
|
+
async (
|
|
4155
|
+
args: {
|
|
4156
|
+
product_profile_id: number;
|
|
4157
|
+
url: string;
|
|
4158
|
+
link_as?: "primary_image" | "logo";
|
|
4159
|
+
},
|
|
4160
|
+
client,
|
|
4161
|
+
) => {
|
|
4162
|
+
const url = args.url?.trim();
|
|
4163
|
+
if (!url) return fail("url is required.");
|
|
4164
|
+
// The controller keys the image URL off `image_url` (distinct from
|
|
4165
|
+
// extract's `url`); map the agent-facing `url` onto it.
|
|
4166
|
+
const body: Record<string, unknown> = {
|
|
4167
|
+
image_url: url,
|
|
4168
|
+
product_profile_id: args.product_profile_id,
|
|
4169
|
+
};
|
|
4170
|
+
if (args.link_as !== undefined) body.link_as = args.link_as;
|
|
4171
|
+
const res = await client.post<{ data: unknown }>(
|
|
4172
|
+
"/product-sources/import-image",
|
|
4173
|
+
body,
|
|
4174
|
+
);
|
|
4175
|
+
return ok(asRecord(res).data ?? res);
|
|
4176
|
+
},
|
|
4177
|
+
),
|
|
4178
|
+
);
|
|
4179
|
+
|
|
4180
|
+
// ── Campaign engine: product profiles ────────────────────────────────────────
|
|
4181
|
+
|
|
4182
|
+
registerTool(
|
|
4183
|
+
"list_product_profiles",
|
|
4184
|
+
{
|
|
4185
|
+
title: "List your product profiles",
|
|
4186
|
+
description:
|
|
4187
|
+
"Lists your saved product fact sheets (name, benefits, offer, audience, CTA, compliance constraints, " +
|
|
4188
|
+
"imported image/logo keys), newest first. Read-only, 0 credits. Scope: video:read. Pass an id to plan_campaign / " +
|
|
4189
|
+
"generate_hook_variations / create_campaign_pack so a campaign is planned from a saved source of truth.",
|
|
4190
|
+
inputSchema: {},
|
|
4191
|
+
annotations: { title: "List product profiles", ...RO },
|
|
4192
|
+
},
|
|
4193
|
+
tool(async (_args, client) => ok(await client.get("/product-profiles"))),
|
|
4194
|
+
);
|
|
4195
|
+
|
|
4196
|
+
registerTool(
|
|
4197
|
+
"create_product_profile",
|
|
4198
|
+
{
|
|
4199
|
+
title: "Create a product profile",
|
|
4200
|
+
description:
|
|
4201
|
+
"Saves a reusable product fact sheet. Only `name` is required; the rest (description, benefits[], offer, " +
|
|
4202
|
+
"proof_points[], audience, cta, banned_claims[], brand_tone) ground the AI when planning/generating. " +
|
|
4203
|
+
"0 credits. Scope: video:generate. Tip: seed it from extract_product_page's facts. To attach imagery, call " +
|
|
4204
|
+
"import_product_image afterwards (sets primary_image/logo).",
|
|
4205
|
+
inputSchema: {
|
|
4206
|
+
name: z.string().min(1).max(300).describe("Product name (required)"),
|
|
4207
|
+
description: z
|
|
4208
|
+
.string()
|
|
4209
|
+
.max(4000)
|
|
4210
|
+
.optional()
|
|
4211
|
+
.describe("What the product is / does"),
|
|
4212
|
+
benefits: z
|
|
4213
|
+
.array(z.string())
|
|
4214
|
+
.optional()
|
|
4215
|
+
.describe("Key benefit statements"),
|
|
4216
|
+
offer: z
|
|
4217
|
+
.string()
|
|
4218
|
+
.max(500)
|
|
4219
|
+
.optional()
|
|
4220
|
+
.describe("Headline offer / price / promotion"),
|
|
4221
|
+
proof_points: z
|
|
4222
|
+
.array(z.string())
|
|
4223
|
+
.optional()
|
|
4224
|
+
.describe("Ratings, review counts, other proof points"),
|
|
4225
|
+
audience: z.string().max(500).optional().describe("Target audience"),
|
|
4226
|
+
cta: z.string().max(300).optional().describe("Preferred call to action"),
|
|
4227
|
+
banned_claims: z
|
|
4228
|
+
.array(z.string())
|
|
4229
|
+
.optional()
|
|
4230
|
+
.describe("Claims the product must never make"),
|
|
4231
|
+
brand_tone: z
|
|
4232
|
+
.string()
|
|
4233
|
+
.max(500)
|
|
4234
|
+
.optional()
|
|
4235
|
+
.describe("Tone of voice for generated copy"),
|
|
4236
|
+
source_url: z
|
|
4237
|
+
.string()
|
|
4238
|
+
.max(2048)
|
|
4239
|
+
.optional()
|
|
4240
|
+
.describe("Product page URL the facts came from"),
|
|
4241
|
+
},
|
|
4242
|
+
annotations: {
|
|
4243
|
+
title: "Create product profile",
|
|
4244
|
+
...WRITE,
|
|
4245
|
+
// No idempotency plug → a retry forks a duplicate profile.
|
|
4246
|
+
idempotentHint: false,
|
|
4247
|
+
},
|
|
4248
|
+
},
|
|
4249
|
+
tool(async (args: Record<string, unknown>, client) => {
|
|
4250
|
+
const res = await client.post<{ data: unknown }>(
|
|
4251
|
+
"/product-profiles",
|
|
4252
|
+
pickProductProfileFields(args),
|
|
4253
|
+
);
|
|
4254
|
+
return ok(asRecord(res).data ?? res);
|
|
4255
|
+
}),
|
|
4256
|
+
);
|
|
4257
|
+
|
|
4258
|
+
registerTool(
|
|
4259
|
+
"update_product_profile",
|
|
4260
|
+
{
|
|
4261
|
+
title: "Update a product profile",
|
|
4262
|
+
description:
|
|
4263
|
+
"Patches a saved product profile — only the fields you pass change. 0 credits. Scope: video:generate. " +
|
|
4264
|
+
"404 for an unknown/foreign id.",
|
|
4265
|
+
inputSchema: {
|
|
4266
|
+
id: z.number().int().describe("Product profile id"),
|
|
4267
|
+
name: z.string().min(1).max(300).optional().describe("Product name"),
|
|
4268
|
+
description: z.string().max(4000).optional(),
|
|
4269
|
+
benefits: z.array(z.string()).optional(),
|
|
4270
|
+
offer: z.string().max(500).optional(),
|
|
4271
|
+
proof_points: z.array(z.string()).optional(),
|
|
4272
|
+
audience: z.string().max(500).optional(),
|
|
4273
|
+
cta: z.string().max(300).optional(),
|
|
4274
|
+
banned_claims: z.array(z.string()).optional(),
|
|
4275
|
+
brand_tone: z.string().max(500).optional(),
|
|
4276
|
+
source_url: z.string().max(2048).optional(),
|
|
4277
|
+
},
|
|
4278
|
+
annotations: {
|
|
4279
|
+
title: "Update product profile",
|
|
4280
|
+
...WRITE,
|
|
4281
|
+
// PATCH by id → replaying is naturally idempotent (same fields → same state).
|
|
4282
|
+
idempotentHint: true,
|
|
4283
|
+
},
|
|
4284
|
+
},
|
|
4285
|
+
tool(async (args: { id: number } & Record<string, unknown>, client) => {
|
|
4286
|
+
const res = await client.patch<{ data: unknown }>(
|
|
4287
|
+
`/product-profiles/${args.id}`,
|
|
4288
|
+
pickProductProfileFields(args),
|
|
4289
|
+
);
|
|
4290
|
+
return ok(asRecord(res).data ?? res);
|
|
4291
|
+
}),
|
|
4292
|
+
);
|
|
4293
|
+
|
|
4294
|
+
// ── Campaign engine: brand profiles ──────────────────────────────────────────
|
|
4295
|
+
|
|
4296
|
+
registerTool(
|
|
4297
|
+
"list_brand_profiles",
|
|
4298
|
+
{
|
|
4299
|
+
title: "List your brand profiles",
|
|
4300
|
+
description:
|
|
4301
|
+
"Lists your saved brand identities (name, logo key, colors, font, tone words, default CTA/audience, " +
|
|
4302
|
+
"preferred visual_language/creative_format, and linked assets), newest first. Read-only, 0 credits. " +
|
|
4303
|
+
"Scope: video:read. Pass an id as brand_profile_id to generate_hook_variations / create_campaign_pack / " +
|
|
4304
|
+
"create_campaign to steer tone/format and inherit the logo into materialized drafts.",
|
|
4305
|
+
inputSchema: {},
|
|
4306
|
+
annotations: { title: "List brand profiles", ...RO },
|
|
4307
|
+
},
|
|
4308
|
+
tool(async (_args, client) => ok(await client.get("/brand-profiles"))),
|
|
4309
|
+
);
|
|
4310
|
+
|
|
4311
|
+
registerTool(
|
|
4312
|
+
"create_brand_profile",
|
|
4313
|
+
{
|
|
4314
|
+
title: "Create a brand profile",
|
|
4315
|
+
description:
|
|
4316
|
+
"Saves a reusable brand identity. Only `name` is required. primary_color/secondary_color are hex " +
|
|
4317
|
+
"(e.g. #09EFBE); preferred_visual_language and preferred_creative_format take the same values as the " +
|
|
4318
|
+
"editor/short tools; tone_words[]/default_cta/default_audience/banned_claims[] steer generated copy. " +
|
|
4319
|
+
"0 credits. Scope: video:generate. Attach a logo/product asset from your catalog with " +
|
|
4320
|
+
"update_brand_profile (attach_catalog_asset_id).",
|
|
4321
|
+
inputSchema: {
|
|
4322
|
+
name: z.string().min(1).max(300).describe("Brand name (required)"),
|
|
4323
|
+
logo_s3_key: z
|
|
4324
|
+
.string()
|
|
4325
|
+
.optional()
|
|
4326
|
+
.describe("S3 key of the brand logo (from an import/upload)"),
|
|
4327
|
+
primary_color: z.string().optional().describe("Hex color, e.g. #ffffff"),
|
|
4328
|
+
secondary_color: z
|
|
4329
|
+
.string()
|
|
4330
|
+
.optional()
|
|
4331
|
+
.describe("Hex color, e.g. #ffffff"),
|
|
4332
|
+
font_family: z
|
|
4333
|
+
.string()
|
|
4334
|
+
.optional()
|
|
4335
|
+
.describe("Brand typography (a supported Shorts overlay font)"),
|
|
4336
|
+
tone_words: z
|
|
4337
|
+
.array(z.string())
|
|
4338
|
+
.optional()
|
|
4339
|
+
.describe("Tone descriptors, e.g. ['bold','warm']"),
|
|
4340
|
+
default_cta: z.string().max(300).optional(),
|
|
4341
|
+
default_audience: z.string().max(500).optional(),
|
|
4342
|
+
banned_claims: z.array(z.string()).optional(),
|
|
4343
|
+
preferred_visual_language: z
|
|
4344
|
+
.enum(VISUAL_LANGUAGES)
|
|
4345
|
+
.optional()
|
|
4346
|
+
.describe("Default visual language for generated videos"),
|
|
4347
|
+
preferred_creative_format: z
|
|
4348
|
+
.enum(CREATIVE_FORMATS)
|
|
4349
|
+
.optional()
|
|
4350
|
+
.describe("Default creative format for generated videos"),
|
|
4351
|
+
},
|
|
4352
|
+
annotations: {
|
|
4353
|
+
title: "Create brand profile",
|
|
4354
|
+
...WRITE,
|
|
4355
|
+
idempotentHint: false,
|
|
4356
|
+
},
|
|
4357
|
+
},
|
|
4358
|
+
tool(async (args: Record<string, unknown>, client) => {
|
|
4359
|
+
const res = await client.post<{ data: unknown }>(
|
|
4360
|
+
"/brand-profiles",
|
|
4361
|
+
pickBrandProfileFields(args),
|
|
4362
|
+
);
|
|
4363
|
+
return ok(asRecord(res).data ?? res);
|
|
4364
|
+
}),
|
|
4365
|
+
);
|
|
4366
|
+
|
|
4367
|
+
registerTool(
|
|
4368
|
+
"update_brand_profile",
|
|
4369
|
+
{
|
|
4370
|
+
title: "Update a brand profile (+ attach/detach assets)",
|
|
4371
|
+
description:
|
|
4372
|
+
"Patches a saved brand profile — only the fields you pass change. 0 credits. Scope: video:generate. " +
|
|
4373
|
+
"You can ALSO manage its catalog-asset links in the same call: attach_catalog_asset_id links one of your " +
|
|
4374
|
+
"catalog assets (role required: 'logo' | 'product_image' | 'other'); detach_asset_id removes an existing " +
|
|
4375
|
+
"link by its LINK id (from the profile's assets[].id, not the catalog_asset_id). 404 for an unknown/foreign " +
|
|
4376
|
+
"profile; 422 if the catalog asset isn't yours. Returns the updated profile with its assets.",
|
|
4377
|
+
inputSchema: {
|
|
4378
|
+
id: z.number().int().describe("Brand profile id"),
|
|
4379
|
+
name: z.string().min(1).max(300).optional(),
|
|
4380
|
+
logo_s3_key: z.string().optional(),
|
|
4381
|
+
primary_color: z.string().optional().describe("Hex color, e.g. #ffffff"),
|
|
4382
|
+
secondary_color: z.string().optional().describe("Hex color"),
|
|
4383
|
+
font_family: z.string().optional(),
|
|
4384
|
+
tone_words: z.array(z.string()).optional(),
|
|
4385
|
+
default_cta: z.string().max(300).optional(),
|
|
4386
|
+
default_audience: z.string().max(500).optional(),
|
|
4387
|
+
banned_claims: z.array(z.string()).optional(),
|
|
4388
|
+
preferred_visual_language: z.enum(VISUAL_LANGUAGES).optional(),
|
|
4389
|
+
preferred_creative_format: z.enum(CREATIVE_FORMATS).optional(),
|
|
4390
|
+
attach_catalog_asset_id: z
|
|
4391
|
+
.number()
|
|
4392
|
+
.int()
|
|
4393
|
+
.optional()
|
|
4394
|
+
.describe(
|
|
4395
|
+
"Link this catalog asset id to the profile (requires asset_role)",
|
|
4396
|
+
),
|
|
4397
|
+
asset_role: z
|
|
4398
|
+
.enum(["logo", "product_image", "other"])
|
|
4399
|
+
.optional()
|
|
4400
|
+
.describe("Role for attach_catalog_asset_id (required when attaching)"),
|
|
4401
|
+
detach_asset_id: z
|
|
4402
|
+
.number()
|
|
4403
|
+
.int()
|
|
4404
|
+
.optional()
|
|
4405
|
+
.describe("Remove this asset LINK id (assets[].id) from the profile"),
|
|
4406
|
+
},
|
|
4407
|
+
annotations: {
|
|
4408
|
+
title: "Update brand profile",
|
|
4409
|
+
...WRITE,
|
|
4410
|
+
// The PATCH itself is idempotent, but attach/detach sub-calls create/delete
|
|
4411
|
+
// links (not idempotency-keyed), so a blind retry isn't a clean no-op.
|
|
4412
|
+
idempotentHint: false,
|
|
4413
|
+
},
|
|
4414
|
+
},
|
|
4415
|
+
tool(
|
|
4416
|
+
async (
|
|
4417
|
+
args: {
|
|
4418
|
+
id: number;
|
|
4419
|
+
attach_catalog_asset_id?: number;
|
|
4420
|
+
asset_role?: "logo" | "product_image" | "other";
|
|
4421
|
+
detach_asset_id?: number;
|
|
4422
|
+
} & Record<string, unknown>,
|
|
4423
|
+
client,
|
|
4424
|
+
) => {
|
|
4425
|
+
if (
|
|
4426
|
+
args.attach_catalog_asset_id !== undefined &&
|
|
4427
|
+
args.asset_role === undefined
|
|
4428
|
+
) {
|
|
4429
|
+
return fail(
|
|
4430
|
+
"asset_role is required when attach_catalog_asset_id is set ('logo' | 'product_image' | 'other').",
|
|
4431
|
+
);
|
|
4432
|
+
}
|
|
4433
|
+
// 1) Patch the scalar brand fields when any were supplied.
|
|
4434
|
+
const fields = pickBrandProfileFields(args);
|
|
4435
|
+
if (Object.keys(fields).length > 0) {
|
|
4436
|
+
await client.patch(`/brand-profiles/${args.id}`, fields);
|
|
4437
|
+
}
|
|
4438
|
+
// 2) Attach a catalog asset (separate endpoint keyed on catalog_asset_id).
|
|
4439
|
+
if (args.attach_catalog_asset_id !== undefined) {
|
|
4440
|
+
await client.post(`/brand-profiles/${args.id}/assets`, {
|
|
4441
|
+
catalog_asset_id: args.attach_catalog_asset_id,
|
|
4442
|
+
role: args.asset_role,
|
|
4443
|
+
});
|
|
4444
|
+
}
|
|
4445
|
+
// 3) Detach an asset LINK by its id (204 No Content).
|
|
4446
|
+
if (args.detach_asset_id !== undefined) {
|
|
4447
|
+
await client.del(
|
|
4448
|
+
`/brand-profiles/${args.id}/assets/${args.detach_asset_id}`,
|
|
4449
|
+
);
|
|
4450
|
+
}
|
|
4451
|
+
// Return the fresh profile with its (updated) assets.
|
|
4452
|
+
const res = await client.get<{ data: unknown }>(
|
|
4453
|
+
`/brand-profiles/${args.id}`,
|
|
4454
|
+
);
|
|
4455
|
+
return ok(asRecord(res).data ?? res);
|
|
4456
|
+
},
|
|
4457
|
+
),
|
|
4458
|
+
);
|
|
4459
|
+
|
|
4460
|
+
// ── Campaign engine: planning (free AI-assist quota) ──────────────────────────
|
|
4461
|
+
|
|
4462
|
+
registerTool(
|
|
4463
|
+
"plan_campaign",
|
|
4464
|
+
{
|
|
4465
|
+
title: "Plan a campaign from product facts",
|
|
4466
|
+
description:
|
|
4467
|
+
"Turns product facts (a saved product_profile_id OR an inline product with a name) into a campaign PLAN: " +
|
|
4468
|
+
"ad hooks, recommended output formats, a one-line positioning statement, and a transparent credit ESTIMATE " +
|
|
4469
|
+
"for the proposed paid outputs. Generates no media and persists nothing. Spends $0 — it consumes the FREE " +
|
|
4470
|
+
"daily AI-assist quota (NOT credits). Scope: video:generate. Rate limit: 30/min. On 429 the quota is " +
|
|
4471
|
+
"exhausted: set auto_unlock:true to spend 1 credit for +10 assists (retried once) or call unlock_ai_assists. " +
|
|
4472
|
+
"Next: generate_hook_variations for draftable variations, then create_campaign_pack.",
|
|
4473
|
+
inputSchema: {
|
|
4474
|
+
product_profile_id: z
|
|
4475
|
+
.number()
|
|
4476
|
+
.int()
|
|
4477
|
+
.optional()
|
|
4478
|
+
.describe("Id of a saved product profile (OR pass product)"),
|
|
4479
|
+
product: z
|
|
4480
|
+
.object({
|
|
4481
|
+
name: z.string(),
|
|
4482
|
+
description: z.string().optional(),
|
|
4483
|
+
benefits: z.array(z.string()).optional(),
|
|
4484
|
+
offer: z.string().optional(),
|
|
4485
|
+
proof_points: z.array(z.string()).optional(),
|
|
4486
|
+
audience: z.string().optional(),
|
|
4487
|
+
cta: z.string().optional(),
|
|
4488
|
+
banned_claims: z.array(z.string()).optional(),
|
|
4489
|
+
})
|
|
4490
|
+
.optional()
|
|
4491
|
+
.describe(
|
|
4492
|
+
"Inline product facts (name required) if you have no profile",
|
|
4493
|
+
),
|
|
4494
|
+
goal: z
|
|
4495
|
+
.string()
|
|
4496
|
+
.optional()
|
|
4497
|
+
.describe("Campaign goal, e.g. awareness or conversion"),
|
|
4498
|
+
platform: z
|
|
4499
|
+
.string()
|
|
4500
|
+
.optional()
|
|
4501
|
+
.describe("Target platform, e.g. tiktok, instagram, reels, shorts"),
|
|
4502
|
+
language: z
|
|
4503
|
+
.string()
|
|
4504
|
+
.optional()
|
|
4505
|
+
.describe('Viewer-facing language code, e.g. "en" (default)'),
|
|
4506
|
+
auto_unlock: z
|
|
4507
|
+
.boolean()
|
|
4508
|
+
.optional()
|
|
4509
|
+
.describe(
|
|
4510
|
+
"On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
|
|
4511
|
+
),
|
|
4512
|
+
},
|
|
4513
|
+
annotations: {
|
|
4514
|
+
title: "Plan campaign",
|
|
4515
|
+
...WRITE,
|
|
4516
|
+
// Consumes the free quota (a side effect) → not idempotent.
|
|
4517
|
+
idempotentHint: false,
|
|
4518
|
+
},
|
|
4519
|
+
},
|
|
4520
|
+
tool(
|
|
4521
|
+
async (
|
|
4522
|
+
args: {
|
|
4523
|
+
product_profile_id?: number;
|
|
4524
|
+
product?: Record<string, unknown>;
|
|
4525
|
+
goal?: string;
|
|
4526
|
+
platform?: string;
|
|
4527
|
+
language?: string;
|
|
4528
|
+
auto_unlock?: boolean;
|
|
4529
|
+
},
|
|
4530
|
+
client,
|
|
4531
|
+
) => {
|
|
4532
|
+
const srcErr = validatePlanSource(
|
|
4533
|
+
args.product_profile_id,
|
|
4534
|
+
args.product as { name?: unknown } | undefined,
|
|
4535
|
+
);
|
|
4536
|
+
if (srcErr) return fail(srcErr);
|
|
4537
|
+
const body = buildPlanBody(args);
|
|
4538
|
+
const res = await withAssist(client, args.auto_unlock ?? false, () =>
|
|
4539
|
+
client.post<{ data: unknown }>("/campaign-packs/plan", body),
|
|
4540
|
+
);
|
|
4541
|
+
return ok(asRecord(res).data ?? res);
|
|
4542
|
+
},
|
|
4543
|
+
),
|
|
4544
|
+
);
|
|
4545
|
+
|
|
4546
|
+
registerTool(
|
|
4547
|
+
"generate_hook_variations",
|
|
4548
|
+
{
|
|
4549
|
+
title: "Generate a hook variations board",
|
|
4550
|
+
description:
|
|
4551
|
+
"Turns product facts (a saved product_profile_id OR an inline product with a name) and an OPTIONAL " +
|
|
4552
|
+
"brand_profile_id into 3-6 distinct hook variations — each with a hook, angle, suggested output format " +
|
|
4553
|
+
"(editor_ad/short/carousel), a draft script, a ready-to-post caption, hashtags, and a per-variation credit " +
|
|
4554
|
+
"ESTIMATE. Generates no media and persists nothing. Spends $0 — it consumes the FREE daily AI-assist quota " +
|
|
4555
|
+
"(NOT credits). Scope: video:generate. Rate limit: 30/min. On 429 set auto_unlock:true (1 credit → +10 " +
|
|
4556
|
+
"assists, retried once) or call unlock_ai_assists. Next: create_campaign_pack, then add_pack_variations to " +
|
|
4557
|
+
"persist + materialize the ones you pick as DRAFTS.",
|
|
4558
|
+
inputSchema: {
|
|
4559
|
+
product_profile_id: z
|
|
4560
|
+
.number()
|
|
4561
|
+
.int()
|
|
4562
|
+
.optional()
|
|
4563
|
+
.describe("Id of a saved product profile (OR pass product)"),
|
|
4564
|
+
product: z
|
|
4565
|
+
.object({
|
|
4566
|
+
name: z.string(),
|
|
4567
|
+
description: z.string().optional(),
|
|
4568
|
+
benefits: z.array(z.string()).optional(),
|
|
4569
|
+
offer: z.string().optional(),
|
|
4570
|
+
proof_points: z.array(z.string()).optional(),
|
|
4571
|
+
audience: z.string().optional(),
|
|
4572
|
+
cta: z.string().optional(),
|
|
4573
|
+
banned_claims: z.array(z.string()).optional(),
|
|
4574
|
+
})
|
|
4575
|
+
.optional()
|
|
4576
|
+
.describe(
|
|
4577
|
+
"Inline product facts (name required) if you have no profile",
|
|
4578
|
+
),
|
|
4579
|
+
brand_profile_id: z
|
|
4580
|
+
.number()
|
|
4581
|
+
.int()
|
|
4582
|
+
.optional()
|
|
4583
|
+
.describe(
|
|
4584
|
+
"Optional saved brand profile to steer tone/format/banned claims",
|
|
4585
|
+
),
|
|
4586
|
+
count: z
|
|
4587
|
+
.number()
|
|
4588
|
+
.int()
|
|
4589
|
+
.optional()
|
|
4590
|
+
.describe("Desired number of variations, clamped to 3-6 (default 4)"),
|
|
4591
|
+
goal: z.string().optional().describe("Campaign goal"),
|
|
4592
|
+
platform: z
|
|
4593
|
+
.string()
|
|
4594
|
+
.optional()
|
|
4595
|
+
.describe("Target platform, e.g. tiktok, instagram"),
|
|
4596
|
+
language: z
|
|
4597
|
+
.string()
|
|
4598
|
+
.optional()
|
|
4599
|
+
.describe('Viewer-facing language code, e.g. "en" (default)'),
|
|
4600
|
+
auto_unlock: z
|
|
4601
|
+
.boolean()
|
|
4602
|
+
.optional()
|
|
4603
|
+
.describe(
|
|
4604
|
+
"On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
|
|
4605
|
+
),
|
|
4606
|
+
},
|
|
4607
|
+
annotations: {
|
|
4608
|
+
title: "Generate hook variations",
|
|
4609
|
+
...WRITE,
|
|
4610
|
+
idempotentHint: false,
|
|
4611
|
+
},
|
|
4612
|
+
},
|
|
4613
|
+
tool(
|
|
4614
|
+
async (
|
|
4615
|
+
args: {
|
|
4616
|
+
product_profile_id?: number;
|
|
4617
|
+
product?: Record<string, unknown>;
|
|
4618
|
+
brand_profile_id?: number;
|
|
4619
|
+
count?: number;
|
|
4620
|
+
goal?: string;
|
|
4621
|
+
platform?: string;
|
|
4622
|
+
language?: string;
|
|
4623
|
+
auto_unlock?: boolean;
|
|
4624
|
+
},
|
|
4625
|
+
client,
|
|
4626
|
+
) => {
|
|
4627
|
+
const srcErr = validatePlanSource(
|
|
4628
|
+
args.product_profile_id,
|
|
4629
|
+
args.product as { name?: unknown } | undefined,
|
|
4630
|
+
);
|
|
4631
|
+
if (srcErr) return fail(srcErr);
|
|
4632
|
+
const body = buildPlanBody(args);
|
|
4633
|
+
const res = await withAssist(client, args.auto_unlock ?? false, () =>
|
|
4634
|
+
client.post<{ data: unknown }>("/campaign-packs/hook-variations", body),
|
|
4635
|
+
);
|
|
4636
|
+
return ok(asRecord(res).data ?? res);
|
|
4637
|
+
},
|
|
4638
|
+
),
|
|
4639
|
+
);
|
|
4640
|
+
|
|
4641
|
+
// ── Campaign engine: packs (persist / materialize) ───────────────────────────
|
|
4642
|
+
|
|
4643
|
+
registerTool(
|
|
4644
|
+
"create_campaign_pack",
|
|
4645
|
+
{
|
|
4646
|
+
title: "Persist a campaign pack",
|
|
4647
|
+
description:
|
|
4648
|
+
"Stores a campaign pack (the planning container) plus any inline TEXT items (hook/caption copy). editor_ad/" +
|
|
4649
|
+
"short/carousel items are NOT created here — persist them via add_pack_variations (which also materializes " +
|
|
4650
|
+
"them into drafts). Spends 0 credits. Scope: video:generate. Link a product_profile_id and/or brand_profile_id " +
|
|
4651
|
+
"so materialized drafts inherit the product image + brand logo. Returns the pack id + items. Next: " +
|
|
4652
|
+
"add_pack_variations to fill it with draftable outputs.",
|
|
4653
|
+
inputSchema: {
|
|
4654
|
+
title: z
|
|
4655
|
+
.string()
|
|
4656
|
+
.max(300)
|
|
4657
|
+
.optional()
|
|
4658
|
+
.describe("Human-friendly pack name"),
|
|
4659
|
+
product_profile_id: z
|
|
4660
|
+
.number()
|
|
4661
|
+
.int()
|
|
4662
|
+
.optional()
|
|
4663
|
+
.describe(
|
|
4664
|
+
"Saved product profile to ground the pack (422 if not yours)",
|
|
4665
|
+
),
|
|
4666
|
+
brand_profile_id: z
|
|
4667
|
+
.number()
|
|
4668
|
+
.int()
|
|
4669
|
+
.optional()
|
|
4670
|
+
.describe(
|
|
4671
|
+
"Saved brand profile applied to the pack + materialized drafts",
|
|
4672
|
+
),
|
|
4673
|
+
brief: z
|
|
4674
|
+
.string()
|
|
4675
|
+
.max(4000)
|
|
4676
|
+
.optional()
|
|
4677
|
+
.describe("Freeform brief for the campaign"),
|
|
4678
|
+
source_type: z
|
|
4679
|
+
.enum(["url", "image", "brief", "asset"])
|
|
4680
|
+
.optional()
|
|
4681
|
+
.describe("Where the pack originated"),
|
|
4682
|
+
source_url: z
|
|
4683
|
+
.string()
|
|
4684
|
+
.max(2048)
|
|
4685
|
+
.optional()
|
|
4686
|
+
.describe("Origin URL when source_type is 'url'"),
|
|
4687
|
+
language: z
|
|
4688
|
+
.string()
|
|
4689
|
+
.optional()
|
|
4690
|
+
.describe(
|
|
4691
|
+
"Language stored on the pack + used for materialized draft copy",
|
|
4692
|
+
),
|
|
4693
|
+
items: z
|
|
4694
|
+
.array(
|
|
4695
|
+
z.object({
|
|
4696
|
+
kind: z
|
|
4697
|
+
.enum(["hook", "caption"])
|
|
4698
|
+
.describe("Only hook/caption text items persist here"),
|
|
4699
|
+
hook: z.string().optional(),
|
|
4700
|
+
caption: z.string().optional(),
|
|
4701
|
+
hashtags: z.array(z.string()).optional(),
|
|
4702
|
+
}),
|
|
4703
|
+
)
|
|
4704
|
+
.optional()
|
|
4705
|
+
.describe("Optional inline hook/caption copy items"),
|
|
4706
|
+
},
|
|
4707
|
+
annotations: {
|
|
4708
|
+
title: "Create campaign pack",
|
|
4709
|
+
...WRITE,
|
|
4710
|
+
// No idempotency plug → a retry forks a duplicate pack.
|
|
4711
|
+
idempotentHint: false,
|
|
4712
|
+
},
|
|
4713
|
+
},
|
|
4714
|
+
tool(
|
|
4715
|
+
async (
|
|
4716
|
+
args: {
|
|
4717
|
+
title?: string;
|
|
4718
|
+
product_profile_id?: number;
|
|
4719
|
+
brand_profile_id?: number;
|
|
4720
|
+
brief?: string;
|
|
4721
|
+
source_type?: string;
|
|
4722
|
+
source_url?: string;
|
|
4723
|
+
language?: string;
|
|
4724
|
+
items?: Array<Record<string, unknown>>;
|
|
4725
|
+
},
|
|
4726
|
+
client,
|
|
4727
|
+
) => {
|
|
4728
|
+
const body: Record<string, unknown> = {};
|
|
4729
|
+
if (args.title !== undefined) body.title = args.title;
|
|
4730
|
+
if (args.product_profile_id !== undefined)
|
|
4731
|
+
body.product_profile_id = args.product_profile_id;
|
|
4732
|
+
if (args.brand_profile_id !== undefined)
|
|
4733
|
+
body.brand_profile_id = args.brand_profile_id;
|
|
4734
|
+
if (args.brief !== undefined) body.brief = args.brief;
|
|
4735
|
+
if (args.source_type !== undefined) body.source_type = args.source_type;
|
|
4736
|
+
if (args.source_url !== undefined) body.source_url = args.source_url;
|
|
4737
|
+
if (args.language !== undefined) body.language = args.language;
|
|
4738
|
+
if (args.items !== undefined) body.items = args.items;
|
|
4739
|
+
const res = await client.post<{ data: unknown }>("/campaign-packs", body);
|
|
4740
|
+
return ok(summarizePack(asRecord(res).data));
|
|
4741
|
+
},
|
|
4742
|
+
),
|
|
4743
|
+
);
|
|
4744
|
+
|
|
4745
|
+
registerTool(
|
|
4746
|
+
"list_campaign_packs",
|
|
4747
|
+
{
|
|
4748
|
+
title: "List your campaign packs",
|
|
4749
|
+
description:
|
|
4750
|
+
"Lists your campaign packs (newest first) at the PACK level only: id, title, status, source, and linked " +
|
|
4751
|
+
"product/brand profile. It does NOT include per-item detail — the list endpoint doesn't load items, so " +
|
|
4752
|
+
"per-item kind/status/draft slug/deep-link route/credit estimate come from get_campaign_pack({ id }). " +
|
|
4753
|
+
"Read-only, 0 credits. Scope: video:read.",
|
|
4754
|
+
inputSchema: {},
|
|
4755
|
+
annotations: { title: "List campaign packs", ...RO },
|
|
4756
|
+
},
|
|
4757
|
+
tool(async (_args, client) => {
|
|
4758
|
+
const res = await client.get<{ data: unknown }>("/campaign-packs");
|
|
4759
|
+
const data = asRecord(res).data;
|
|
4760
|
+
// The list endpoint does NOT preload items (they'd serialize as []); emit
|
|
4761
|
+
// pack-level fields only and point the agent at get_campaign_pack for items.
|
|
4762
|
+
const packs = Array.isArray(data) ? data.map(summarizePackListEntry) : [];
|
|
4763
|
+
return ok({ packs });
|
|
4764
|
+
}),
|
|
4765
|
+
);
|
|
4766
|
+
|
|
4767
|
+
registerTool(
|
|
4768
|
+
"get_campaign_pack",
|
|
4769
|
+
{
|
|
4770
|
+
title: "Get one campaign pack with its items",
|
|
4771
|
+
description:
|
|
4772
|
+
"Returns a campaign pack and its items. Per item you get: kind, status (planned → draft → generating → " +
|
|
4773
|
+
"completed | failed), the materialized DRAFT slug + deep-link route (editor_ad/short → the video project, " +
|
|
4774
|
+
"carousel → the slider), the per-item credit ESTIMATE, and — once an item has been generated — its " +
|
|
4775
|
+
"preview_url/video_result_id. Read-only, 0 credits. Scope: video:read. Use the draft slugs to generate each " +
|
|
4776
|
+
"output EXPLICITLY (a separate, PAID step): start_autopilot/generate for videos, generate_slider for carousels.",
|
|
4777
|
+
inputSchema: {
|
|
4778
|
+
id: z.number().int().describe("Campaign pack id"),
|
|
4779
|
+
},
|
|
4780
|
+
outputSchema: getCampaignPackOutput,
|
|
4781
|
+
annotations: { title: "Get campaign pack", ...RO },
|
|
4782
|
+
},
|
|
4783
|
+
tool(async (args: { id: number }, client) => {
|
|
4784
|
+
const res = await client.get<{ data: unknown }>(
|
|
4785
|
+
`/campaign-packs/${args.id}`,
|
|
4786
|
+
);
|
|
4787
|
+
return ok(summarizePack(asRecord(res).data));
|
|
4788
|
+
}),
|
|
4789
|
+
);
|
|
4790
|
+
|
|
4791
|
+
registerTool(
|
|
4792
|
+
"add_pack_variations",
|
|
4793
|
+
{
|
|
4794
|
+
title: "Add + materialize hook variations to a pack",
|
|
4795
|
+
description:
|
|
4796
|
+
"Persists a batch of selected hook variations (from generate_hook_variations) as items under a pack and, by " +
|
|
4797
|
+
"default (materialize:true), turns each into an editable DRAFT (a video project or image slider). Spends 0 " +
|
|
4798
|
+
"credits — nothing is generated/rendered (that's a separate PAID step). Scope: video:generate. Caps: at most " +
|
|
4799
|
+
"12 variations per call; rate-limited to 15/min. Per-variation BEST-EFFORT: a variation that can't be " +
|
|
4800
|
+
"materialized (e.g. you hit a draft limit) stays a 'planned' item with a recorded reason while the rest still " +
|
|
4801
|
+
"materialize — the response summary lists created/materialized/planned + skipped_reasons. Pass the variation " +
|
|
4802
|
+
"objects verbatim from the board (each needs at least suggested_format). Next: get_campaign_pack to review, " +
|
|
4803
|
+
"then generate each draft explicitly.",
|
|
4804
|
+
inputSchema: {
|
|
4805
|
+
pack_id: z
|
|
4806
|
+
.number()
|
|
4807
|
+
.int()
|
|
4808
|
+
.describe("Campaign pack id (from create_campaign_pack)"),
|
|
4809
|
+
variations: z
|
|
4810
|
+
.array(
|
|
4811
|
+
z.object({
|
|
4812
|
+
suggested_format: z
|
|
4813
|
+
.enum(["editor_ad", "short", "carousel"])
|
|
4814
|
+
.describe("Output kind for this variation"),
|
|
4815
|
+
hook: z.string().optional(),
|
|
4816
|
+
angle: z.string().optional(),
|
|
4817
|
+
draft_script: z.string().optional(),
|
|
4818
|
+
caption: z.string().optional(),
|
|
4819
|
+
hashtags: z.array(z.string()).optional(),
|
|
4820
|
+
credit_estimate: z.record(z.unknown()).optional(),
|
|
4821
|
+
}),
|
|
4822
|
+
)
|
|
4823
|
+
.describe("1-12 variations to persist (echo them from the board)"),
|
|
4824
|
+
materialize: z
|
|
4825
|
+
.boolean()
|
|
4826
|
+
.optional()
|
|
4827
|
+
.describe(
|
|
4828
|
+
"Materialize each into a draft (default true); false keeps them planned",
|
|
4829
|
+
),
|
|
4830
|
+
},
|
|
4831
|
+
annotations: {
|
|
4832
|
+
title: "Add pack variations",
|
|
4833
|
+
...WRITE,
|
|
4834
|
+
// Each call creates NEW items (no idempotency plug) → not idempotent.
|
|
4835
|
+
idempotentHint: false,
|
|
4836
|
+
},
|
|
4837
|
+
},
|
|
4838
|
+
tool(
|
|
4839
|
+
async (
|
|
4840
|
+
args: {
|
|
4841
|
+
pack_id: number;
|
|
4842
|
+
variations: Array<Record<string, unknown>>;
|
|
4843
|
+
materialize?: boolean;
|
|
4844
|
+
},
|
|
4845
|
+
client,
|
|
4846
|
+
) => {
|
|
4847
|
+
const err = validateVariationsBatch(args.variations);
|
|
4848
|
+
if (err) return fail(err);
|
|
4849
|
+
const body: Record<string, unknown> = { variations: args.variations };
|
|
4850
|
+
if (args.materialize !== undefined) body.materialize = args.materialize;
|
|
4851
|
+
const res = await client.post<{ data: unknown }>(
|
|
4852
|
+
`/campaign-packs/${args.pack_id}/variations`,
|
|
4853
|
+
body,
|
|
4854
|
+
);
|
|
4855
|
+
const data = asRecord(asRecord(res).data);
|
|
4856
|
+
const items = Array.isArray(data.items)
|
|
4857
|
+
? data.items.map(summarizePackItem)
|
|
4858
|
+
: [];
|
|
4859
|
+
return ok({
|
|
4860
|
+
campaign_pack_id: data.campaign_pack_id ?? args.pack_id,
|
|
4861
|
+
items,
|
|
4862
|
+
summary: data.summary ?? null,
|
|
4863
|
+
next: "get_campaign_pack to review the drafts; generation/render is a separate, explicit, PAID step per draft.",
|
|
4864
|
+
});
|
|
4865
|
+
},
|
|
4866
|
+
),
|
|
4867
|
+
);
|
|
4868
|
+
|
|
4869
|
+
registerTool(
|
|
4870
|
+
"materialize_pack_item",
|
|
4871
|
+
{
|
|
4872
|
+
title: "Materialize one planned pack item into a draft",
|
|
4873
|
+
description:
|
|
4874
|
+
"Turns a single PLANNED editor_ad/short/carousel item into an editable DRAFT (a video project or image " +
|
|
4875
|
+
"slider) and links it back to the item. Spends 0 credits — generation/render is a separate PAID step. " +
|
|
4876
|
+
"Scope: video:generate. Rate limit: 60/min. Safe to retry: NO duplicate draft is ever created (a concurrent " +
|
|
4877
|
+
"race loser converges on the same draft under a row lock). A SEQUENTIAL re-call of an already-materialized " +
|
|
4878
|
+
"item returns 422 ('already materialized') — that 422 means the draft already exists; read it via " +
|
|
4879
|
+
"get_campaign_pack. 422 also for hook/caption items or when you hit a draft limit. Prefer add_pack_variations " +
|
|
4880
|
+
"to materialize a batch at once; use this to (re)try one item.",
|
|
4881
|
+
inputSchema: {
|
|
4882
|
+
pack_id: z.number().int().describe("Campaign pack id"),
|
|
4883
|
+
item_id: z
|
|
4884
|
+
.number()
|
|
4885
|
+
.int()
|
|
4886
|
+
.describe("Campaign pack item id (from get_campaign_pack)"),
|
|
4887
|
+
},
|
|
4888
|
+
annotations: {
|
|
4889
|
+
title: "Materialize pack item",
|
|
4890
|
+
...WRITE,
|
|
4891
|
+
// idempotentHint in the "no duplicate is ever created" sense: the server
|
|
4892
|
+
// locks the item row and re-asserts the output ref is still null, so a
|
|
4893
|
+
// concurrent race loser converges on the SAME draft. A sequential replay is
|
|
4894
|
+
// rejected 422 by the pre-lock ensure_materializable (already materialized) —
|
|
4895
|
+
// it never forks a second draft.
|
|
4896
|
+
idempotentHint: true,
|
|
4897
|
+
},
|
|
4898
|
+
},
|
|
4899
|
+
tool(async (args: { pack_id: number; item_id: number }, client) => {
|
|
4900
|
+
const res = await client.post<{ data: unknown }>(
|
|
4901
|
+
`/campaign-packs/${args.pack_id}/items/${args.item_id}/materialize`,
|
|
4902
|
+
);
|
|
4903
|
+
return ok(summarizePackItem(asRecord(res).data));
|
|
4904
|
+
}),
|
|
4905
|
+
);
|
|
4906
|
+
|
|
4907
|
+
// ── Campaign engine: high-level orchestrator ─────────────────────────────────
|
|
4908
|
+
|
|
4909
|
+
registerTool(
|
|
4910
|
+
"create_campaign",
|
|
4911
|
+
{
|
|
4912
|
+
title: "Create a whole campaign from a product (one shot)",
|
|
4913
|
+
description:
|
|
4914
|
+
"The high-level path (like make_video, but for a content CAMPAIGN): give a product URL (or a saved " +
|
|
4915
|
+
"product_profile_id) and get back a persisted campaign pack full of editable DRAFTS. It runs the whole " +
|
|
4916
|
+
"pipeline — extract the page (if a url) → save a product profile → best-effort import the primary image + " +
|
|
4917
|
+
"logo → plan → generate hook variations → create the pack → add the variations as materialized DRAFTS. " +
|
|
4918
|
+
"Spends $0 end to end: it only ever touches the FREE daily AI-assist quota (plan + hook variations) and " +
|
|
4919
|
+
"creates 0-credit drafts. It NEVER starts autopilot/generation/render — that stays a SEPARATE, explicit, " +
|
|
4920
|
+
"PAID step you trigger per draft afterwards. Scope: video:generate. On a partial failure it returns " +
|
|
4921
|
+
"everything achieved so far (profile id, pack id, per-item status) plus precise resume guidance naming the " +
|
|
4922
|
+
"granular tool to continue with. The result lists each draft's slug + kind + per-item credit estimate.",
|
|
4923
|
+
inputSchema: {
|
|
4924
|
+
url: z
|
|
4925
|
+
.string()
|
|
4926
|
+
.max(2048)
|
|
4927
|
+
.optional()
|
|
4928
|
+
.describe(
|
|
4929
|
+
"Product page URL to ingest (required unless product_profile_id is given)",
|
|
4930
|
+
),
|
|
4931
|
+
product_profile_id: z
|
|
4932
|
+
.number()
|
|
4933
|
+
.int()
|
|
4934
|
+
.optional()
|
|
4935
|
+
.describe("Use a saved product profile instead of extracting a URL"),
|
|
4936
|
+
brand_profile_id: z
|
|
4937
|
+
.number()
|
|
4938
|
+
.int()
|
|
4939
|
+
.optional()
|
|
4940
|
+
.describe(
|
|
4941
|
+
"Optional saved brand profile — steers hooks and gives drafts your logo/brand",
|
|
4942
|
+
),
|
|
4943
|
+
item_count: z
|
|
4944
|
+
.number()
|
|
4945
|
+
.int()
|
|
4946
|
+
.optional()
|
|
4947
|
+
.describe("How many variations to aim for, clamped to 3-6 (default 6)"),
|
|
4948
|
+
formats: z
|
|
4949
|
+
.array(z.enum(["editor_ad", "short", "carousel"]))
|
|
4950
|
+
.optional()
|
|
4951
|
+
.describe(
|
|
4952
|
+
"Restrict to these output formats (default: whatever the board recommends)",
|
|
4953
|
+
),
|
|
4954
|
+
language: z
|
|
4955
|
+
.string()
|
|
4956
|
+
.optional()
|
|
4957
|
+
.describe('Viewer-facing language code, e.g. "en" (default)'),
|
|
4958
|
+
goal: z.string().optional().describe("Campaign goal, e.g. conversion"),
|
|
4959
|
+
platform: z
|
|
4960
|
+
.string()
|
|
4961
|
+
.optional()
|
|
4962
|
+
.describe("Target platform, e.g. tiktok, instagram"),
|
|
4963
|
+
},
|
|
4964
|
+
annotations: {
|
|
4965
|
+
title: "Create campaign",
|
|
4966
|
+
...WRITE,
|
|
4967
|
+
// Multi-step create pipeline (profiles, pack, items — none idempotency-keyed);
|
|
4968
|
+
// a blind re-run forks new rows. Resume with the granular tools instead.
|
|
4969
|
+
idempotentHint: false,
|
|
4970
|
+
},
|
|
4971
|
+
outputSchema: createCampaignOutput,
|
|
4972
|
+
},
|
|
4973
|
+
tool(async (args: CreateCampaignArgs, client) => {
|
|
4974
|
+
if (!args.url && args.product_profile_id === undefined) {
|
|
4975
|
+
return fail("Provide either url or product_profile_id.");
|
|
4976
|
+
}
|
|
4977
|
+
const result = await runCreateCampaign(client, args, (msg) =>
|
|
4978
|
+
console.error(`[create_campaign] ${msg}`),
|
|
4979
|
+
);
|
|
4980
|
+
return ok(result);
|
|
4981
|
+
}),
|
|
4982
|
+
);
|
|
4983
|
+
|
|
4984
|
+
// ── Series: recurring-content shows ───────────────────────────────────────────
|
|
4985
|
+
|
|
4986
|
+
registerTool(
|
|
4987
|
+
"create_series",
|
|
4988
|
+
{
|
|
4989
|
+
title: "Create a recurring-content series",
|
|
4990
|
+
description:
|
|
4991
|
+
"Creates a reusable recurring-content SHOW template (e.g. 'Myth vs Fact', 'Founder Tip') bound to an " +
|
|
4992
|
+
"optional brand + product profile — the durable format the episode planner draws upcoming ideas from. " +
|
|
4993
|
+
"name and template are required. Spends 0 credits. Scope: video:generate. A supplied " +
|
|
4994
|
+
"brand_profile_id/product_profile_id must be one of your own (422 invalid_brand_profile/" +
|
|
4995
|
+
"invalid_product_profile otherwise). Next: plan_series_episodes to draft idea episodes.",
|
|
4996
|
+
inputSchema: {
|
|
4997
|
+
name: z
|
|
4998
|
+
.string()
|
|
4999
|
+
.max(300)
|
|
5000
|
+
.describe("Show name, e.g. 'Myth vs Fact' (required)"),
|
|
5001
|
+
template: z
|
|
5002
|
+
.enum([
|
|
5003
|
+
"founder_tip",
|
|
5004
|
+
"myth_vs_fact",
|
|
5005
|
+
"before_after",
|
|
5006
|
+
"customer_question",
|
|
5007
|
+
"build_in_public",
|
|
5008
|
+
"product_demo_of_the_week",
|
|
5009
|
+
"mistake_to_avoid",
|
|
5010
|
+
])
|
|
5011
|
+
.describe(
|
|
5012
|
+
"The recurring format/template (required). One of: founder_tip, myth_vs_fact, " +
|
|
5013
|
+
"before_after, customer_question, build_in_public, product_demo_of_the_week, mistake_to_avoid",
|
|
5014
|
+
),
|
|
5015
|
+
brand_profile_id: z
|
|
5016
|
+
.number()
|
|
5017
|
+
.int()
|
|
5018
|
+
.optional()
|
|
5019
|
+
.describe(
|
|
5020
|
+
"Optional saved brand profile the series (and its drafts) inherit",
|
|
5021
|
+
),
|
|
5022
|
+
product_profile_id: z
|
|
5023
|
+
.number()
|
|
5024
|
+
.int()
|
|
5025
|
+
.optional()
|
|
5026
|
+
.describe("Optional saved product profile grounding episode ideas"),
|
|
5027
|
+
cadence: z
|
|
5028
|
+
.enum(["weekly", "biweekly", "monthly"])
|
|
5029
|
+
.optional()
|
|
5030
|
+
.describe(
|
|
5031
|
+
"Posting cadence: weekly, biweekly, or monthly (steers the ideas)",
|
|
5032
|
+
),
|
|
5033
|
+
tone: z
|
|
5034
|
+
.string()
|
|
5035
|
+
.optional()
|
|
5036
|
+
.describe("Editorial tone, e.g. playful, expert"),
|
|
5037
|
+
default_format: z
|
|
5038
|
+
.enum(["editor_ad", "short", "carousel"])
|
|
5039
|
+
.optional()
|
|
5040
|
+
.describe("Default output format for materialized episodes"),
|
|
5041
|
+
status: z
|
|
5042
|
+
.enum(["active", "paused", "archived"])
|
|
5043
|
+
.optional()
|
|
5044
|
+
.describe("Series status (default active)"),
|
|
5045
|
+
},
|
|
5046
|
+
annotations: {
|
|
5047
|
+
title: "Create series",
|
|
5048
|
+
...WRITE,
|
|
5049
|
+
// No idempotency plug on the series controller → a retry forks a duplicate.
|
|
5050
|
+
idempotentHint: false,
|
|
5051
|
+
},
|
|
5052
|
+
},
|
|
5053
|
+
tool(
|
|
5054
|
+
async (
|
|
5055
|
+
args: {
|
|
5056
|
+
name: string;
|
|
5057
|
+
template: string;
|
|
5058
|
+
brand_profile_id?: number;
|
|
5059
|
+
product_profile_id?: number;
|
|
5060
|
+
cadence?: string;
|
|
5061
|
+
tone?: string;
|
|
5062
|
+
default_format?: string;
|
|
5063
|
+
status?: string;
|
|
5064
|
+
},
|
|
5065
|
+
client,
|
|
5066
|
+
) => {
|
|
5067
|
+
const res = await client.post<{ data: unknown }>(
|
|
5068
|
+
"/series",
|
|
5069
|
+
pickSeriesFields(args),
|
|
5070
|
+
);
|
|
5071
|
+
return ok(summarizeSeries(asRecord(res).data));
|
|
5072
|
+
},
|
|
5073
|
+
),
|
|
5074
|
+
);
|
|
5075
|
+
|
|
5076
|
+
registerTool(
|
|
5077
|
+
"list_series",
|
|
5078
|
+
{
|
|
5079
|
+
title: "List your series",
|
|
5080
|
+
description:
|
|
5081
|
+
"Lists your recurring-content series (newest first), each summarized with its status, template, cadence, " +
|
|
5082
|
+
"tone, default format, and linked brand/product profile + rolling campaign pack ids. Read-only, 0 credits. " +
|
|
5083
|
+
"Scope: video:read. Use get_series_dashboard for one series' episodes grouped by lane.",
|
|
5084
|
+
inputSchema: {},
|
|
5085
|
+
annotations: { title: "List series", ...RO },
|
|
5086
|
+
},
|
|
5087
|
+
tool(async (_args, client) => {
|
|
5088
|
+
const res = await client.get<{ data: unknown }>("/series");
|
|
5089
|
+
const data = asRecord(res).data;
|
|
5090
|
+
const series = Array.isArray(data) ? data.map(summarizeSeries) : [];
|
|
5091
|
+
return ok({ series });
|
|
5092
|
+
}),
|
|
5093
|
+
);
|
|
5094
|
+
|
|
5095
|
+
registerTool(
|
|
5096
|
+
"update_series",
|
|
5097
|
+
{
|
|
5098
|
+
title: "Update a series",
|
|
5099
|
+
description:
|
|
5100
|
+
"Patches one of your series; only the fields you pass change. Toggle status among active/paused/archived " +
|
|
5101
|
+
"(a paused series stops surfacing for new ideas). A changed brand_profile_id/product_profile_id is " +
|
|
5102
|
+
"re-verified against your own profiles (422 otherwise). Spends 0 credits. Scope: video:generate. " +
|
|
5103
|
+
"404 for an unknown/foreign series.",
|
|
5104
|
+
inputSchema: {
|
|
5105
|
+
id: z.number().int().describe("Series id"),
|
|
5106
|
+
name: z.string().max(300).optional().describe("Show name"),
|
|
5107
|
+
template: z
|
|
5108
|
+
.enum([
|
|
5109
|
+
"founder_tip",
|
|
5110
|
+
"myth_vs_fact",
|
|
5111
|
+
"before_after",
|
|
5112
|
+
"customer_question",
|
|
5113
|
+
"build_in_public",
|
|
5114
|
+
"product_demo_of_the_week",
|
|
5115
|
+
"mistake_to_avoid",
|
|
5116
|
+
])
|
|
5117
|
+
.optional()
|
|
5118
|
+
.describe(
|
|
5119
|
+
"Recurring format/template. One of: founder_tip, myth_vs_fact, before_after, " +
|
|
5120
|
+
"customer_question, build_in_public, product_demo_of_the_week, mistake_to_avoid",
|
|
5121
|
+
),
|
|
5122
|
+
brand_profile_id: z
|
|
5123
|
+
.number()
|
|
5124
|
+
.int()
|
|
5125
|
+
.optional()
|
|
5126
|
+
.describe("Saved brand profile to bind (422 if not yours)"),
|
|
5127
|
+
product_profile_id: z
|
|
5128
|
+
.number()
|
|
5129
|
+
.int()
|
|
5130
|
+
.optional()
|
|
5131
|
+
.describe("Saved product profile to bind (422 if not yours)"),
|
|
5132
|
+
cadence: z
|
|
5133
|
+
.enum(["weekly", "biweekly", "monthly"])
|
|
5134
|
+
.optional()
|
|
5135
|
+
.describe("Posting cadence: weekly, biweekly, or monthly"),
|
|
5136
|
+
tone: z.string().optional().describe("Editorial tone"),
|
|
5137
|
+
default_format: z
|
|
5138
|
+
.enum(["editor_ad", "short", "carousel"])
|
|
5139
|
+
.optional()
|
|
5140
|
+
.describe("Default output format for materialized episodes"),
|
|
5141
|
+
status: z
|
|
5142
|
+
.enum(["active", "paused", "archived"])
|
|
5143
|
+
.optional()
|
|
5144
|
+
.describe("Series status"),
|
|
5145
|
+
},
|
|
5146
|
+
annotations: { title: "Update series", ...WRITE, idempotentHint: false },
|
|
5147
|
+
},
|
|
5148
|
+
tool(
|
|
5149
|
+
async (
|
|
5150
|
+
args: {
|
|
5151
|
+
id: number;
|
|
5152
|
+
name?: string;
|
|
5153
|
+
template?: string;
|
|
5154
|
+
brand_profile_id?: number;
|
|
5155
|
+
product_profile_id?: number;
|
|
5156
|
+
cadence?: string;
|
|
5157
|
+
tone?: string;
|
|
5158
|
+
default_format?: string;
|
|
5159
|
+
status?: string;
|
|
5160
|
+
},
|
|
5161
|
+
client,
|
|
5162
|
+
) => {
|
|
5163
|
+
const res = await client.patch<{ data: unknown }>(
|
|
5164
|
+
`/series/${args.id}`,
|
|
5165
|
+
pickSeriesFields(args),
|
|
5166
|
+
);
|
|
5167
|
+
return ok(summarizeSeries(asRecord(res).data));
|
|
5168
|
+
},
|
|
5169
|
+
),
|
|
5170
|
+
);
|
|
5171
|
+
|
|
5172
|
+
registerTool(
|
|
5173
|
+
"plan_series_episodes",
|
|
5174
|
+
{
|
|
5175
|
+
title: "Plan upcoming episode ideas for a series",
|
|
5176
|
+
description:
|
|
5177
|
+
"Drafts 3-5 distinct upcoming EPISODE ideas (status 'idea') for one of your series, grounded on its " +
|
|
5178
|
+
"product profile (or name) and steered by its brand/tone/cadence, and persists them as episodes. " +
|
|
5179
|
+
"Generates no media and spends $0 — it consumes the FREE daily AI-assist quota (NOT credits). " +
|
|
5180
|
+
"Scope: video:generate. Rate limit: 30/min. On 429 the quota is exhausted: set auto_unlock:true to spend " +
|
|
5181
|
+
"1 credit for +10 assists (retried once) or call unlock_ai_assists. 404 for an unknown/foreign series. " +
|
|
5182
|
+
"Next: materialize_episode to turn a chosen idea into a 0-credit draft.",
|
|
5183
|
+
inputSchema: {
|
|
5184
|
+
series_id: z.number().int().describe("Series id"),
|
|
5185
|
+
count: z
|
|
5186
|
+
.number()
|
|
5187
|
+
.int()
|
|
5188
|
+
.optional()
|
|
5189
|
+
.describe("Desired number of ideas, clamped to 3-5 (default 4)"),
|
|
5190
|
+
language: z
|
|
5191
|
+
.string()
|
|
5192
|
+
.optional()
|
|
5193
|
+
.describe('Viewer-facing language code, e.g. "en" (default)'),
|
|
5194
|
+
auto_unlock: z
|
|
5195
|
+
.boolean()
|
|
5196
|
+
.optional()
|
|
5197
|
+
.describe(
|
|
5198
|
+
"On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
|
|
5199
|
+
),
|
|
5200
|
+
},
|
|
5201
|
+
annotations: {
|
|
5202
|
+
title: "Plan series episodes",
|
|
5203
|
+
...WRITE,
|
|
5204
|
+
// Consumes the free quota + persists idea episodes (a side effect) → not idempotent.
|
|
5205
|
+
idempotentHint: false,
|
|
5206
|
+
},
|
|
5207
|
+
},
|
|
5208
|
+
tool(
|
|
5209
|
+
async (
|
|
5210
|
+
args: {
|
|
5211
|
+
series_id: number;
|
|
5212
|
+
count?: number;
|
|
5213
|
+
language?: string;
|
|
5214
|
+
auto_unlock?: boolean;
|
|
5215
|
+
},
|
|
5216
|
+
client,
|
|
5217
|
+
) => {
|
|
5218
|
+
const body: Record<string, unknown> = {};
|
|
5219
|
+
if (args.count !== undefined) body.count = args.count;
|
|
5220
|
+
if (args.language !== undefined && args.language.trim() !== "")
|
|
5221
|
+
body.language = args.language;
|
|
5222
|
+
const res = await withAssist(client, args.auto_unlock ?? false, () =>
|
|
5223
|
+
client.post<{ data: unknown }>(
|
|
5224
|
+
`/series/${args.series_id}/episodes/plan`,
|
|
5225
|
+
body,
|
|
5226
|
+
),
|
|
5227
|
+
);
|
|
5228
|
+
const data = asRecord(asRecord(res).data);
|
|
5229
|
+
const episodes = Array.isArray(data.episodes)
|
|
5230
|
+
? data.episodes.map(summarizeEpisode)
|
|
5231
|
+
: [];
|
|
5232
|
+
return ok({
|
|
5233
|
+
series_id: args.series_id,
|
|
5234
|
+
episodes,
|
|
5235
|
+
ai_assist_quota: data.ai_assist_quota ?? null,
|
|
5236
|
+
next: "materialize_episode({ series_id, episode_id }) to turn an idea into a 0-credit draft.",
|
|
5237
|
+
});
|
|
5238
|
+
},
|
|
5239
|
+
),
|
|
5240
|
+
);
|
|
5241
|
+
|
|
5242
|
+
registerTool(
|
|
5243
|
+
"list_series_episodes",
|
|
5244
|
+
{
|
|
5245
|
+
title: "List a series' episodes",
|
|
5246
|
+
description:
|
|
5247
|
+
"Lists a series' episodes (position ascending), each summarized with its status (idea → drafted → " +
|
|
5248
|
+
"published), hook/brief/caption, suggested format, and — once materialized — the draft slug + deep-link " +
|
|
5249
|
+
"route (editor_ad/short → the video project, carousel → the slider). Optionally filter by status. " +
|
|
5250
|
+
"Read-only, 0 credits. Scope: video:read. 404 for an unknown/foreign series.",
|
|
5251
|
+
inputSchema: {
|
|
5252
|
+
series_id: z.number().int().describe("Series id"),
|
|
5253
|
+
status: z
|
|
5254
|
+
.enum(["idea", "drafted", "published", "archived"])
|
|
5255
|
+
.optional()
|
|
5256
|
+
.describe("Filter to one status"),
|
|
5257
|
+
},
|
|
5258
|
+
annotations: { title: "List series episodes", ...RO },
|
|
5259
|
+
},
|
|
5260
|
+
tool(async (args: { series_id: number; status?: string }, client) => {
|
|
5261
|
+
const res = await client.get<{ data: unknown }>(
|
|
5262
|
+
`/series/${args.series_id}/episodes`,
|
|
5263
|
+
args.status ? { status: args.status } : undefined,
|
|
5264
|
+
);
|
|
5265
|
+
const data = asRecord(asRecord(res).data);
|
|
5266
|
+
const episodes = Array.isArray(data.episodes)
|
|
5267
|
+
? data.episodes.map(summarizeEpisode)
|
|
5268
|
+
: [];
|
|
5269
|
+
return ok({ series_id: args.series_id, episodes });
|
|
5270
|
+
}),
|
|
5271
|
+
);
|
|
5272
|
+
|
|
5273
|
+
registerTool(
|
|
5274
|
+
"materialize_episode",
|
|
5275
|
+
{
|
|
5276
|
+
title: "Materialize an episode idea into a draft",
|
|
5277
|
+
description:
|
|
5278
|
+
"Turns an 'idea' episode into an editable 0-credit DRAFT (a video project or image slider) grouped under " +
|
|
5279
|
+
"the series' rolling campaign pack and inheriting the series' brand. Spends 0 credits — generation/render " +
|
|
5280
|
+
"is a SEPARATE, explicit, PAID step. Scope: video:generate. Rate limit: 60/min. Safe to retry: NO duplicate " +
|
|
5281
|
+
"draft is ever created (a concurrent race loser converges on the same draft under a row lock). A SEQUENTIAL " +
|
|
5282
|
+
"re-call of an already-materialized episode returns 422 ('already materialized') — that 422 means the draft " +
|
|
5283
|
+
"already exists; find it via list_series_episodes / get_series_dashboard. 422 also for a non-idea episode or " +
|
|
5284
|
+
"when you hit a draft limit. Returns the drafted episode with its draft slug + kind; generate it explicitly " +
|
|
5285
|
+
"next (start_autopilot/generate for videos, generate_slider for carousels).",
|
|
5286
|
+
inputSchema: {
|
|
5287
|
+
series_id: z.number().int().describe("Series id"),
|
|
5288
|
+
episode_id: z
|
|
5289
|
+
.number()
|
|
5290
|
+
.int()
|
|
5291
|
+
.describe(
|
|
5292
|
+
"Episode id (from plan_series_episodes / list_series_episodes)",
|
|
5293
|
+
),
|
|
5294
|
+
},
|
|
5295
|
+
annotations: {
|
|
5296
|
+
title: "Materialize episode",
|
|
5297
|
+
...WRITE,
|
|
5298
|
+
// idempotentHint in the "no duplicate is ever created" sense: the server
|
|
5299
|
+
// takes FOR UPDATE locks and re-checks materializability, so a concurrent
|
|
5300
|
+
// race loser converges on the SAME draft. A sequential replay is rejected
|
|
5301
|
+
// 422 by the pre-lock ensure_materializable (already materialized) — it
|
|
5302
|
+
// never forks a second draft.
|
|
5303
|
+
idempotentHint: true,
|
|
5304
|
+
},
|
|
5305
|
+
},
|
|
5306
|
+
tool(async (args: { series_id: number; episode_id: number }, client) => {
|
|
5307
|
+
const res = await client.post<{ data: unknown }>(
|
|
5308
|
+
`/series/${args.series_id}/episodes/${args.episode_id}/materialize`,
|
|
5309
|
+
);
|
|
5310
|
+
const episode = summarizeEpisode(asRecord(res).data);
|
|
5311
|
+
return ok({
|
|
5312
|
+
...episode,
|
|
5313
|
+
next: episode.draft_slug
|
|
5314
|
+
? "Generate this draft EXPLICITLY (a separate, PAID step): editor_ad/short → start_autopilot({ slug }); carousel → generate_slider({ slug })."
|
|
5315
|
+
: "Draft created — review it with list_series_episodes.",
|
|
5316
|
+
});
|
|
5317
|
+
}),
|
|
5318
|
+
);
|
|
5319
|
+
|
|
5320
|
+
registerTool(
|
|
5321
|
+
"get_series_dashboard",
|
|
5322
|
+
{
|
|
5323
|
+
title: "Get a series dashboard",
|
|
5324
|
+
description:
|
|
5325
|
+
"Returns a read-only overview of one series: the series plus its episodes grouped into lanes — upcoming " +
|
|
5326
|
+
"(ideas), drafted, and published — with per-item performance notes for episodes linked to a campaign-pack " +
|
|
5327
|
+
"item. Each drafted/published episode carries its draft slug + deep-link route. Read-only, 0 credits. " +
|
|
5328
|
+
"Scope: video:read. 404 for an unknown/foreign series.",
|
|
5329
|
+
inputSchema: {
|
|
5330
|
+
series_id: z.number().int().describe("Series id"),
|
|
5331
|
+
},
|
|
5332
|
+
outputSchema: getSeriesDashboardOutput,
|
|
5333
|
+
annotations: { title: "Get series dashboard", ...RO },
|
|
5334
|
+
},
|
|
5335
|
+
tool(async (args: { series_id: number }, client) => {
|
|
5336
|
+
const res = await client.get<{ data: unknown }>(
|
|
5337
|
+
`/series/${args.series_id}/dashboard`,
|
|
5338
|
+
);
|
|
5339
|
+
return ok(summarizeDashboard(asRecord(res).data));
|
|
5340
|
+
}),
|
|
5341
|
+
);
|
|
5342
|
+
|
|
5343
|
+
registerTool(
|
|
5344
|
+
"mark_episode_posted",
|
|
5345
|
+
{
|
|
5346
|
+
title: "Mark a drafted episode as posted",
|
|
5347
|
+
description:
|
|
5348
|
+
"Records that you posted a DRAFTED episode MANUALLY (there is no platform posting API): flips the episode " +
|
|
5349
|
+
"'drafted' → 'published' (labelled 'Posted' in the app) and stamps posted_at, making the dashboard's " +
|
|
5350
|
+
"published lane reachable. Spends 0 credits. Scope: video:generate. Atomic — only a drafted episode " +
|
|
5351
|
+
"transitions; an idea or archived episode returns 422 invalid_status (materialize it into a draft first). " +
|
|
5352
|
+
"IDEMPOTENT on replay: re-marking an already-published episode returns 200 with the original posted_at " +
|
|
5353
|
+
"untouched. 404 for an unknown/foreign series or episode.",
|
|
5354
|
+
inputSchema: {
|
|
5355
|
+
series_id: z.number().int().describe("Series id"),
|
|
5356
|
+
episode_id: z
|
|
5357
|
+
.number()
|
|
5358
|
+
.int()
|
|
5359
|
+
.describe("Episode id (must be a drafted episode)"),
|
|
5360
|
+
},
|
|
5361
|
+
annotations: {
|
|
5362
|
+
title: "Mark episode posted",
|
|
5363
|
+
...WRITE,
|
|
5364
|
+
// The server transition is a CAS whose replay it defines as a no-op 200
|
|
5365
|
+
// (already-published keeps the original posted_at), so re-marking is safe.
|
|
5366
|
+
idempotentHint: true,
|
|
5367
|
+
},
|
|
5368
|
+
},
|
|
5369
|
+
tool(async (args: { series_id: number; episode_id: number }, client) => {
|
|
5370
|
+
const res = await client.post<{ data: unknown }>(
|
|
5371
|
+
`/series/${args.series_id}/episodes/${args.episode_id}/mark-posted`,
|
|
5372
|
+
);
|
|
5373
|
+
return ok(summarizeEpisode(asRecord(res).data));
|
|
5374
|
+
}),
|
|
5375
|
+
);
|
|
5376
|
+
|
|
5377
|
+
// ── Content calendar: reminder-only scheduling ────────────────────────────────
|
|
5378
|
+
//
|
|
5379
|
+
// REMINDER-FIRST BY DESIGN: these tools schedule a completed video as a manual-
|
|
5380
|
+
// posting REMINDER (a push to the USER at the due time, then mark_scheduled_posted
|
|
5381
|
+
// to record the manual post). They NEVER accept or send a social-account id, so
|
|
5382
|
+
// every post is reminder-only and the pipeline WriteScope floor authorizes the
|
|
5383
|
+
// write with video:generate — publishing stays human. Attaching an account
|
|
5384
|
+
// (auto-publish) is app-only (account:admin), so a 403 insufficient_scope from
|
|
5385
|
+
// the write tools means the TARGETED post already carries an account, or the token
|
|
5386
|
+
// predates the reminder-calendar scope split — calendarWrite() spells that out.
|
|
5387
|
+
|
|
5388
|
+
/**
|
|
5389
|
+
* Wraps a calendar WRITE (schedule/reschedule/cancel) so an
|
|
5390
|
+
* `insufficient_scope` 403 is re-surfaced with the reminder-only explanation
|
|
5391
|
+
* (the generic errMessage would print the bare server sentence). Any other
|
|
5392
|
+
* failure flows through tool()'s normal errMessage path unchanged.
|
|
5393
|
+
*/
|
|
5394
|
+
function calendarWrite<A>(
|
|
5395
|
+
fn: (
|
|
5396
|
+
args: A,
|
|
5397
|
+
client: HubfluencerClient,
|
|
5398
|
+
extra: ToolExtra,
|
|
5399
|
+
) => Promise<ReturnType<typeof ok>>,
|
|
5400
|
+
) {
|
|
5401
|
+
return tool<A>(async (args, client, extra) => {
|
|
5402
|
+
try {
|
|
5403
|
+
return await fn(args, client, extra);
|
|
5404
|
+
} catch (e) {
|
|
5405
|
+
const he = e as HubfluencerError;
|
|
5406
|
+
if (he && he.status === 403) {
|
|
5407
|
+
return fail(calendarScopeErrorText(he.status, he.code, errMessage(e)));
|
|
5408
|
+
}
|
|
5409
|
+
throw e;
|
|
5410
|
+
}
|
|
5411
|
+
});
|
|
5412
|
+
}
|
|
5413
|
+
|
|
5414
|
+
registerTool(
|
|
5415
|
+
"schedule_post",
|
|
5416
|
+
{
|
|
5417
|
+
title: "Schedule a posting reminder for a finished video",
|
|
5418
|
+
description:
|
|
5419
|
+
"Schedules one of your own COMPLETED video results for a FUTURE UTC instant as a manual-posting REMINDER: " +
|
|
5420
|
+
"a push notification fires to YOU at the scheduled time and you confirm the manual post with " +
|
|
5421
|
+
"mark_scheduled_posted. This tool is reminder-only — it never attaches a social account, so publishing " +
|
|
5422
|
+
"stays in your hands (auto-publish is app-only). Spends 0 credits. Scope: video:generate. Get the " +
|
|
5423
|
+
"video_result_id from a completed project's result (get_status/download_result) or list_scheduled_posts. " +
|
|
5424
|
+
`platform is your posting INTENT (${SCHEDULED_POST_PLATFORMS.join("/")}); "other" for anywhere else. ` +
|
|
5425
|
+
`Caption ≤${5000} chars; ≤${30} hashtags (each ≤${100}). At most ${MAX_PENDING_SCHEDULED_POSTS} pending ` +
|
|
5426
|
+
"reminders per account (422 scheduled_post_limit_reached beyond that — cancel or mark some posted first). " +
|
|
5427
|
+
"422 for a non-completed/foreign video result or a non-future instant.",
|
|
5428
|
+
inputSchema: {
|
|
5429
|
+
video_result_id: z
|
|
5430
|
+
.number()
|
|
5431
|
+
.int()
|
|
5432
|
+
.describe(
|
|
5433
|
+
"Id of one of your own COMPLETED video results (the render to post)",
|
|
5434
|
+
),
|
|
5435
|
+
platform: z
|
|
5436
|
+
.enum(SCHEDULED_POST_PLATFORMS)
|
|
5437
|
+
.describe('Where you intend to post: tiktok, instagram, or "other"'),
|
|
5438
|
+
scheduled_at: z
|
|
5439
|
+
.string()
|
|
5440
|
+
.describe(
|
|
5441
|
+
"The UTC instant to be reminded, ISO-8601 (must be in the future)",
|
|
5442
|
+
),
|
|
5443
|
+
caption: z
|
|
5444
|
+
.string()
|
|
5445
|
+
.optional()
|
|
5446
|
+
.describe(
|
|
5447
|
+
"Optional post caption to stage with the reminder (≤5000 chars)",
|
|
5448
|
+
),
|
|
5449
|
+
hashtags: z
|
|
5450
|
+
.array(z.string())
|
|
5451
|
+
.optional()
|
|
5452
|
+
.describe("Optional hashtags to stage (≤30, each ≤100 chars)"),
|
|
5453
|
+
timezone: z
|
|
5454
|
+
.string()
|
|
5455
|
+
.optional()
|
|
5456
|
+
.describe("Your chosen timezone (display only), e.g. Europe/Paris"),
|
|
5457
|
+
campaign_pack_item_id: z
|
|
5458
|
+
.number()
|
|
5459
|
+
.int()
|
|
5460
|
+
.optional()
|
|
5461
|
+
.describe(
|
|
5462
|
+
"Optional id of one of your own campaign pack items to group under",
|
|
5463
|
+
),
|
|
5464
|
+
},
|
|
5465
|
+
annotations: {
|
|
5466
|
+
title: "Schedule post reminder",
|
|
5467
|
+
...WRITE,
|
|
5468
|
+
// No idempotency plug on the scheduled-post controller → a retry forks a
|
|
5469
|
+
// duplicate reminder.
|
|
5470
|
+
idempotentHint: false,
|
|
5471
|
+
},
|
|
5472
|
+
},
|
|
5473
|
+
calendarWrite(
|
|
5474
|
+
async (
|
|
5475
|
+
args: {
|
|
5476
|
+
video_result_id: number;
|
|
5477
|
+
platform: string;
|
|
5478
|
+
scheduled_at: string;
|
|
5479
|
+
caption?: string;
|
|
5480
|
+
hashtags?: string[];
|
|
5481
|
+
timezone?: string;
|
|
5482
|
+
campaign_pack_item_id?: number;
|
|
5483
|
+
},
|
|
5484
|
+
client,
|
|
5485
|
+
) => {
|
|
5486
|
+
const copyErr = validateScheduledPostCopy(args);
|
|
5487
|
+
if (copyErr) return fail(copyErr);
|
|
5488
|
+
const res = await client.post<{ data: unknown }>(
|
|
5489
|
+
"/scheduled-posts",
|
|
5490
|
+
buildScheduledPostBody(args),
|
|
5491
|
+
);
|
|
5492
|
+
return ok(summarizeScheduledPost(asRecord(res).data));
|
|
5493
|
+
},
|
|
5494
|
+
),
|
|
5495
|
+
);
|
|
5496
|
+
|
|
5497
|
+
registerTool(
|
|
5498
|
+
"list_scheduled_posts",
|
|
5499
|
+
{
|
|
5500
|
+
title: "List your scheduled posts (calendar)",
|
|
5501
|
+
description:
|
|
5502
|
+
"Lists your scheduled posts (calendar) ordered by scheduled_at ascending, each summarized with its " +
|
|
5503
|
+
"platform, status, the staged caption/hashtags, the presigned export video_url (post it manually), and the " +
|
|
5504
|
+
"owning video's kind. Optionally narrow the window with from/to (ISO-8601 UTC) and/or filter by status " +
|
|
5505
|
+
`(${SCHEDULED_POST_STATUSES.join("/")}). Read-only, 0 credits. Scope: video:read. Account bindings are ` +
|
|
5506
|
+
"intentionally omitted (this surface is reminder-only).",
|
|
5507
|
+
inputSchema: {
|
|
5508
|
+
from: z
|
|
5509
|
+
.string()
|
|
5510
|
+
.optional()
|
|
5511
|
+
.describe("Lower bound (inclusive) on scheduled_at, ISO-8601 UTC"),
|
|
5512
|
+
to: z
|
|
5513
|
+
.string()
|
|
5514
|
+
.optional()
|
|
5515
|
+
.describe("Upper bound (inclusive) on scheduled_at, ISO-8601 UTC"),
|
|
5516
|
+
status: z
|
|
5517
|
+
.enum(SCHEDULED_POST_STATUSES)
|
|
5518
|
+
.optional()
|
|
5519
|
+
.describe("Filter to one status"),
|
|
5520
|
+
},
|
|
5521
|
+
outputSchema: listScheduledPostsOutput,
|
|
5522
|
+
annotations: { title: "List scheduled posts", ...RO },
|
|
5523
|
+
},
|
|
5524
|
+
tool(
|
|
5525
|
+
async (args: { from?: string; to?: string; status?: string }, client) => {
|
|
5526
|
+
const query: Record<string, string> = {};
|
|
5527
|
+
if (args.from !== undefined) query.from = args.from;
|
|
5528
|
+
if (args.to !== undefined) query.to = args.to;
|
|
5529
|
+
if (args.status !== undefined) query.status = args.status;
|
|
5530
|
+
const res = await client.get<{ data: unknown }>(
|
|
5531
|
+
"/scheduled-posts",
|
|
5532
|
+
Object.keys(query).length > 0 ? query : undefined,
|
|
5533
|
+
);
|
|
5534
|
+
const data = asRecord(res).data;
|
|
5535
|
+
const posts = Array.isArray(data) ? data.map(summarizeScheduledPost) : [];
|
|
5536
|
+
return ok({ posts });
|
|
5537
|
+
},
|
|
5538
|
+
),
|
|
5539
|
+
);
|
|
5540
|
+
|
|
5541
|
+
registerTool(
|
|
5542
|
+
"reschedule_post",
|
|
5543
|
+
{
|
|
5544
|
+
title: "Reschedule a scheduled post",
|
|
5545
|
+
description:
|
|
5546
|
+
"Moves a still-'scheduled' or 'reminded' post to a new FUTURE instant (a reminded post is re-armed back to " +
|
|
5547
|
+
"'scheduled' — 'remind me later'). Spends 0 credits. Scope: video:generate. 422 not_reschedulable for a " +
|
|
5548
|
+
"post already posted/publishing/published/failed/canceled, or scheduled_in_past for a non-future/ malformed " +
|
|
5549
|
+
"instant. 404 for unknown/foreign ids. A 403 insufficient_scope means the targeted post carries a social " +
|
|
5550
|
+
"account (app-only) — reminder-only posts are reschedulable with video:generate.",
|
|
5551
|
+
inputSchema: {
|
|
5552
|
+
id: z.number().int().describe("Scheduled post id"),
|
|
5553
|
+
scheduled_at: z
|
|
5554
|
+
.string()
|
|
5555
|
+
.describe("The new UTC instant, ISO-8601 (must be in the future)"),
|
|
5556
|
+
},
|
|
5557
|
+
annotations: { title: "Reschedule post", ...WRITE, idempotentHint: false },
|
|
5558
|
+
},
|
|
5559
|
+
calendarWrite(async (args: { id: number; scheduled_at: string }, client) => {
|
|
5560
|
+
const res = await client.patch<{ data: unknown }>(
|
|
5561
|
+
`/scheduled-posts/${args.id}`,
|
|
5562
|
+
{ scheduled_at: args.scheduled_at },
|
|
5563
|
+
);
|
|
5564
|
+
return ok(summarizeScheduledPost(asRecord(res).data));
|
|
5565
|
+
}),
|
|
5566
|
+
);
|
|
5567
|
+
|
|
5568
|
+
registerTool(
|
|
5569
|
+
"cancel_scheduled_post",
|
|
5570
|
+
{
|
|
5571
|
+
title: "Cancel a scheduled post",
|
|
5572
|
+
description:
|
|
5573
|
+
"Cancels a 'scheduled', 'reminded', or 'failed' post (status → 'canceled'); it will no longer fire. Spends " +
|
|
5574
|
+
"0 credits. Scope: video:generate. 422 not_cancelable for a post already posted/publishing/published/" +
|
|
5575
|
+
"canceled. 404 for unknown/foreign ids. A 403 insufficient_scope means the targeted post carries a social " +
|
|
5576
|
+
"account (app-only) — reminder-only posts are cancelable with video:generate.",
|
|
5577
|
+
inputSchema: {
|
|
5578
|
+
id: z.number().int().describe("Scheduled post id"),
|
|
5579
|
+
},
|
|
5580
|
+
annotations: {
|
|
5581
|
+
title: "Cancel scheduled post",
|
|
5582
|
+
readOnlyHint: false,
|
|
5583
|
+
// Cancellation is a state-destroying transition on the reminder.
|
|
5584
|
+
destructiveHint: true,
|
|
5585
|
+
openWorldHint: true,
|
|
5586
|
+
idempotentHint: false,
|
|
5587
|
+
},
|
|
5588
|
+
},
|
|
5589
|
+
calendarWrite(async (args: { id: number }, client) => {
|
|
5590
|
+
const res = await client.del<{ data: unknown }>(
|
|
5591
|
+
`/scheduled-posts/${args.id}`,
|
|
5592
|
+
);
|
|
5593
|
+
return ok(summarizeScheduledPost(asRecord(res).data));
|
|
5594
|
+
}),
|
|
5595
|
+
);
|
|
5596
|
+
|
|
5597
|
+
registerTool(
|
|
5598
|
+
"mark_scheduled_posted",
|
|
5599
|
+
{
|
|
5600
|
+
title: "Mark a scheduled post as posted (manual posting)",
|
|
5601
|
+
description:
|
|
5602
|
+
"Records that you posted this content MANUALLY: transitions a 'scheduled' or 'reminded' post to 'posted' " +
|
|
5603
|
+
"and stamps posted_at. Spends 0 credits. Scope: video:generate (closing the manual-posting loop is allowed " +
|
|
5604
|
+
"for agent tokens). Atomic — a post in any other status (including an already-posted one, so a SECOND call) " +
|
|
5605
|
+
"returns 422 not_markable; this is NOT a replayable no-op. 404 for unknown/foreign ids. Touches no platform API.",
|
|
5606
|
+
inputSchema: {
|
|
5607
|
+
id: z
|
|
5608
|
+
.number()
|
|
5609
|
+
.int()
|
|
5610
|
+
.describe("Scheduled post id (a scheduled or reminded post)"),
|
|
5611
|
+
},
|
|
5612
|
+
annotations: {
|
|
5613
|
+
title: "Mark scheduled post posted",
|
|
5614
|
+
...WRITE,
|
|
5615
|
+
// The CAS returns 422 not_markable on a second call (not a no-op), so a
|
|
5616
|
+
// blind retry after success is NOT idempotent.
|
|
5617
|
+
idempotentHint: false,
|
|
5618
|
+
},
|
|
5619
|
+
},
|
|
5620
|
+
tool(async (args: { id: number }, client) => {
|
|
5621
|
+
const res = await client.post<{ data: unknown }>(
|
|
5622
|
+
`/scheduled-posts/${args.id}/mark-posted`,
|
|
5623
|
+
);
|
|
5624
|
+
return ok(summarizeScheduledPost(asRecord(res).data));
|
|
5625
|
+
}),
|
|
5626
|
+
);
|
|
5627
|
+
|
|
5628
|
+
// ── Performance loop: metrics, attributes, cautious recommendation ────────────
|
|
5629
|
+
//
|
|
5630
|
+
// Attach lightweight performance snapshots + creative-attribute tags to a
|
|
5631
|
+
// campaign-pack ITEM (keyed by the globally-unique item id), then read a CAUTIOUS
|
|
5632
|
+
// aggregate recommendation over your own items. Everything here is user-scoped
|
|
5633
|
+
// (a foreign/unknown item is a generic 404) and spends NO video credits; only
|
|
5634
|
+
// make_more_like_winner consumes the free daily AI-assist quota. get_status ids
|
|
5635
|
+
// come from a materialized pack item (get_campaign_pack surfaces each item id).
|
|
5636
|
+
|
|
5637
|
+
registerTool(
|
|
5638
|
+
"record_performance",
|
|
5639
|
+
{
|
|
5640
|
+
title: "Record a performance snapshot for a campaign-pack item",
|
|
5641
|
+
description:
|
|
5642
|
+
"Attaches one performance snapshot (platform + engagement/conversion counts + optional revenue/watch " +
|
|
5643
|
+
"time) to one of your own campaign-pack items (item_id from get_campaign_pack). Many snapshots per item " +
|
|
5644
|
+
"are allowed — captured_at (defaults to now if omitted) distinguishes them, so you can log a trend over " +
|
|
5645
|
+
"time. Spends 0 credits. Scope: video:generate. Rate limit: 30/min. 404 for a foreign/unknown item; 422 " +
|
|
5646
|
+
"for invalid metrics. NOT idempotency-keyed — a transport retry records a DUPLICATE snapshot, so don't " +
|
|
5647
|
+
"blindly retry a success.",
|
|
5648
|
+
inputSchema: {
|
|
5649
|
+
item_id: z
|
|
5650
|
+
.number()
|
|
5651
|
+
.int()
|
|
5652
|
+
.describe("Campaign-pack item id (from get_campaign_pack)"),
|
|
5653
|
+
platform: z
|
|
5654
|
+
.enum(PERFORMANCE_PLATFORMS)
|
|
5655
|
+
.describe('Where it ran: tiktok, instagram, or "other"'),
|
|
5656
|
+
views: z.number().int().optional().describe("View count (≥0)"),
|
|
5657
|
+
likes: z.number().int().optional().describe("Like count (≥0)"),
|
|
5658
|
+
comments: z.number().int().optional().describe("Comment count (≥0)"),
|
|
5659
|
+
shares: z.number().int().optional().describe("Share count (≥0)"),
|
|
5660
|
+
clicks: z.number().int().optional().describe("Click count (≥0)"),
|
|
5661
|
+
conversions: z
|
|
5662
|
+
.number()
|
|
5663
|
+
.int()
|
|
5664
|
+
.optional()
|
|
5665
|
+
.describe("Conversion count (≥0)"),
|
|
5666
|
+
revenue: z
|
|
5667
|
+
.number()
|
|
5668
|
+
.optional()
|
|
5669
|
+
.describe("Attributed revenue (≥0, ≤~1 trillion)"),
|
|
5670
|
+
watch_time_seconds: z
|
|
5671
|
+
.number()
|
|
5672
|
+
.int()
|
|
5673
|
+
.optional()
|
|
5674
|
+
.describe("Total watch time in seconds (≥0)"),
|
|
5675
|
+
source: z
|
|
5676
|
+
.enum(PERFORMANCE_SOURCES)
|
|
5677
|
+
.optional()
|
|
5678
|
+
.describe("How the metrics were gathered (default manual)"),
|
|
5679
|
+
captured_at: z
|
|
5680
|
+
.string()
|
|
5681
|
+
.optional()
|
|
5682
|
+
.describe("When measured, ISO-8601 UTC (defaults to now; not future)"),
|
|
5683
|
+
metadata: z
|
|
5684
|
+
.record(z.string(), z.unknown())
|
|
5685
|
+
.optional()
|
|
5686
|
+
.describe("Optional provenance blob (≤16 KiB JSON)"),
|
|
5687
|
+
},
|
|
5688
|
+
annotations: {
|
|
5689
|
+
title: "Record performance",
|
|
5690
|
+
...WRITE,
|
|
5691
|
+
// Append-only (many snapshots per item, no idempotency plug) → a retry
|
|
5692
|
+
// forks a duplicate snapshot.
|
|
5693
|
+
idempotentHint: false,
|
|
5694
|
+
},
|
|
5695
|
+
},
|
|
5696
|
+
tool(async (args: RecordPerformanceArgs & { item_id: number }, client) => {
|
|
5697
|
+
const err = validatePerformanceMetrics(args);
|
|
5698
|
+
if (err) return fail(err);
|
|
5699
|
+
const res = await client.post<{ data: unknown }>(
|
|
5700
|
+
`/campaign-pack-items/${args.item_id}/performance`,
|
|
5701
|
+
buildPerformanceBody(args),
|
|
5702
|
+
);
|
|
5703
|
+
return ok({
|
|
5704
|
+
...summarizePerformance(asRecord(res).data),
|
|
5705
|
+
next: "set_item_attributes to tag this item's creative, then get_recommendations once a few items have both.",
|
|
5706
|
+
});
|
|
5707
|
+
}),
|
|
5708
|
+
);
|
|
5709
|
+
|
|
5710
|
+
registerTool(
|
|
5711
|
+
"list_performance",
|
|
5712
|
+
{
|
|
5713
|
+
title: "List a campaign-pack item's performance snapshots",
|
|
5714
|
+
description:
|
|
5715
|
+
"Returns one of your own campaign-pack items' performance snapshots (newest first) plus a small summary " +
|
|
5716
|
+
"(latest snapshot + count). Read-only, 0 credits. Scope: video:read. 404 for a foreign/unknown item.",
|
|
5717
|
+
inputSchema: {
|
|
5718
|
+
item_id: z.number().int().describe("Campaign-pack item id"),
|
|
5719
|
+
},
|
|
5720
|
+
annotations: { title: "List performance", ...RO },
|
|
5721
|
+
},
|
|
5722
|
+
tool(async (args: { item_id: number }, client) => {
|
|
5723
|
+
const res = await client.get<{ data: unknown }>(
|
|
5724
|
+
`/campaign-pack-items/${args.item_id}/performance`,
|
|
5725
|
+
);
|
|
5726
|
+
const data = asRecord(asRecord(res).data);
|
|
5727
|
+
const performance = Array.isArray(data.performance)
|
|
5728
|
+
? data.performance.map(summarizePerformance)
|
|
5729
|
+
: [];
|
|
5730
|
+
return ok({
|
|
5731
|
+
item_id: args.item_id,
|
|
5732
|
+
performance,
|
|
5733
|
+
summary: data.summary ?? null,
|
|
5734
|
+
});
|
|
5735
|
+
}),
|
|
5736
|
+
);
|
|
5737
|
+
|
|
5738
|
+
registerTool(
|
|
5739
|
+
"set_item_attributes",
|
|
5740
|
+
{
|
|
5741
|
+
title: "Set the creative attributes for a campaign-pack item",
|
|
5742
|
+
description:
|
|
5743
|
+
"Inserts-or-updates the SINGLE creative-attributes row (hook_type, creative_format, visual_language, " +
|
|
5744
|
+
"product_category, cta, length, caption_style) for one of your own campaign-pack items — the tags the " +
|
|
5745
|
+
"recommendation loop correlates with performance. Calling it again UPDATES the same row (atomic upsert). " +
|
|
5746
|
+
"Spends 0 credits. Scope: video:generate. 404 for a foreign/unknown item; 422 for invalid attributes.",
|
|
5747
|
+
inputSchema: {
|
|
5748
|
+
item_id: z.number().int().describe("Campaign-pack item id"),
|
|
5749
|
+
hook_type: z
|
|
5750
|
+
.string()
|
|
5751
|
+
.optional()
|
|
5752
|
+
.describe('The hook style, e.g. "question", "bold_claim"'),
|
|
5753
|
+
creative_format: z
|
|
5754
|
+
.string()
|
|
5755
|
+
.optional()
|
|
5756
|
+
.describe('Narrative format, e.g. "problem_solution"'),
|
|
5757
|
+
visual_language: z
|
|
5758
|
+
.string()
|
|
5759
|
+
.optional()
|
|
5760
|
+
.describe('Production treatment, e.g. "ugc_realism"'),
|
|
5761
|
+
product_category: z.string().optional().describe("Product category tag"),
|
|
5762
|
+
cta: z.string().optional().describe("Call-to-action used"),
|
|
5763
|
+
length: z.string().optional().describe('Length bucket, e.g. "short"'),
|
|
5764
|
+
caption_style: z.string().optional().describe("Caption style tag"),
|
|
5765
|
+
metadata: z
|
|
5766
|
+
.record(z.string(), z.unknown())
|
|
5767
|
+
.optional()
|
|
5768
|
+
.describe("Optional extra classifiers"),
|
|
5769
|
+
},
|
|
5770
|
+
annotations: {
|
|
5771
|
+
title: "Set item attributes",
|
|
5772
|
+
...WRITE,
|
|
5773
|
+
// A single upserted row: a repeat identical call converges to the same
|
|
5774
|
+
// row, so re-applying is safe.
|
|
5775
|
+
idempotentHint: true,
|
|
5776
|
+
},
|
|
5777
|
+
},
|
|
5778
|
+
tool(async (args: { item_id: number } & Record<string, unknown>, client) => {
|
|
5779
|
+
const res = await client.put<{ data: unknown }>(
|
|
5780
|
+
`/campaign-pack-items/${args.item_id}/attributes`,
|
|
5781
|
+
pickCreativeAttributeFields(args),
|
|
5782
|
+
);
|
|
5783
|
+
return ok(asRecord(res).data);
|
|
5784
|
+
}),
|
|
5785
|
+
);
|
|
5786
|
+
|
|
5787
|
+
registerTool(
|
|
5788
|
+
"get_item_attributes",
|
|
5789
|
+
{
|
|
5790
|
+
title: "Fetch the creative attributes for a campaign-pack item",
|
|
5791
|
+
description:
|
|
5792
|
+
"Returns the single creative-attributes row for one of your own campaign-pack items, or null if none has " +
|
|
5793
|
+
"been set. Read-only, 0 credits. Scope: video:read. 404 for a foreign/unknown item.",
|
|
5794
|
+
inputSchema: {
|
|
5795
|
+
item_id: z.number().int().describe("Campaign-pack item id"),
|
|
5796
|
+
},
|
|
5797
|
+
annotations: { title: "Get item attributes", ...RO },
|
|
5798
|
+
},
|
|
5799
|
+
tool(async (args: { item_id: number }, client) => {
|
|
5800
|
+
const res = await client.get<{ data: unknown }>(
|
|
5801
|
+
`/campaign-pack-items/${args.item_id}/attributes`,
|
|
5802
|
+
);
|
|
5803
|
+
return ok(asRecord(res).data ?? null);
|
|
5804
|
+
}),
|
|
5805
|
+
);
|
|
5806
|
+
|
|
5807
|
+
registerTool(
|
|
5808
|
+
"get_recommendations",
|
|
5809
|
+
{
|
|
5810
|
+
title: "Cautious creative recommendation from your own performance",
|
|
5811
|
+
description:
|
|
5812
|
+
"Aggregates your own campaign-pack items that have BOTH creative attributes AND at least one performance " +
|
|
5813
|
+
"snapshot into a CAUTIOUS recommendation: the top-performing hook_type / creative_format / visual_language " +
|
|
5814
|
+
"by average views, your best items, and an HONEST confidence level. Confidence is a function of sample size " +
|
|
5815
|
+
'and is returned as "none" (no data yet), "low", or "moderate" — it is NEVER overstated, and the result ' +
|
|
5816
|
+
"NEVER claims causation, ROAS, or attribution: these are correlations in YOUR OWN data, a hint to test, not " +
|
|
5817
|
+
"proof. Present the server's `note` and `confidence` as-is; do not upgrade them. Read-only; NO LLM; NO " +
|
|
5818
|
+
"video credits; NO quota. Scope: video:read.",
|
|
5819
|
+
inputSchema: {},
|
|
5820
|
+
outputSchema: getRecommendationsOutput,
|
|
5821
|
+
annotations: { title: "Get recommendations", ...RO },
|
|
5822
|
+
},
|
|
5823
|
+
tool(async (_args, client) => {
|
|
5824
|
+
const res = await client.get<{ data: unknown }>(
|
|
5825
|
+
"/performance/recommendations",
|
|
5826
|
+
);
|
|
5827
|
+
// Pass the server aggregate through UNCHANGED — the caution semantics
|
|
5828
|
+
// (confidence tier + non-causal note) are the server's to state, not ours.
|
|
5829
|
+
return ok(asRecord(res).data);
|
|
5830
|
+
}),
|
|
5831
|
+
);
|
|
5832
|
+
|
|
5833
|
+
registerTool(
|
|
5834
|
+
"make_more_like_winner",
|
|
5835
|
+
{
|
|
5836
|
+
title: "Make more hook variations like a winning item",
|
|
5837
|
+
description:
|
|
5838
|
+
"Generates 3 follow-up hook variations from one of your own campaign-pack items, threading that item's " +
|
|
5839
|
+
"winning angle/format/hook into the generator while keeping its brand + product profile — the way to " +
|
|
5840
|
+
"double down on a proven creative. Generates no media, persists nothing, and spends NO video credits: it " +
|
|
5841
|
+
"consumes the FREE daily AI-assist quota instead (429 when exhausted; set auto_unlock:true to spend 1 credit " +
|
|
5842
|
+
"for +10 assists, retried once, or call unlock_ai_assists). Scope: video:generate. Rate limit: 30/min. 404 " +
|
|
5843
|
+
"for a foreign/unknown item; 422 when the item has no usable product context. These are new IDEAS — feed a " +
|
|
5844
|
+
"chosen one into add_pack_variations / create_campaign_pack to turn it into a draft.",
|
|
5845
|
+
inputSchema: {
|
|
5846
|
+
item_id: z
|
|
5847
|
+
.number()
|
|
5848
|
+
.int()
|
|
5849
|
+
.describe("Campaign-pack item id of the winning creative"),
|
|
5850
|
+
auto_unlock: z
|
|
5851
|
+
.boolean()
|
|
5852
|
+
.optional()
|
|
5853
|
+
.describe(
|
|
5854
|
+
"On 429, spend 1 credit to unlock +10 assists and retry once (default false)",
|
|
5855
|
+
),
|
|
5856
|
+
},
|
|
5857
|
+
annotations: {
|
|
5858
|
+
title: "Make more like winner",
|
|
5859
|
+
...WRITE,
|
|
5860
|
+
// Consumes the free quota (a side effect); no idempotency plug.
|
|
5861
|
+
idempotentHint: false,
|
|
5862
|
+
},
|
|
5863
|
+
},
|
|
5864
|
+
tool(async (args: { item_id: number; auto_unlock?: boolean }, client) => {
|
|
5865
|
+
const res = await withAssist(client, args.auto_unlock ?? false, () =>
|
|
5866
|
+
client.post<{ data: unknown }>(
|
|
5867
|
+
`/campaign-pack-items/${args.item_id}/make-more`,
|
|
5868
|
+
),
|
|
5869
|
+
);
|
|
5870
|
+
const data = asRecord(asRecord(res).data);
|
|
5871
|
+
return ok({
|
|
5872
|
+
item_id: args.item_id,
|
|
5873
|
+
variations: Array.isArray(data.variations) ? data.variations : [],
|
|
5874
|
+
ai_assist_quota: data.ai_assist_quota ?? null,
|
|
5875
|
+
next: "Feed a chosen variation into add_pack_variations({ pack_id, variations, materialize: true }) to draft it.",
|
|
5876
|
+
});
|
|
5877
|
+
}),
|
|
5878
|
+
);
|
|
5879
|
+
|
|
5880
|
+
// ── Review links: no-login approval loop ──────────────────────────────────────
|
|
5881
|
+
//
|
|
5882
|
+
// Mint an expiring, hashed-token review link for ONE completed output so a
|
|
5883
|
+
// reviewer can open /review/<token> with no account. The plaintext token is
|
|
5884
|
+
// returned ONCE at creation and is NOT retrievable again (only its hash is
|
|
5885
|
+
// stored). All user-scoped; spends 0 credits; writes need video:generate.
|
|
5886
|
+
|
|
5887
|
+
registerTool(
|
|
5888
|
+
"create_review_link",
|
|
5889
|
+
{
|
|
5890
|
+
title: "Create a no-login review link for one completed output",
|
|
5891
|
+
description:
|
|
5892
|
+
"Mints an expiring, hashed-token review link for EXACTLY ONE of your own COMPLETED outputs — a video " +
|
|
5893
|
+
"result (video_result_id) OR a carousel/slider (slider_id), never both — optionally grouped under one of " +
|
|
5894
|
+
"your campaign packs. The reviewer opens the returned URL with NO account. Spends 0 credits. Scope: " +
|
|
5895
|
+
"video:generate. IMPORTANT: the plaintext token + review URL are returned ONCE and CANNOT be retrieved " +
|
|
5896
|
+
"again (only the hash is stored) — surface them to the user immediately. expires_in_hours defaults to 168 " +
|
|
5897
|
+
`(7 days) and is clamped to ${REVIEW_LINK_MIN_EXPIRES_IN_HOURS}..${REVIEW_LINK_MAX_EXPIRES_IN_HOURS}. ` +
|
|
5898
|
+
"422 for a not-completed/foreign output or a foreign campaign pack.",
|
|
5899
|
+
inputSchema: {
|
|
5900
|
+
video_result_id: z
|
|
5901
|
+
.number()
|
|
5902
|
+
.int()
|
|
5903
|
+
.optional()
|
|
5904
|
+
.describe(
|
|
5905
|
+
"Id of one of your own COMPLETED video results (mutually exclusive with slider_id)",
|
|
5906
|
+
),
|
|
5907
|
+
slider_id: z
|
|
5908
|
+
.number()
|
|
5909
|
+
.int()
|
|
5910
|
+
.optional()
|
|
5911
|
+
.describe(
|
|
5912
|
+
"Id of one of your own COMPLETED sliders/carousels (mutually exclusive with video_result_id)",
|
|
5913
|
+
),
|
|
5914
|
+
campaign_pack_id: z
|
|
5915
|
+
.number()
|
|
5916
|
+
.int()
|
|
5917
|
+
.optional()
|
|
5918
|
+
.describe(
|
|
5919
|
+
"Optional id of one of your own campaign packs to group under",
|
|
5920
|
+
),
|
|
5921
|
+
expires_in_hours: z
|
|
5922
|
+
.number()
|
|
5923
|
+
.int()
|
|
5924
|
+
.optional()
|
|
5925
|
+
.describe(
|
|
5926
|
+
`How long the link stays valid; clamped ${REVIEW_LINK_MIN_EXPIRES_IN_HOURS}..${REVIEW_LINK_MAX_EXPIRES_IN_HOURS} (default 168 = 7 days)`,
|
|
5927
|
+
),
|
|
5928
|
+
download_enabled: z
|
|
5929
|
+
.boolean()
|
|
5930
|
+
.optional()
|
|
5931
|
+
.describe(
|
|
5932
|
+
"Whether the reviewer may download the output (default false)",
|
|
5933
|
+
),
|
|
5934
|
+
version_label: z
|
|
5935
|
+
.string()
|
|
5936
|
+
.optional()
|
|
5937
|
+
.describe("Optional human label for this review version (≤120 chars)"),
|
|
5938
|
+
},
|
|
5939
|
+
annotations: {
|
|
5940
|
+
title: "Create review link",
|
|
5941
|
+
...WRITE,
|
|
5942
|
+
// Mints a fresh token every call (no idempotency plug) → a retry forks a
|
|
5943
|
+
// second link with a NEW token.
|
|
5944
|
+
idempotentHint: false,
|
|
5945
|
+
},
|
|
5946
|
+
},
|
|
5947
|
+
tool(
|
|
5948
|
+
async (
|
|
5949
|
+
args: {
|
|
5950
|
+
video_result_id?: number;
|
|
5951
|
+
slider_id?: number;
|
|
5952
|
+
campaign_pack_id?: number;
|
|
5953
|
+
expires_in_hours?: number;
|
|
5954
|
+
download_enabled?: boolean;
|
|
5955
|
+
version_label?: string;
|
|
5956
|
+
},
|
|
5957
|
+
client,
|
|
5958
|
+
) => {
|
|
5959
|
+
const err = validateReviewOutput(args.video_result_id, args.slider_id);
|
|
5960
|
+
if (err) return fail(err);
|
|
5961
|
+
const res = await client.post<{ data: unknown }>(
|
|
5962
|
+
"/review-links",
|
|
5963
|
+
buildReviewLinkBody(args),
|
|
5964
|
+
);
|
|
5965
|
+
// The reviewer page (/review/<token>) is a FRONT web-app route, not an API
|
|
5966
|
+
// endpoint, so build the absolute URL on the web-app origin (HUBFLUENCER_WEB_URL,
|
|
5967
|
+
// default https://app.hubfluencer.com) — NOT client.origin (the API base).
|
|
5968
|
+
const result: CreateReviewLinkResult = shapeCreateReviewLink(
|
|
5969
|
+
asRecord(res).data,
|
|
5970
|
+
reviewWebOrigin(),
|
|
5971
|
+
);
|
|
5972
|
+
return ok(result);
|
|
5973
|
+
},
|
|
5974
|
+
),
|
|
5975
|
+
);
|
|
5976
|
+
|
|
5977
|
+
registerTool(
|
|
5978
|
+
"list_review_links",
|
|
5979
|
+
{
|
|
5980
|
+
title: "List your review links",
|
|
5981
|
+
description:
|
|
5982
|
+
"Lists your review links, newest first, each summarized with its status (pending → viewed → approved / " +
|
|
5983
|
+
"changes_requested / revoked), the output it points at, expiry, and its visible comment_count — so you can " +
|
|
5984
|
+
"see review + approval activity at a glance. NEVER returns the token (only shown once at creation). " +
|
|
5985
|
+
"Read-only, 0 credits. Scope: video:read.",
|
|
5986
|
+
inputSchema: {},
|
|
5987
|
+
annotations: { title: "List review links", ...RO },
|
|
5988
|
+
},
|
|
5989
|
+
tool(async (_args, client) => {
|
|
5990
|
+
const res = await client.get<{ data: unknown }>("/review-links");
|
|
5991
|
+
const data = asRecord(res).data;
|
|
5992
|
+
const links = Array.isArray(data) ? data.map(summarizeReviewLink) : [];
|
|
5993
|
+
return ok({ review_links: links });
|
|
5994
|
+
}),
|
|
5995
|
+
);
|
|
5996
|
+
|
|
5997
|
+
registerTool(
|
|
5998
|
+
"revoke_review_link",
|
|
5999
|
+
{
|
|
6000
|
+
title: "Revoke a review link",
|
|
6001
|
+
description:
|
|
6002
|
+
"Revokes one of your own review links (status → 'revoked'); the no-login URL immediately stops working. " +
|
|
6003
|
+
"Returns the revoked link. Spends 0 credits. Scope: video:generate. 404 for an unknown/foreign id.",
|
|
6004
|
+
inputSchema: {
|
|
6005
|
+
id: z.number().int().describe("Review link id (from list_review_links)"),
|
|
6006
|
+
},
|
|
6007
|
+
annotations: {
|
|
6008
|
+
title: "Revoke review link",
|
|
6009
|
+
readOnlyHint: false,
|
|
6010
|
+
// Disabling the public URL is a state-destroying transition on the link.
|
|
6011
|
+
destructiveHint: true,
|
|
6012
|
+
openWorldHint: true,
|
|
6013
|
+
idempotentHint: false,
|
|
6014
|
+
},
|
|
6015
|
+
},
|
|
6016
|
+
tool(async (args: { id: number }, client) => {
|
|
6017
|
+
const res = await client.del<{ data: unknown }>(`/review-links/${args.id}`);
|
|
6018
|
+
return ok(summarizeReviewLink(asRecord(res).data));
|
|
6019
|
+
}),
|
|
6020
|
+
);
|
|
6021
|
+
|
|
6022
|
+
// ── Asset catalog: cross-project reusable media ───────────────────────────────
|
|
6023
|
+
//
|
|
6024
|
+
// The reusable user asset library (images + videos). Uploading here lets an asset
|
|
6025
|
+
// be reused across projects (via the app's from-asset flows). SUBSCRIPTION-GATED:
|
|
6026
|
+
// these routes sit behind RequireSubscription, which returns 402
|
|
6027
|
+
// `subscription_required` when the user has no active subscription —
|
|
6028
|
+
// assetCatalog() re-surfaces that as a clear "needs an active subscription".
|
|
6029
|
+
|
|
6030
|
+
/**
|
|
6031
|
+
* Wraps an asset-catalog tool so a `subscription_required` 402 (the
|
|
6032
|
+
* RequireSubscription plug) is re-surfaced with a clear "subscribe first"
|
|
6033
|
+
* explanation (the generic errMessage would print the bare server sentence). Any
|
|
6034
|
+
* other failure flows through tool()'s normal errMessage path unchanged.
|
|
6035
|
+
*/
|
|
6036
|
+
function assetCatalog<A>(
|
|
6037
|
+
fn: (
|
|
6038
|
+
args: A,
|
|
6039
|
+
client: HubfluencerClient,
|
|
6040
|
+
extra: ToolExtra,
|
|
6041
|
+
) => Promise<ReturnType<typeof ok>>,
|
|
6042
|
+
) {
|
|
6043
|
+
return tool<A>(async (args, client, extra) => {
|
|
6044
|
+
try {
|
|
6045
|
+
return await fn(args, client, extra);
|
|
6046
|
+
} catch (e) {
|
|
6047
|
+
const he = e as HubfluencerError;
|
|
6048
|
+
if (he && he.status === 402) {
|
|
6049
|
+
return fail(
|
|
6050
|
+
assetSubscriptionErrorText(he.status, he.code, errMessage(e)),
|
|
6051
|
+
);
|
|
6052
|
+
}
|
|
6053
|
+
throw e;
|
|
6054
|
+
}
|
|
6055
|
+
});
|
|
6056
|
+
}
|
|
6057
|
+
|
|
6058
|
+
registerTool(
|
|
6059
|
+
"list_assets",
|
|
6060
|
+
{
|
|
6061
|
+
title: "List your reusable asset catalog",
|
|
6062
|
+
description:
|
|
6063
|
+
"Lists your reusable asset catalog (images + videos), newest first, each with its media type, mime, size, " +
|
|
6064
|
+
"description, status, and a presigned asset_url — plus your storage quota. Optionally page with limit/offset " +
|
|
6065
|
+
"(offset = page * limit). Read-only, 0 credits. Scope: video:read. Requires an ACTIVE SUBSCRIPTION (402 " +
|
|
6066
|
+
"subscription_required otherwise).",
|
|
6067
|
+
inputSchema: {
|
|
6068
|
+
limit: z
|
|
6069
|
+
.number()
|
|
6070
|
+
.int()
|
|
6071
|
+
.optional()
|
|
6072
|
+
.describe("Max assets to return (a positive integer)"),
|
|
6073
|
+
offset: z
|
|
6074
|
+
.number()
|
|
6075
|
+
.int()
|
|
6076
|
+
.optional()
|
|
6077
|
+
.describe("How many to skip (offset = page * limit)"),
|
|
6078
|
+
},
|
|
6079
|
+
annotations: { title: "List assets", ...RO },
|
|
6080
|
+
},
|
|
6081
|
+
assetCatalog(async (args: { limit?: number; offset?: number }, client) => {
|
|
6082
|
+
const query: Record<string, number> = {};
|
|
6083
|
+
if (args.limit !== undefined) query.limit = args.limit;
|
|
6084
|
+
if (args.offset !== undefined) query.offset = args.offset;
|
|
6085
|
+
const res = await client.get<{ data: unknown; meta?: unknown }>(
|
|
6086
|
+
"/assets",
|
|
6087
|
+
Object.keys(query).length > 0 ? query : undefined,
|
|
6088
|
+
);
|
|
6089
|
+
const data = asRecord(res).data;
|
|
6090
|
+
const assets = Array.isArray(data) ? data.map(summarizeCatalogAsset) : [];
|
|
6091
|
+
const quota = asRecord(asRecord(res).meta).quota;
|
|
6092
|
+
return ok({
|
|
6093
|
+
assets,
|
|
6094
|
+
quota: quota ? summarizeAssetQuota(quota) : null,
|
|
6095
|
+
});
|
|
6096
|
+
}),
|
|
6097
|
+
);
|
|
6098
|
+
|
|
6099
|
+
registerTool(
|
|
6100
|
+
"get_asset_quota",
|
|
6101
|
+
{
|
|
6102
|
+
title: "Get your asset-catalog storage quota",
|
|
6103
|
+
description:
|
|
6104
|
+
"Returns your asset-catalog storage quota (limit / used / pending / reserved / remaining bytes). Read-only, " +
|
|
6105
|
+
"0 credits. Scope: video:read. Requires an ACTIVE SUBSCRIPTION (402 subscription_required otherwise).",
|
|
6106
|
+
inputSchema: {},
|
|
6107
|
+
annotations: { title: "Get asset quota", ...RO },
|
|
6108
|
+
},
|
|
6109
|
+
assetCatalog(async (_args, client) => {
|
|
6110
|
+
const res = await client.get<{ data: unknown }>("/assets/quota");
|
|
6111
|
+
return ok(summarizeAssetQuota(asRecord(res).data));
|
|
6112
|
+
}),
|
|
6113
|
+
);
|
|
6114
|
+
|
|
6115
|
+
registerTool(
|
|
6116
|
+
"upload_asset",
|
|
6117
|
+
{
|
|
6118
|
+
title: "Upload a local file to your reusable asset catalog",
|
|
6119
|
+
description:
|
|
6120
|
+
"Uploads a local IMAGE or VIDEO into your reusable asset catalog so it can be reused across projects. " +
|
|
6121
|
+
`Allowed types: ${CATALOG_ASSET_EXTS.map((e) => `.${e}`).join(", ")} — images ≤20 MiB, videos ≤500 MB. ` +
|
|
6122
|
+
"Runs presign → PUT → confirm; the confirm HEADs the object and validates size/type, so a mismatch 422s. " +
|
|
6123
|
+
"For a VIDEO you MUST pass duration_seconds (≤60; the server rejects a video with no/over-long duration). " +
|
|
6124
|
+
"Spends 0 credits. Scope: video:generate. Rate limit: 30/min. Requires an ACTIVE SUBSCRIPTION (402 " +
|
|
6125
|
+
"subscription_required otherwise). The local path is confined to HUBFLUENCER_INPUT_DIR (default: cwd). " +
|
|
6126
|
+
"Returns the confirmed asset id + media type + status.",
|
|
6127
|
+
inputSchema: {
|
|
6128
|
+
file_path: z
|
|
6129
|
+
.string()
|
|
6130
|
+
.describe(
|
|
6131
|
+
"Path to a local image/video, inside HUBFLUENCER_INPUT_DIR (default: cwd)",
|
|
6132
|
+
),
|
|
6133
|
+
description: z
|
|
6134
|
+
.string()
|
|
6135
|
+
.optional()
|
|
6136
|
+
.describe("Optional description stored with the asset"),
|
|
6137
|
+
duration_seconds: z
|
|
6138
|
+
.number()
|
|
6139
|
+
.optional()
|
|
6140
|
+
.describe("Video duration in seconds (REQUIRED for a video, ≤60)"),
|
|
6141
|
+
width: z.number().int().optional().describe("Optional pixel width"),
|
|
6142
|
+
height: z.number().int().optional().describe("Optional pixel height"),
|
|
6143
|
+
},
|
|
6144
|
+
annotations: {
|
|
6145
|
+
title: "Upload asset",
|
|
6146
|
+
...WRITE,
|
|
6147
|
+
// Each upload presigns a fresh asset row (no idempotency plug) → a retry
|
|
6148
|
+
// uploads a duplicate.
|
|
6149
|
+
idempotentHint: false,
|
|
6150
|
+
},
|
|
6151
|
+
},
|
|
6152
|
+
assetCatalog(
|
|
6153
|
+
async (
|
|
6154
|
+
args: {
|
|
6155
|
+
file_path: string;
|
|
6156
|
+
description?: string;
|
|
6157
|
+
duration_seconds?: number;
|
|
6158
|
+
width?: number;
|
|
6159
|
+
height?: number;
|
|
6160
|
+
},
|
|
6161
|
+
client,
|
|
6162
|
+
) => {
|
|
6163
|
+
const asset = await uploadCatalogAsset(client, args.file_path, {
|
|
6164
|
+
description: args.description,
|
|
6165
|
+
durationSeconds: args.duration_seconds,
|
|
6166
|
+
width: args.width,
|
|
6167
|
+
height: args.height,
|
|
6168
|
+
});
|
|
6169
|
+
return ok({
|
|
6170
|
+
...asset,
|
|
6171
|
+
next: "list_assets to see it; reuse it across projects from the app's asset picker.",
|
|
6172
|
+
});
|
|
6173
|
+
},
|
|
6174
|
+
),
|
|
6175
|
+
);
|
|
6176
|
+
|
|
3273
6177
|
// ── Boot ──────────────────────────────────────────────────────────────────────
|
|
3274
6178
|
|
|
3275
6179
|
async function main() {
|