@hubfluencer/mcp 0.4.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 +2757 -177
- 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 +3122 -144
- 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,470 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, side-effect-light helpers for the performance-loop, review-link, and
|
|
3
|
+
* asset-catalog tools (index.ts).
|
|
4
|
+
*
|
|
5
|
+
* Like core.ts / campaign.ts / series-calendar.ts, this lives outside index.ts
|
|
6
|
+
* on purpose: index.ts registers every tool and connects an stdio transport at
|
|
7
|
+
* module load, so a unit test can't import it. The request-shape builders, the
|
|
8
|
+
* metric/attribute bounds validators, the review-link create presentation (the
|
|
9
|
+
* plaintext token is shown ONCE), the response summarizers, and the
|
|
10
|
+
* subscription-gate (402) message augmentation are extracted here so they are
|
|
11
|
+
* testable against a fake client / plain inputs.
|
|
12
|
+
*
|
|
13
|
+
* GROUND TRUTH (verified against the API controllers + contexts, 2026-07-02):
|
|
14
|
+
* - creative_performance_controller.ex + performance_controller.ex (+ the
|
|
15
|
+
* Performance context / CreativePerformance schema),
|
|
16
|
+
* review_link_controller.ex (+ the Reviews context / ReviewLink schema),
|
|
17
|
+
* catalog_asset_controller.ex (+ the Assets context / CatalogAsset schema).
|
|
18
|
+
* - Performance + review controllers scope by the caller: a foreign/unknown
|
|
19
|
+
* campaign-pack item or review link is a generic 404 (no enumeration). Reads
|
|
20
|
+
* require video:read; writes require video:generate (agent tokens never carry
|
|
21
|
+
* account:admin). record_metrics / set_attributes / create-review-link spend
|
|
22
|
+
* NO video credits. make_more consumes the FREE daily AiAssistQuota (429
|
|
23
|
+
* ai_assist_quota_exceeded), like the campaign-pack planner, and spends no
|
|
24
|
+
* video credits.
|
|
25
|
+
* - NONE of these controllers mount `Plugs.Idempotency`, so these tools send NO
|
|
26
|
+
* Idempotency-Key. record_metrics is append-only (many snapshots per item),
|
|
27
|
+
* create-review-link mints a fresh token every call — so a transport retry of
|
|
28
|
+
* either FORKS a row; the tool `idempotentHint`s reflect that honestly. The
|
|
29
|
+
* one exception is set_attributes: it upserts the SINGLE attributes row (a
|
|
30
|
+
* second identical call converges to the same row), so it IS idempotent.
|
|
31
|
+
* - The CATALOG asset routes additionally mount `RequireSubscription`, which
|
|
32
|
+
* returns 402 `subscription_required` (NOT 403) when the user has no active
|
|
33
|
+
* subscription — surfaced verbatim so the agent knows it's a billing gate, not
|
|
34
|
+
* a tool bug it can retry.
|
|
35
|
+
* - CAUTION SEMANTICS: get_recommendations returns the server's aggregate
|
|
36
|
+
* UNCHANGED (confidence none/low/moderate, an honest note, never a causal/ROAS
|
|
37
|
+
* claim). This module NEVER re-derives, softens, or overstates it.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { asRecord } from "./core.js";
|
|
41
|
+
|
|
42
|
+
/** The narrow client surface these tools' helpers need — a subset of HubfluencerClient. */
|
|
43
|
+
export interface PerfReviewAssetsClient {
|
|
44
|
+
get<T = unknown>(
|
|
45
|
+
path: string,
|
|
46
|
+
query?: Record<string, string | number | undefined>,
|
|
47
|
+
): Promise<T>;
|
|
48
|
+
post<T = unknown>(
|
|
49
|
+
path: string,
|
|
50
|
+
body?: unknown,
|
|
51
|
+
idempotencyKey?: string,
|
|
52
|
+
): Promise<T>;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ── Performance: metric snapshot ──────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
// The platforms + sources a performance snapshot accepts
|
|
58
|
+
// (CreativePerformance.platforms/0 + sources/0). Mirrored so the flat tool schema
|
|
59
|
+
// can enum them without a server round-trip.
|
|
60
|
+
export const PERFORMANCE_PLATFORMS = ["tiktok", "instagram", "other"] as const;
|
|
61
|
+
export const PERFORMANCE_SOURCES = [
|
|
62
|
+
"manual",
|
|
63
|
+
"tiktok_api",
|
|
64
|
+
"instagram_api",
|
|
65
|
+
] as const;
|
|
66
|
+
|
|
67
|
+
// The non-negative integer engagement/conversion counts (CreativePerformance
|
|
68
|
+
// @count_fields + watch_time). Validated client-side so a bad value fails fast
|
|
69
|
+
// with a precise message instead of round-tripping to a 422.
|
|
70
|
+
const PERFORMANCE_COUNT_FIELDS = [
|
|
71
|
+
"views",
|
|
72
|
+
"likes",
|
|
73
|
+
"comments",
|
|
74
|
+
"shares",
|
|
75
|
+
"clicks",
|
|
76
|
+
"conversions",
|
|
77
|
+
"watch_time_seconds",
|
|
78
|
+
] as const;
|
|
79
|
+
|
|
80
|
+
// revenue is numeric(14,2): a value at/above 10^12 overflows the column (the
|
|
81
|
+
// changeset caps it at 999999999999.99). Mirror the ceiling so the tool rejects
|
|
82
|
+
// an over-magnitude value cleanly rather than eliciting the server 422.
|
|
83
|
+
const MAX_PERFORMANCE_REVENUE = 999_999_999_999.99;
|
|
84
|
+
|
|
85
|
+
export interface RecordPerformanceArgs {
|
|
86
|
+
views?: number;
|
|
87
|
+
likes?: number;
|
|
88
|
+
comments?: number;
|
|
89
|
+
shares?: number;
|
|
90
|
+
clicks?: number;
|
|
91
|
+
conversions?: number;
|
|
92
|
+
revenue?: number;
|
|
93
|
+
watch_time_seconds?: number;
|
|
94
|
+
platform: string;
|
|
95
|
+
source?: string;
|
|
96
|
+
captured_at?: string;
|
|
97
|
+
metadata?: Record<string, unknown>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Validates a performance snapshot against the CreativePerformance changeset
|
|
102
|
+
* bounds (counts + watch_time ≥0, revenue ≥0 and ≤~1 trillion). Platform/source
|
|
103
|
+
* membership is enforced by the tool's Zod enum, so it isn't re-checked here.
|
|
104
|
+
* Returns an error string naming the exact bound, or null when valid.
|
|
105
|
+
*/
|
|
106
|
+
export function validatePerformanceMetrics(
|
|
107
|
+
args: RecordPerformanceArgs,
|
|
108
|
+
): string | null {
|
|
109
|
+
for (const field of PERFORMANCE_COUNT_FIELDS) {
|
|
110
|
+
const v = args[field];
|
|
111
|
+
if (v === undefined) continue;
|
|
112
|
+
if (!Number.isFinite(v) || !Number.isInteger(v) || v < 0) {
|
|
113
|
+
return `${field} must be a non-negative integer.`;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
if (args.revenue !== undefined) {
|
|
117
|
+
if (!Number.isFinite(args.revenue) || args.revenue < 0) {
|
|
118
|
+
return "revenue must be a non-negative number.";
|
|
119
|
+
}
|
|
120
|
+
if (args.revenue > MAX_PERFORMANCE_REVENUE) {
|
|
121
|
+
return `revenue must be at most ${MAX_PERFORMANCE_REVENUE}.`;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Builds the record-performance request body from the tool args, dropping
|
|
129
|
+
* undefined so the server sees a clean shape. `captured_at` is REQUIRED by the
|
|
130
|
+
* changeset (it orders snapshots); when the caller omits it we default to the
|
|
131
|
+
* current instant so a bare `record_performance` still succeeds. The owner +
|
|
132
|
+
* campaign_pack_item_id are set server-side from the verified item, never here.
|
|
133
|
+
*/
|
|
134
|
+
export function buildPerformanceBody(
|
|
135
|
+
args: RecordPerformanceArgs,
|
|
136
|
+
now: () => Date = () => new Date(),
|
|
137
|
+
): Record<string, unknown> {
|
|
138
|
+
const body: Record<string, unknown> = { platform: args.platform };
|
|
139
|
+
for (const field of PERFORMANCE_COUNT_FIELDS) {
|
|
140
|
+
if (args[field] !== undefined) body[field] = args[field];
|
|
141
|
+
}
|
|
142
|
+
if (args.revenue !== undefined) body.revenue = args.revenue;
|
|
143
|
+
if (typeof args.source === "string" && args.source.trim() !== "")
|
|
144
|
+
body.source = args.source;
|
|
145
|
+
body.captured_at =
|
|
146
|
+
typeof args.captured_at === "string" && args.captured_at.trim() !== ""
|
|
147
|
+
? args.captured_at
|
|
148
|
+
: now().toISOString();
|
|
149
|
+
if (args.metadata !== undefined) body.metadata = args.metadata;
|
|
150
|
+
return body;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** One performance snapshot, trimmed to the fields an agent reads back. */
|
|
154
|
+
export interface PerformanceSnapshotSummary {
|
|
155
|
+
id: unknown;
|
|
156
|
+
campaign_pack_item_id: unknown;
|
|
157
|
+
platform: unknown;
|
|
158
|
+
views: unknown;
|
|
159
|
+
likes: unknown;
|
|
160
|
+
comments: unknown;
|
|
161
|
+
shares: unknown;
|
|
162
|
+
clicks: unknown;
|
|
163
|
+
conversions: unknown;
|
|
164
|
+
revenue: unknown;
|
|
165
|
+
watch_time_seconds: unknown;
|
|
166
|
+
source: unknown;
|
|
167
|
+
captured_at: unknown;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Summarizes one raw performance snapshot (CreativePerformanceController serializer). */
|
|
171
|
+
export function summarizePerformance(
|
|
172
|
+
snapshot: unknown,
|
|
173
|
+
): PerformanceSnapshotSummary {
|
|
174
|
+
const s = asRecord(snapshot);
|
|
175
|
+
return {
|
|
176
|
+
id: s.id,
|
|
177
|
+
campaign_pack_item_id: s.campaign_pack_item_id,
|
|
178
|
+
platform: s.platform,
|
|
179
|
+
views: s.views ?? null,
|
|
180
|
+
likes: s.likes ?? null,
|
|
181
|
+
comments: s.comments ?? null,
|
|
182
|
+
shares: s.shares ?? null,
|
|
183
|
+
clicks: s.clicks ?? null,
|
|
184
|
+
conversions: s.conversions ?? null,
|
|
185
|
+
revenue: s.revenue ?? null,
|
|
186
|
+
watch_time_seconds: s.watch_time_seconds ?? null,
|
|
187
|
+
source: s.source ?? null,
|
|
188
|
+
captured_at: s.captured_at ?? null,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ── Performance: creative attributes (single upserted row) ────────────────────
|
|
193
|
+
|
|
194
|
+
// The creative-attributes upsert accepts exactly these textual classifiers
|
|
195
|
+
// (creative_performance_controller.ex attribute_attrs). All free-form; the server
|
|
196
|
+
// stores one row per item and a repeat call updates it in place.
|
|
197
|
+
export const CREATIVE_ATTRIBUTE_FIELDS = [
|
|
198
|
+
"hook_type",
|
|
199
|
+
"creative_format",
|
|
200
|
+
"visual_language",
|
|
201
|
+
"product_category",
|
|
202
|
+
"cta",
|
|
203
|
+
"length",
|
|
204
|
+
"caption_style",
|
|
205
|
+
"metadata",
|
|
206
|
+
] as const;
|
|
207
|
+
|
|
208
|
+
/** Picks only the accepted creative-attribute fields from a raw args object. */
|
|
209
|
+
export function pickCreativeAttributeFields(
|
|
210
|
+
args: Record<string, unknown>,
|
|
211
|
+
): Record<string, unknown> {
|
|
212
|
+
const out: Record<string, unknown> = {};
|
|
213
|
+
for (const k of CREATIVE_ATTRIBUTE_FIELDS) {
|
|
214
|
+
if (args[k] !== undefined) out[k] = args[k];
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Review links ──────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
// The review-link expiry window (ReviewLink create; ReviewLinkCreateRequest
|
|
222
|
+
// schema). Mirrored so the tool describes + fails fast on the bound; the server
|
|
223
|
+
// clamps anyway (default 168h = 7 days).
|
|
224
|
+
export const REVIEW_LINK_MIN_EXPIRES_IN_HOURS = 1;
|
|
225
|
+
export const REVIEW_LINK_MAX_EXPIRES_IN_HOURS = 720;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Validates the create-review-link output reference: EXACTLY ONE of
|
|
229
|
+
* video_result_id / slider_id (the changeset requires exactly one on create).
|
|
230
|
+
* Returns an error string, or null when the pair is valid.
|
|
231
|
+
*/
|
|
232
|
+
export function validateReviewOutput(
|
|
233
|
+
videoResultId: number | undefined,
|
|
234
|
+
sliderId: number | undefined,
|
|
235
|
+
): string | null {
|
|
236
|
+
const hasVideo = typeof videoResultId === "number";
|
|
237
|
+
const hasSlider = typeof sliderId === "number";
|
|
238
|
+
if (hasVideo && hasSlider) {
|
|
239
|
+
return "Provide exactly one of video_result_id or slider_id, not both.";
|
|
240
|
+
}
|
|
241
|
+
if (!hasVideo && !hasSlider) {
|
|
242
|
+
return "Provide exactly one of video_result_id (a completed render) or slider_id (a completed carousel).";
|
|
243
|
+
}
|
|
244
|
+
return null;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Builds the create-review-link request body from the tool args, dropping
|
|
249
|
+
* undefined so the server sees a clean shape. `expires_in_hours` is passed
|
|
250
|
+
* through as-is (the server clamps 1..720); a blank version_label is omitted.
|
|
251
|
+
*/
|
|
252
|
+
export function buildReviewLinkBody(args: {
|
|
253
|
+
video_result_id?: number;
|
|
254
|
+
slider_id?: number;
|
|
255
|
+
campaign_pack_id?: number;
|
|
256
|
+
expires_in_hours?: number;
|
|
257
|
+
download_enabled?: boolean;
|
|
258
|
+
version_label?: string;
|
|
259
|
+
}): Record<string, unknown> {
|
|
260
|
+
const body: Record<string, unknown> = {};
|
|
261
|
+
if (args.video_result_id !== undefined)
|
|
262
|
+
body.video_result_id = args.video_result_id;
|
|
263
|
+
if (args.slider_id !== undefined) body.slider_id = args.slider_id;
|
|
264
|
+
if (args.campaign_pack_id !== undefined)
|
|
265
|
+
body.campaign_pack_id = args.campaign_pack_id;
|
|
266
|
+
if (args.expires_in_hours !== undefined)
|
|
267
|
+
body.expires_in_hours = args.expires_in_hours;
|
|
268
|
+
if (args.download_enabled !== undefined)
|
|
269
|
+
body.download_enabled = args.download_enabled;
|
|
270
|
+
if (
|
|
271
|
+
typeof args.version_label === "string" &&
|
|
272
|
+
args.version_label.trim() !== ""
|
|
273
|
+
)
|
|
274
|
+
body.version_label = args.version_label;
|
|
275
|
+
return body;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** One review link, trimmed to what an agent tracking a review needs. */
|
|
279
|
+
export interface ReviewLinkSummary {
|
|
280
|
+
id: unknown;
|
|
281
|
+
status: unknown;
|
|
282
|
+
output_kind: unknown;
|
|
283
|
+
video_result_id: unknown;
|
|
284
|
+
slider_id: unknown;
|
|
285
|
+
campaign_pack_id: unknown;
|
|
286
|
+
expires_at: unknown;
|
|
287
|
+
download_enabled: unknown;
|
|
288
|
+
version_label: unknown;
|
|
289
|
+
view_count: unknown;
|
|
290
|
+
viewed_at: unknown;
|
|
291
|
+
comment_count: unknown;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Summarizes one raw review link (ReviewLinkController serializer). NEVER carries
|
|
296
|
+
* a token (the serializer omits token_hash and the plaintext only ever appears in
|
|
297
|
+
* the create payload). `comment_count` is the visible-comment count the context
|
|
298
|
+
* annotates on list/show (null on the create response — no activity yet).
|
|
299
|
+
*/
|
|
300
|
+
export function summarizeReviewLink(link: unknown): ReviewLinkSummary {
|
|
301
|
+
const l = asRecord(link);
|
|
302
|
+
return {
|
|
303
|
+
id: l.id,
|
|
304
|
+
status: l.status,
|
|
305
|
+
output_kind: l.output_kind ?? null,
|
|
306
|
+
video_result_id: l.video_result_id ?? null,
|
|
307
|
+
slider_id: l.slider_id ?? null,
|
|
308
|
+
campaign_pack_id: l.campaign_pack_id ?? null,
|
|
309
|
+
expires_at: l.expires_at ?? null,
|
|
310
|
+
download_enabled: l.download_enabled ?? null,
|
|
311
|
+
version_label: l.version_label ?? null,
|
|
312
|
+
view_count: l.view_count ?? null,
|
|
313
|
+
viewed_at: l.viewed_at ?? null,
|
|
314
|
+
comment_count: l.comment_count ?? null,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export interface CreateReviewLinkResult {
|
|
319
|
+
review_link: ReviewLinkSummary;
|
|
320
|
+
/** The plaintext review token — shown ONCE; NOT retrievable again. */
|
|
321
|
+
token: unknown;
|
|
322
|
+
/** Relative public review path, e.g. /review/<token>. */
|
|
323
|
+
review_path: unknown;
|
|
324
|
+
/** The absolute public review URL on the web-app origin (else the path). */
|
|
325
|
+
review_url: string | null;
|
|
326
|
+
important: string;
|
|
327
|
+
next: string;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// The no-login reviewer page (`/review/<token>`) is a FRONT web-app route (Expo
|
|
331
|
+
// Router / Cloudflare Pages), NOT an API endpoint — the Phoenix API host serves a
|
|
332
|
+
// 404 there. So the absolute review URL must be built on the web-app origin, which
|
|
333
|
+
// is a DIFFERENT host from the API base (default API https://hubfluencer.com, web
|
|
334
|
+
// app https://app.hubfluencer.com). This mirrors the front's own
|
|
335
|
+
// REVIEW_WEB_ORIGIN_FALLBACK (types/review-link.ts) and the API's :web_app_origin
|
|
336
|
+
// default (callback_page_safety.ex). `HUBFLUENCER_WEB_URL` overrides it (e.g. a
|
|
337
|
+
// staging web origin) without touching HUBFLUENCER_BASE_URL, which stays the API.
|
|
338
|
+
const DEFAULT_WEB_APP_ORIGIN = "https://app.hubfluencer.com";
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The web-app origin that serves the public no-login pages (the reviewer page at
|
|
342
|
+
* `/review/<token>`). Read from `HUBFLUENCER_WEB_URL`, falling back to the
|
|
343
|
+
* canonical https://app.hubfluencer.com — NOT the API base (a different host).
|
|
344
|
+
*/
|
|
345
|
+
export function reviewWebOrigin(env: NodeJS.ProcessEnv = process.env): string {
|
|
346
|
+
const configured = env.HUBFLUENCER_WEB_URL;
|
|
347
|
+
if (typeof configured === "string" && configured.trim() !== "") {
|
|
348
|
+
return configured.trim().replace(/\/+$/, "");
|
|
349
|
+
}
|
|
350
|
+
return DEFAULT_WEB_APP_ORIGIN;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Shapes the create-review-link result so the ONCE-only token + the public review
|
|
355
|
+
* URL are front-and-centre and the caller is told it can't be fetched again. The
|
|
356
|
+
* server returns { review_link, token, review_path }; `webOrigin` (the web-app
|
|
357
|
+
* host that actually serves `/review/<token>`, NOT the API base) is used to
|
|
358
|
+
* present an absolute review_url the owner can hand to a reviewer, falling back to
|
|
359
|
+
* the relative path.
|
|
360
|
+
*/
|
|
361
|
+
export function shapeCreateReviewLink(
|
|
362
|
+
data: unknown,
|
|
363
|
+
webOrigin: string | undefined,
|
|
364
|
+
): CreateReviewLinkResult {
|
|
365
|
+
const d = asRecord(data);
|
|
366
|
+
const token = d.token ?? null;
|
|
367
|
+
const reviewPath = typeof d.review_path === "string" ? d.review_path : null;
|
|
368
|
+
const reviewUrl = absoluteReviewUrl(reviewPath, webOrigin);
|
|
369
|
+
return {
|
|
370
|
+
review_link: summarizeReviewLink(d.review_link),
|
|
371
|
+
token,
|
|
372
|
+
review_path: reviewPath,
|
|
373
|
+
review_url: reviewUrl,
|
|
374
|
+
important:
|
|
375
|
+
"SAVE THE TOKEN NOW — it is shown only ONCE and is NOT retrievable again. " +
|
|
376
|
+
"Only its hash is stored server-side; list_review_links and revoke_review_link never return it. " +
|
|
377
|
+
`Share the review URL${reviewUrl ? ` (${reviewUrl})` : ""} with your reviewer — they open it with no account.`,
|
|
378
|
+
next: "list_review_links to track approval/comment activity; revoke_review_link({ id }) to disable the URL.",
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Joins a relative review path to the web-app origin, when known, so the owner
|
|
384
|
+
* gets a copy-pasteable absolute URL. The public review page is served by the
|
|
385
|
+
* FRONT web app (`/review/<token>`), not the API host. Returns the relative path
|
|
386
|
+
* unchanged if the origin is absent or unparseable — never throws.
|
|
387
|
+
*/
|
|
388
|
+
export function absoluteReviewUrl(
|
|
389
|
+
reviewPath: string | null,
|
|
390
|
+
webOrigin: string | undefined,
|
|
391
|
+
): string | null {
|
|
392
|
+
if (!reviewPath) return null;
|
|
393
|
+
if (typeof webOrigin !== "string" || webOrigin.trim() === "")
|
|
394
|
+
return reviewPath;
|
|
395
|
+
try {
|
|
396
|
+
return new URL(reviewPath, webOrigin).toString();
|
|
397
|
+
} catch {
|
|
398
|
+
return reviewPath;
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// ── Asset catalog ─────────────────────────────────────────────────────────────
|
|
403
|
+
|
|
404
|
+
/** One catalog asset, trimmed to the fields an agent reuses across projects. */
|
|
405
|
+
export interface CatalogAssetSummary {
|
|
406
|
+
id: unknown;
|
|
407
|
+
filename: unknown;
|
|
408
|
+
media_type: unknown;
|
|
409
|
+
mime_type: unknown;
|
|
410
|
+
size_bytes: unknown;
|
|
411
|
+
description: unknown;
|
|
412
|
+
status: unknown;
|
|
413
|
+
asset_url: unknown;
|
|
414
|
+
inserted_at: unknown;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* Summarizes one raw catalog asset (CatalogAssetController render_asset) into the
|
|
419
|
+
* compact shape the asset tools return. `asset_url` is the presigned URL (image
|
|
420
|
+
* or video) the agent can reference; the image_meta/verified_* provenance fields
|
|
421
|
+
* are dropped as noise for the agent surface.
|
|
422
|
+
*/
|
|
423
|
+
export function summarizeCatalogAsset(asset: unknown): CatalogAssetSummary {
|
|
424
|
+
const a = asRecord(asset);
|
|
425
|
+
return {
|
|
426
|
+
id: a.id,
|
|
427
|
+
filename: a.filename ?? null,
|
|
428
|
+
media_type: a.media_type ?? null,
|
|
429
|
+
mime_type: a.mime_type ?? null,
|
|
430
|
+
size_bytes: a.size_bytes ?? null,
|
|
431
|
+
description: a.description ?? null,
|
|
432
|
+
status: a.status ?? null,
|
|
433
|
+
asset_url: a.asset_url ?? null,
|
|
434
|
+
inserted_at: a.inserted_at ?? null,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The storage quota block the catalog list/quota endpoints return. */
|
|
439
|
+
export function summarizeAssetQuota(quota: unknown): Record<string, unknown> {
|
|
440
|
+
const q = asRecord(quota);
|
|
441
|
+
return {
|
|
442
|
+
limit_bytes: q.limit_bytes ?? null,
|
|
443
|
+
used_bytes: q.used_bytes ?? null,
|
|
444
|
+
pending_bytes: q.pending_bytes ?? null,
|
|
445
|
+
reserved_bytes: q.reserved_bytes ?? null,
|
|
446
|
+
remaining_bytes: q.remaining_bytes ?? null,
|
|
447
|
+
};
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ── Subscription-gate (402) message augmentation ──────────────────────────────
|
|
451
|
+
|
|
452
|
+
/**
|
|
453
|
+
* Augments a `subscription_required` (402) message from the catalog asset routes
|
|
454
|
+
* (RequireSubscription plug). These routes gate the whole asset catalog behind an
|
|
455
|
+
* active subscription — a scope/credit retry can't fix it — so spell that out so
|
|
456
|
+
* the agent surfaces a clear "needs an active subscription" instead of a bare code
|
|
457
|
+
* and doesn't loop. Returns the original message unchanged for any other error.
|
|
458
|
+
*/
|
|
459
|
+
export function assetSubscriptionErrorText(
|
|
460
|
+
status: number | undefined,
|
|
461
|
+
code: string | undefined,
|
|
462
|
+
message: string,
|
|
463
|
+
): string {
|
|
464
|
+
if (status !== 402 || code !== "subscription_required") return message;
|
|
465
|
+
return (
|
|
466
|
+
`${message} The reusable asset catalog requires an ACTIVE SUBSCRIPTION; ` +
|
|
467
|
+
"this is a billing gate, not a scope or credit issue, so retrying won't help — " +
|
|
468
|
+
"subscribe in the app first. (Generating videos/carousels does not require this.)"
|
|
469
|
+
);
|
|
470
|
+
}
|