@hubfluencer/mcp 0.5.0 → 0.7.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 +190 -32
- package/dist/index.js +2835 -144
- 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 +4809 -1596
- 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
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, side-effect-light helpers for the series + content-calendar tools
|
|
3
|
+
* (index.ts).
|
|
4
|
+
*
|
|
5
|
+
* Like core.ts / campaign.ts, this lives outside index.ts on purpose: index.ts
|
|
6
|
+
* registers every tool and connects an stdio transport at module load, so a unit
|
|
7
|
+
* test can't import it. The request-shape builders, response summarizers, the
|
|
8
|
+
* batch/bounds validators, and the calendar's 403-scope message augmentation are
|
|
9
|
+
* extracted here so they are testable against a fake client / plain inputs.
|
|
10
|
+
*
|
|
11
|
+
* GROUND TRUTH (verified against the API controllers + contexts, 2026-07-02):
|
|
12
|
+
* - series_controller.ex, series_episode_controller.ex,
|
|
13
|
+
* scheduled_post_controller.ex + the Series / Posts contexts.
|
|
14
|
+
* - NONE of those three controllers mount `Plugs.Idempotency`, so the series +
|
|
15
|
+
* calendar tools send NO Idempotency-Key (mirroring campaign.ts). A plain
|
|
16
|
+
* transport retry of a create is therefore NOT server-deduplicated; the tool
|
|
17
|
+
* descriptions + `idempotentHint` reflect that honestly. The two exceptions
|
|
18
|
+
* that ARE safe to replay are semantic, not plug-backed: mark-episode-posted
|
|
19
|
+
* and mark-scheduled-posted are CAS transitions whose replay the server
|
|
20
|
+
* defines as a no-op / 422 (see the mark helpers below).
|
|
21
|
+
* - plan_series_episodes meters the FREE daily AiAssistQuota (429
|
|
22
|
+
* `ai_assist_quota_exceeded`, identical shape to the campaign-pack planner)
|
|
23
|
+
* and spends no video credits. materialize_episode creates a 0-credit DRAFT.
|
|
24
|
+
* - The calendar is REMINDER-ONLY by design here: these tools never accept or
|
|
25
|
+
* send a social-account id, so every scheduled_post they create is a manual-
|
|
26
|
+
* posting reminder that the pipeline `WriteScope` floor authorizes with
|
|
27
|
+
* `video:generate`. Attaching a social account (auto-publish) additionally
|
|
28
|
+
* requires `account:admin`, which agent tokens never hold — so a 403
|
|
29
|
+
* `insufficient_scope` here means the TARGETED post already carries an
|
|
30
|
+
* account, or the token predates the reminder-calendar scope split.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { asRecord } from "./core.js";
|
|
34
|
+
|
|
35
|
+
/** The narrow client surface these tools' helpers need — a subset of HubfluencerClient. */
|
|
36
|
+
export interface SeriesCalendarClient {
|
|
37
|
+
get<T = unknown>(
|
|
38
|
+
path: string,
|
|
39
|
+
query?: Record<string, string | number | undefined>,
|
|
40
|
+
): Promise<T>;
|
|
41
|
+
post<T = unknown>(
|
|
42
|
+
path: string,
|
|
43
|
+
body?: unknown,
|
|
44
|
+
idempotencyKey?: string,
|
|
45
|
+
): Promise<T>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Series field mapping ──────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
// The series create/update body accepts exactly these fields
|
|
51
|
+
// (series_controller.ex @series_fields). `campaign_pack_id` is intentionally
|
|
52
|
+
// EXCLUDED — the server sets the series' rolling pack from context on the first
|
|
53
|
+
// materialize, never from user input — so it is not offered here either.
|
|
54
|
+
export const SERIES_FIELDS = [
|
|
55
|
+
"brand_profile_id",
|
|
56
|
+
"product_profile_id",
|
|
57
|
+
"name",
|
|
58
|
+
"template",
|
|
59
|
+
"cadence",
|
|
60
|
+
"tone",
|
|
61
|
+
"default_format",
|
|
62
|
+
"status",
|
|
63
|
+
"metadata",
|
|
64
|
+
] as const;
|
|
65
|
+
|
|
66
|
+
/** Picks only the accepted series fields from a raw args object, dropping the rest. */
|
|
67
|
+
export function pickSeriesFields(
|
|
68
|
+
args: Record<string, unknown>,
|
|
69
|
+
): Record<string, unknown> {
|
|
70
|
+
const out: Record<string, unknown> = {};
|
|
71
|
+
for (const k of SERIES_FIELDS) {
|
|
72
|
+
if (args[k] !== undefined) out[k] = args[k];
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Series / episode summarization ────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
// A materialized episode deep-links by the format of its draft: editor_ad →
|
|
80
|
+
// /editor/:slug, short → /short/:slug, carousel → /slider/:slug. The serializer
|
|
81
|
+
// already resolves the slug off the (preloaded) campaign_pack_item; surfacing the
|
|
82
|
+
// route saves the agent from re-deriving it and mirrors the pack-item summarizer.
|
|
83
|
+
function episodeDraftRoute(
|
|
84
|
+
format: unknown,
|
|
85
|
+
factorySlug: string | null,
|
|
86
|
+
sliderSlug: string | null,
|
|
87
|
+
): string | null {
|
|
88
|
+
if (format === "carousel") return sliderSlug ? `/slider/${sliderSlug}` : null;
|
|
89
|
+
if (format === "short") return factorySlug ? `/short/${factorySlug}` : null;
|
|
90
|
+
if (format === "editor_ad")
|
|
91
|
+
return factorySlug ? `/editor/${factorySlug}` : null;
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** One episode, trimmed to the fields an agent needs to decide the next step. */
|
|
96
|
+
export interface EpisodeSummary {
|
|
97
|
+
id: unknown;
|
|
98
|
+
series_id: unknown;
|
|
99
|
+
position: unknown;
|
|
100
|
+
status: unknown;
|
|
101
|
+
hook: unknown;
|
|
102
|
+
brief: unknown;
|
|
103
|
+
caption: unknown;
|
|
104
|
+
suggested_format: unknown;
|
|
105
|
+
hashtags: unknown;
|
|
106
|
+
posted_at: unknown;
|
|
107
|
+
campaign_pack_item_id: unknown;
|
|
108
|
+
video_factory_slug: string | null;
|
|
109
|
+
slider_slug: string | null;
|
|
110
|
+
draft_slug: string | null;
|
|
111
|
+
draft_route: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Summarizes one raw episode (as the SeriesEpisode serializer emits it) into the
|
|
116
|
+
* compact shape the episode tools return. Surfaces the materialized draft slug +
|
|
117
|
+
* its deep-link route (editor_ad/short → the video factory, carousel → the
|
|
118
|
+
* slider) so an agent can see which ideas are still plannable, which are drafted,
|
|
119
|
+
* and where each draft lives.
|
|
120
|
+
*/
|
|
121
|
+
export function summarizeEpisode(episode: unknown): EpisodeSummary {
|
|
122
|
+
const e = asRecord(episode);
|
|
123
|
+
const factorySlug =
|
|
124
|
+
typeof e.video_factory_slug === "string" ? e.video_factory_slug : null;
|
|
125
|
+
const sliderSlug = typeof e.slider_slug === "string" ? e.slider_slug : null;
|
|
126
|
+
const draftSlug = factorySlug ?? sliderSlug;
|
|
127
|
+
return {
|
|
128
|
+
id: e.id,
|
|
129
|
+
series_id: e.series_id,
|
|
130
|
+
position: e.position,
|
|
131
|
+
status: e.status,
|
|
132
|
+
hook: e.hook ?? null,
|
|
133
|
+
brief: e.brief ?? null,
|
|
134
|
+
caption: e.caption ?? null,
|
|
135
|
+
suggested_format: e.suggested_format ?? null,
|
|
136
|
+
hashtags: e.hashtags ?? [],
|
|
137
|
+
posted_at: e.posted_at ?? null,
|
|
138
|
+
campaign_pack_item_id: e.campaign_pack_item_id ?? null,
|
|
139
|
+
video_factory_slug: factorySlug,
|
|
140
|
+
slider_slug: sliderSlug,
|
|
141
|
+
draft_slug: draftSlug,
|
|
142
|
+
draft_route: episodeDraftRoute(e.suggested_format, factorySlug, sliderSlug),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Summarizes a raw series (SeriesController serializer). */
|
|
147
|
+
export function summarizeSeries(series: unknown): Record<string, unknown> {
|
|
148
|
+
const s = asRecord(series);
|
|
149
|
+
return {
|
|
150
|
+
id: s.id,
|
|
151
|
+
name: s.name ?? null,
|
|
152
|
+
template: s.template ?? null,
|
|
153
|
+
cadence: s.cadence ?? null,
|
|
154
|
+
tone: s.tone ?? null,
|
|
155
|
+
default_format: s.default_format ?? null,
|
|
156
|
+
status: s.status ?? null,
|
|
157
|
+
brand_profile_id: s.brand_profile_id ?? null,
|
|
158
|
+
product_profile_id: s.product_profile_id ?? null,
|
|
159
|
+
campaign_pack_id: s.campaign_pack_id ?? null,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Summarizes the read-only series dashboard (SeriesEpisodeController.dashboard):
|
|
165
|
+
* the series plus its episodes grouped by lane (upcoming = ideas, drafted,
|
|
166
|
+
* published) and the per-item performance notes, each episode compacted through
|
|
167
|
+
* summarizeEpisode so the drafted/published lanes carry deep-link routes.
|
|
168
|
+
*/
|
|
169
|
+
export function summarizeDashboard(
|
|
170
|
+
dashboard: unknown,
|
|
171
|
+
): Record<string, unknown> {
|
|
172
|
+
const d = asRecord(dashboard);
|
|
173
|
+
const lane = (v: unknown): EpisodeSummary[] =>
|
|
174
|
+
Array.isArray(v) ? v.map(summarizeEpisode) : [];
|
|
175
|
+
return {
|
|
176
|
+
series: summarizeSeries(d.series),
|
|
177
|
+
upcoming: lane(d.upcoming),
|
|
178
|
+
drafted: lane(d.drafted),
|
|
179
|
+
published: lane(d.published),
|
|
180
|
+
performance_notes: Array.isArray(d.performance_notes)
|
|
181
|
+
? d.performance_notes
|
|
182
|
+
: [],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ── Scheduled-post (calendar) summarization + bounds ──────────────────────────
|
|
187
|
+
|
|
188
|
+
// The scheduled-post statuses the calendar list can filter on
|
|
189
|
+
// (ScheduledPost.statuses/0). Kept in sync with the controller's ?status= enum.
|
|
190
|
+
export const SCHEDULED_POST_STATUSES = [
|
|
191
|
+
"scheduled",
|
|
192
|
+
"reminded",
|
|
193
|
+
"posted",
|
|
194
|
+
"publishing",
|
|
195
|
+
"published",
|
|
196
|
+
"failed",
|
|
197
|
+
"canceled",
|
|
198
|
+
] as const;
|
|
199
|
+
|
|
200
|
+
// The platforms a reminder can target (ScheduledPost @platforms). "other" is the
|
|
201
|
+
// pure-reminder platform (no account may ever attach); these tools never attach
|
|
202
|
+
// an account anyway, so all three are reminder-only from the agent's side.
|
|
203
|
+
export const SCHEDULED_POST_PLATFORMS = [
|
|
204
|
+
"tiktok",
|
|
205
|
+
"instagram",
|
|
206
|
+
"other",
|
|
207
|
+
] as const;
|
|
208
|
+
|
|
209
|
+
// Server bounds mirrored so the tool fails fast with a precise message instead of
|
|
210
|
+
// round-tripping to a 422 (ScheduledPost @max_hashtags / @max_hashtag_length +
|
|
211
|
+
// the caption validate_length). @max_pending_scheduled_posts is surfaced in the
|
|
212
|
+
// description, not enforced client-side (the count is server state).
|
|
213
|
+
export const MAX_CAPTION_LENGTH = 5000;
|
|
214
|
+
export const MAX_HASHTAGS = 30;
|
|
215
|
+
export const MAX_HASHTAG_LENGTH = 100;
|
|
216
|
+
export const MAX_PENDING_SCHEDULED_POSTS = 500;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Validates the schedule_post caption + hashtags against the ScheduledPost
|
|
220
|
+
* changeset bounds (caption ≤5000, ≤30 hashtags, each ≤100 chars). Returns an
|
|
221
|
+
* error string naming the exact bound, or null when valid. Kept in code (not
|
|
222
|
+
* Zod) so the flat tool schema stays TS2589-safe and the message is actionable.
|
|
223
|
+
*/
|
|
224
|
+
export function validateScheduledPostCopy(args: {
|
|
225
|
+
caption?: string;
|
|
226
|
+
hashtags?: string[];
|
|
227
|
+
}): string | null {
|
|
228
|
+
if (
|
|
229
|
+
typeof args.caption === "string" &&
|
|
230
|
+
args.caption.length > MAX_CAPTION_LENGTH
|
|
231
|
+
) {
|
|
232
|
+
return `caption must be at most ${MAX_CAPTION_LENGTH} characters.`;
|
|
233
|
+
}
|
|
234
|
+
if (args.hashtags !== undefined) {
|
|
235
|
+
if (args.hashtags.length > MAX_HASHTAGS) {
|
|
236
|
+
return `Provide at most ${MAX_HASHTAGS} hashtags (got ${args.hashtags.length}).`;
|
|
237
|
+
}
|
|
238
|
+
const tooLong = args.hashtags.find((h) => h.length > MAX_HASHTAG_LENGTH);
|
|
239
|
+
if (tooLong !== undefined) {
|
|
240
|
+
return `each hashtag must be at most ${MAX_HASHTAG_LENGTH} characters.`;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Builds the schedule_post create body from the tool args. REMINDER-ONLY: this
|
|
248
|
+
* NEVER threads a social-account id, so the resulting post is always a manual-
|
|
249
|
+
* posting reminder the WriteScope floor authorizes with video:generate. Drops
|
|
250
|
+
* undefined so the server sees a clean shape; blank strings are omitted.
|
|
251
|
+
*/
|
|
252
|
+
export function buildScheduledPostBody(args: {
|
|
253
|
+
video_result_id: number;
|
|
254
|
+
platform: string;
|
|
255
|
+
scheduled_at: string;
|
|
256
|
+
caption?: string;
|
|
257
|
+
hashtags?: string[];
|
|
258
|
+
timezone?: string;
|
|
259
|
+
campaign_pack_item_id?: number;
|
|
260
|
+
}): Record<string, unknown> {
|
|
261
|
+
const body: Record<string, unknown> = {
|
|
262
|
+
video_result_id: args.video_result_id,
|
|
263
|
+
platform: args.platform,
|
|
264
|
+
scheduled_at: args.scheduled_at,
|
|
265
|
+
};
|
|
266
|
+
const str = (v: string | undefined) =>
|
|
267
|
+
typeof v === "string" && v.trim() !== "" ? v : undefined;
|
|
268
|
+
const caption = str(args.caption);
|
|
269
|
+
if (caption !== undefined) body.caption = caption;
|
|
270
|
+
if (args.hashtags !== undefined) body.hashtags = args.hashtags;
|
|
271
|
+
const timezone = str(args.timezone);
|
|
272
|
+
if (timezone !== undefined) body.timezone = timezone;
|
|
273
|
+
if (args.campaign_pack_item_id !== undefined)
|
|
274
|
+
body.campaign_pack_item_id = args.campaign_pack_item_id;
|
|
275
|
+
return body;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** One scheduled post, trimmed to what an agent managing a calendar needs. */
|
|
279
|
+
export interface ScheduledPostSummary {
|
|
280
|
+
id: unknown;
|
|
281
|
+
platform: unknown;
|
|
282
|
+
status: unknown;
|
|
283
|
+
video_result_id: unknown;
|
|
284
|
+
video_url: unknown;
|
|
285
|
+
video_factory_kind: unknown;
|
|
286
|
+
campaign_pack_item_id: unknown;
|
|
287
|
+
caption: unknown;
|
|
288
|
+
hashtags: unknown;
|
|
289
|
+
scheduled_at: unknown;
|
|
290
|
+
timezone: unknown;
|
|
291
|
+
posted_at: unknown;
|
|
292
|
+
published_at: unknown;
|
|
293
|
+
error_message: unknown;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Summarizes one raw scheduled post (ScheduledPostController serializer) into the
|
|
298
|
+
* compact calendar shape. Deliberately DROPS `tiktok_account_id` /
|
|
299
|
+
* `instagram_account_id` / `video_post_id` / `platform_params`: these tools are
|
|
300
|
+
* reminder-only and must not surface (or invite acting on) the auto-publish
|
|
301
|
+
* account binding. `video_url` is the presigned export the user posts manually.
|
|
302
|
+
*/
|
|
303
|
+
export function summarizeScheduledPost(post: unknown): ScheduledPostSummary {
|
|
304
|
+
const p = asRecord(post);
|
|
305
|
+
return {
|
|
306
|
+
id: p.id,
|
|
307
|
+
platform: p.platform,
|
|
308
|
+
status: p.status,
|
|
309
|
+
video_result_id: p.video_result_id ?? null,
|
|
310
|
+
video_url: p.video_url ?? null,
|
|
311
|
+
video_factory_kind: p.video_factory_kind ?? null,
|
|
312
|
+
campaign_pack_item_id: p.campaign_pack_item_id ?? null,
|
|
313
|
+
caption: p.caption ?? null,
|
|
314
|
+
hashtags: p.hashtags ?? [],
|
|
315
|
+
scheduled_at: p.scheduled_at ?? null,
|
|
316
|
+
timezone: p.timezone ?? null,
|
|
317
|
+
posted_at: p.posted_at ?? null,
|
|
318
|
+
published_at: p.published_at ?? null,
|
|
319
|
+
error_message: p.error_message ?? null,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
// ── Calendar 403 (scope) message augmentation ─────────────────────────────────
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Augments a generic `insufficient_scope` (403) message for the reminder-only
|
|
327
|
+
* calendar writes. These tools never send a social-account id, so a scope
|
|
328
|
+
* refusal can only mean one of two things — spell BOTH out so the agent knows it
|
|
329
|
+
* isn't a tool bug and can't fix it by retrying:
|
|
330
|
+
*
|
|
331
|
+
* 1. the TARGETED post already carries a social account (an auto-publish set up
|
|
332
|
+
* in the app), so touching it needs `account:admin`; or
|
|
333
|
+
* 2. the token predates the reminder-calendar scope split (it lacks the write
|
|
334
|
+
* scope the pipeline floor now accepts for reminder-only calendar writes).
|
|
335
|
+
*
|
|
336
|
+
* Publishing stays human/app-only either way. Returns the original message
|
|
337
|
+
* unchanged for any non-403 / non-scope error so other failures read normally.
|
|
338
|
+
*/
|
|
339
|
+
export function calendarScopeErrorText(
|
|
340
|
+
status: number | undefined,
|
|
341
|
+
code: string | undefined,
|
|
342
|
+
message: string,
|
|
343
|
+
): string {
|
|
344
|
+
if (status !== 403 || code !== "insufficient_scope") return message;
|
|
345
|
+
return (
|
|
346
|
+
`${message} This calendar tool only creates manual-posting REMINDERS and never attaches a social ` +
|
|
347
|
+
"account, so this scope refusal means either the targeted post already references a social account " +
|
|
348
|
+
"(an app-only auto-publish that needs account:admin) or the token predates the reminder-calendar " +
|
|
349
|
+
"permission. Publishing stays in the app; reschedule/cancel/mark a reminder-only post instead."
|
|
350
|
+
);
|
|
351
|
+
}
|