hermoso 0.1.472 → 0.1.474
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/mcp/tools.mjs +26 -11
- package/package.json +1 -1
package/mcp/tools.mjs
CHANGED
|
@@ -601,6 +601,7 @@ const wrap = (fn) => {
|
|
|
601
601
|
// A PUBLISH OUTCOME IS DATA (2026-10-25, fb_679fd3573ff0): "nothing was posted" and "could not confirm" both arrive
|
|
602
602
|
// as 502, so the hint follows the server's `remedy.posted`, never the status or the prose.
|
|
603
603
|
if (e?.remedy?.code === 'publish_not_sent') _hints.push({ do: 'call this tool again with the same arguments', why: 'nothing was posted: it failed before anything was sent to the platform, so a retry cannot double-post' });
|
|
604
|
+
else if (e?.remedy?.code === 'publish_refused') _hints.push(e.remedy.retryable === false ? { do: 'tell the user what the platform refused and what to change; do not send the same post again until it is changed', why: 'the platform answered that this post failed, so nothing is live, and the same post would be refused the same way' } : { do: 'call this tool again with the same arguments', why: 'the platform answered that this post failed, so nothing is live and a retry cannot double-post' });
|
|
604
605
|
else if (e?.remedy?.code === 'publish_unconfirmed' || e?.remedy?.code === 'publish_in_progress') _hints.push({ do: 'check the account for the post before anything else; do not retry with a new idempotency key', why: 'the publishing call did not answer cleanly, so the post may be live; allowDuplicate:true is the only way to post it again' });
|
|
605
606
|
}
|
|
606
607
|
// ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
|
|
@@ -2551,7 +2552,14 @@ const splitW = (destructiveHint, idempotentHint) => Object.freeze({ readOnlyHint
|
|
|
2551
2552
|
const splitOp = (name, set, params, ann, title, description, desc) => Object.freeze({ name, set: Object.freeze(set), params: Object.freeze(params), ann, title, description, ...(desc ? { desc: Object.freeze(desc) } : {}) });
|
|
2552
2553
|
const X_BILLED = ' Needs X connected; X bills per call, so it costs credits.';
|
|
2553
2554
|
const META_ACCT = ['account', 'pageId'];
|
|
2554
|
-
|
|
2555
|
+
// A MULTI-ACCOUNT CHANNEL'S OPERATIONS NAME THE ACCOUNT TOO (2026-10-30). withAccountArg gives every tool of a channel in
|
|
2556
|
+
// MULTI_ACCOUNT_CHANNELS an `account` argument at the registry, but an operation tool takes ONLY the params its op lists
|
|
2557
|
+
// (widgetSplitDef / widgetSplitHandler), so on a widget host every YouTube / X / Bluesky / Pinterest operation silently
|
|
2558
|
+
// lost it: a ChatGPT user with two channels connected could not say which one a playlist, caption or board change was
|
|
2559
|
+
// for. Appended here, once, for every op of an account-scoped tool, so a split added tomorrow inherits it.
|
|
2560
|
+
const withSplitAccounts = (m) => Object.fromEntries(Object.entries(m).map(([from, ops]) => [from, !accountScopedTool(from) ? ops
|
|
2561
|
+
: ops.map((o) => (o.params.some((p) => p.replace(/!$/, '') === 'account') ? o : splitOp(o.name, o.set, [...o.params, 'account'], o.ann, o.title, o.description, o.desc)))]));
|
|
2562
|
+
export const WIDGET_HOST_SPLITS = Object.freeze(withSplitAccounts({
|
|
2555
2563
|
moderate_meta_comment: [
|
|
2556
2564
|
splitOp('hide_meta_comment', { action: 'hide' }, ['commentId!', ...META_ACCT], splitW(true, true), 'Hide a Facebook or Instagram comment', 'Use this when a comment on the brand’s own Facebook or Instagram post should be hidden from the public. Reversible with unhide_meta_comment. Get commentId from list_meta_comments. Free.'),
|
|
2557
2565
|
splitOp('unhide_meta_comment', { action: 'unhide' }, ['commentId!', ...META_ACCT], splitW(true, true), 'Unhide a Facebook or Instagram comment', 'Use this when a comment the brand hid on its own Facebook or Instagram post should be visible again. Get commentId from list_meta_comments. Free.'),
|
|
@@ -2748,7 +2756,7 @@ export const WIDGET_HOST_SPLITS = Object.freeze({
|
|
|
2748
2756
|
splitOp('edit_pinterest_board_details', {}, ['boardId!', 'name', 'description'], splitW(true, true), 'Rename a Pinterest board or edit its description', 'Use this when a Pinterest board’s name or description should change. It overwrites the public text. Get boardId from list_pinterest_boards. 0 credits.', { name: 'the new board name' }),
|
|
2749
2757
|
splitOp('set_pinterest_board_visibility', {}, ['boardId!', 'privacy!'], splitW(true, true), 'Make a Pinterest board public or secret', 'Use this when a Pinterest board should become SECRET (hidden, with every Pin on it, from everyone but this account) or PUBLIC again. Say what SECRET hides and get a yes first. It is reversible, and the reply reports how many Pins were hidden. 0 credits.', { privacy: 'PUBLIC or SECRET; SECRET hides every Pin on the board from everyone but this account' }),
|
|
2750
2758
|
],
|
|
2751
|
-
});
|
|
2759
|
+
}));
|
|
2752
2760
|
// op tool name → the multi-op tool it comes from.
|
|
2753
2761
|
export const WIDGET_SPLIT_OF = Object.freeze(Object.fromEntries(Object.entries(WIDGET_HOST_SPLITS).flatMap(([from, ops]) => ops.map((o) => [o.name, from]))));
|
|
2754
2762
|
// Every name a widget host is never LISTED, whatever its scope. Pure, so the checks run it.
|
|
@@ -6295,6 +6303,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6295
6303
|
description: z.string().optional().describe('YOUTUBE — replace the video description; "" clears it and the caption is used.'),
|
|
6296
6304
|
tags: z.array(z.string()).optional().describe('YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags.'),
|
|
6297
6305
|
thumbnailUrl: z.string().optional().describe('YOUTUBE: replace the custom thumbnail; "" goes back to a frame of the video, "auto" to YouTube’s pick.'),
|
|
6306
|
+
paidProductPlacement: z.boolean().optional().describe('YOUTUBE: paid product placement (shows the disclosure) — false removes it'),
|
|
6307
|
+
brandPartner: z.string().optional().describe('YOUTUBE: sponsoring brand channel (UC… or @handle); "" removes it'),
|
|
6298
6308
|
// The same six the CREATE path gained — a field you can set at create and not at edit is a one-way door,
|
|
6299
6309
|
// and reschedule_post is how an agent corrects a queued post it got wrong.
|
|
6300
6310
|
replyControl: z.enum(['everyone', 'accounts_you_follow', 'mentioned_only', 'parent_post_author_only', 'followers_only']).optional().describe('THREADS ONLY — who may reply.'),
|
|
@@ -8410,13 +8420,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8410
8420
|
yourBrand: z.boolean().optional().describe('discloses that this promotes the creator’s own brand'),
|
|
8411
8421
|
platformCover: z.boolean().optional().describe('VIDEO COVER. Omit it (the default) and Hermoso sets the video\u2019s best frame \u2014 the same frame as its Library thumbnail \u2014 as the cover (TikTok video_cover_timestamp_ms, on a direct post \u2014 a draft takes no cover, you pick it in the TikTok app). true = send no cover and let the platform pick (usually the first frame). A cover you pass yourself always wins.'),
|
|
8412
8422
|
},
|
|
8413
|
-
outputSchema: { ok: z.boolean().optional(), publishId: z.string().optional(), status: z.string().optional(), destination: z.string().optional(), media: z.string().optional(), images: z.number().optional(), coverIndex: z.number().optional(), postId: z.string().nullable().optional(), url: z.string().nullable().optional(), account: z.string().nullable().optional(), pending: z.boolean().optional() },
|
|
8423
|
+
outputSchema: { ok: z.boolean().optional(), publishId: z.string().optional(), status: z.string().optional(), destination: z.string().optional(), media: z.string().optional(), images: z.number().optional(), coverIndex: z.number().optional(), postId: z.string().nullable().optional(), url: z.string().nullable().optional(), account: z.string().nullable().optional(), pending: z.boolean().optional(), mediaConformedNote: z.string().optional(), reencodedNote: z.string().optional() },
|
|
8414
8424
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
8415
8425
|
}, wrap(async (a) => {
|
|
8416
8426
|
const d = await apiPost('/api/tiktok/post', a);
|
|
8417
8427
|
const what = d.media === 'photo' ? (d.images > 1 ? `photo post (${d.images} slides)` : 'photo post') : 'video';
|
|
8418
|
-
if (d.destination === 'draft') return ok(`Sent the ${what} to TikTok${d.account ? ` on @${d.account}` : ''} — it's waiting in the TikTok app (inbox notification, or + ▸ drafts) for the user to finish and post.${d.pending ? ' TikTok was still processing when polling stopped; it usually lands within a minute.' : ''}`, d);
|
|
8419
|
-
return ok(`Posted the ${what} to TikTok${d.account ? ` as @${d.account}` : ''}${d.url ? ` — ${d.url}` : ''}.${d.pending ? ' TikTok was still processing when polling stopped — it normally appears within a minute or two. Do not post it again.' : ''}`, d);
|
|
8428
|
+
if (d.destination === 'draft') return ok(`Sent the ${what} to TikTok${d.account ? ` on @${d.account}` : ''} — it's waiting in the TikTok app (inbox notification, or + ▸ drafts) for the user to finish and post.${[d.mediaConformedNote, d.reencodedNote].filter(Boolean).map(n => ` ${n}`).join('')}${d.pending ? ' TikTok was still processing when polling stopped; it usually lands within a minute.' : ''}`, d);
|
|
8429
|
+
return ok(`Posted the ${what} to TikTok${d.account ? ` as @${d.account}` : ''}${d.url ? ` — ${d.url}` : ''}.${d.pending ? ' TikTok was still processing when polling stopped — it normally appears within a minute or two. Do not post it again.' : ''}${[d.mediaConformedNote, d.reencodedNote].filter(Boolean).map(n => ` ${n}`).join('')}`, d);
|
|
8420
8430
|
}));
|
|
8421
8431
|
// A publish TikTok was still PROCESSING when post_to_tiktok stopped polling came back `pending:true` with a publishId
|
|
8422
8432
|
// and the words "do not post it again", and nothing to follow it with: an agent asked call_tool for
|
|
@@ -8428,12 +8438,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8428
8438
|
publishId: z.string().describe('the publishId post_to_tiktok returned'),
|
|
8429
8439
|
account: z.string().optional().describe('which connected TikTok account the post went out on (@handle or id from list_connector_accounts) when the profile has more than one'),
|
|
8430
8440
|
},
|
|
8431
|
-
outputSchema: { publishId: z.string().optional(), status: z.string().optional(), done: z.boolean().optional(), failed: z.boolean().optional(), postId: z.string().nullable().optional(), failReason: z.string().nullable().optional(), uploadedBytes: z.number().nullable().optional() },
|
|
8441
|
+
outputSchema: { publishId: z.string().optional(), status: z.string().optional(), done: z.boolean().optional(), failed: z.boolean().optional(), postId: z.string().nullable().optional(), failReason: z.string().nullable().optional(), failMessage: z.string().optional(), uploadedBytes: z.number().nullable().optional() },
|
|
8432
8442
|
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
8433
8443
|
}, wrap(async (a) => {
|
|
8434
8444
|
const d = await apiGet('/api/tiktok/publish-status', { publishId: a.publishId, ...(a.account ? { account: a.account } : {}) });
|
|
8435
8445
|
if (d.done) return ok(`TikTok finished publishing it${d.postId ? ` (post id ${d.postId})` : ''}.`, d);
|
|
8436
|
-
if (d.failed) return ok(`TikTok did not publish it: ${d.failReason || 'no reason given'}.`, d);
|
|
8446
|
+
if (d.failed) return ok(d.failMessage || `TikTok did not publish it: ${d.failReason || 'no reason given'}.`, d);
|
|
8437
8447
|
return ok(`TikTok is still processing it (${d.status || 'no status yet'}). That is normal for a few minutes; check again shortly and do not post it again.`, d);
|
|
8438
8448
|
}));
|
|
8439
8449
|
server.group('channel_admin');
|
|
@@ -18556,6 +18566,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18556
18566
|
title: 'TikTok post insights',
|
|
18557
18567
|
description: 'Per-POST analytics for the connected TikTok account: views, reach, likes, comments, shares, saves, average watch time, full-watch rate, the RETENTION CURVE (share still watching at each second — the hook read), traffic sources (For You, Follow, Search, Sound, profile…), new followers, profile views, website clicks, lead submissions and the audience by country, gender and new-vs-returning. Newest posts first, up to 20 a page (cursor for more), or name videoIds. Data lags 24-48 hours. Needs the TikTok ACCOUNT authorization; an older one says which permission to re-authorize for. Read-only, free.',
|
|
18558
18568
|
inputSchema: {
|
|
18569
|
+
...MANAGE_BRAND,
|
|
18559
18570
|
videoIds: z.array(z.string()).optional().describe('post ids to read; omit for the newest posts'),
|
|
18560
18571
|
maxCount: z.number().optional().describe('1-20, default 20'),
|
|
18561
18572
|
cursor: z.string().optional().describe('pagination cursor from the previous reply, to fetch the next page'),
|
|
@@ -18564,7 +18575,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
18564
18575
|
outputSchema: { videos: z.array(z.any()).optional(), cursor: z.any().optional(), hasMore: z.boolean().optional(), note: z.string().optional() },
|
|
18565
18576
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
18566
18577
|
}, wrap(async (a) => {
|
|
18567
|
-
const
|
|
18578
|
+
const { brand, ...q } = a; // the brand scopes the call (?brandId=), it is not a query field of its own
|
|
18579
|
+
const d = await apiGet('/api/tiktok-account/videos', queryBrand(a, { ...q, videoIds: a.videoIds ? a.videoIds.join(',') : undefined }));
|
|
18568
18580
|
return ok(`${d.note}\n${(d.videos || []).map(v => `• ${v.itemId}: ${v.views ?? '?'} views, reach ${v.reach ?? '?'}, avg watch ${v.averageWatchSeconds ?? '?'}s, ${v.retentionAt3s ?? '?'}% still watching at 3s — ${(v.caption || '').slice(0, 60)}`).join('\n')}`, d);
|
|
18569
18581
|
}));
|
|
18570
18582
|
server.registerTool('tiktok_spark_recommendations', {
|
|
@@ -19884,7 +19896,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19884
19896
|
// which is how "we have no data on that post" becomes a confident "it got no engagement".
|
|
19885
19897
|
server.registerTool('linkedin_profile_followers', {
|
|
19886
19898
|
title: 'Followers of a personal LinkedIn profile',
|
|
19887
|
-
description: 'Follower count of the PERSONAL LinkedIn profile shared with this brand — the lifetime total and, with startDate / endDate, the new followers per day (LinkedIn\u2019s memberFollowersCount). This is the creator / founder growth number; Company Page followers are in linkedin_page_analytics. It needs a personal profile ticked in Manage accounts (list_connector_accounts provider linkedin) and the LinkedIn analytics authorization on the LinkedIn card — if the reply says re-authorize, say exactly that. Read-only, free.',
|
|
19899
|
+
description: 'Follower count of the PERSONAL LinkedIn profile shared with this brand — the lifetime total and, with startDate / endDate, the new followers per day (LinkedIn\u2019s memberFollowersCount). This is the creator / founder growth number; Company Page followers are in linkedin_page_analytics. It needs a personal profile ticked in Manage accounts (list_connector_accounts provider linkedin, then set_connector_accounts with its id to tick it) and the LinkedIn analytics authorization on the LinkedIn card — if the reply says re-authorize, say exactly that. Read-only, free.',
|
|
19888
19900
|
inputSchema: {
|
|
19889
19901
|
startDate: z.string().optional().describe('YYYY-MM-DD — include for a daily series'),
|
|
19890
19902
|
endDate: z.string().optional().describe('YYYY-MM-DD, exclusive, default today'),
|
|
@@ -22078,7 +22090,10 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22078
22090
|
const s = plan.style || {};
|
|
22079
22091
|
const how = [s.hookDevice ? `Open the way the reference opens: ${s.hookDevice}` : '', s.pacing ? `Pacing: ${s.pacing}` : '', s.narration ? `Narration: ${s.narration}` : ''].filter(Boolean).join('. ');
|
|
22080
22092
|
const captions = typeof a.captions === 'boolean' ? a.captions : !!(plan.reference && s.captions); // on only when the WATCHED reference burns subtitles (no-overlays law, 2026-10-26)
|
|
22081
|
-
|
|
22093
|
+
// female/male are shortcuts onto the default voices; any other name is a narration voice passed straight through,
|
|
22094
|
+
// exactly as make_explainer takes it (never rigid: presets are shortcuts, never the only door — enum-ledger.json).
|
|
22095
|
+
const _v = String(a.voice || s.voice || '').trim();
|
|
22096
|
+
const voice = /^male$/i.test(_v) ? (vo.male || 'George') : (!_v || /^female$/i.test(_v)) ? (vo.female || 'Sarah') : _v;
|
|
22082
22097
|
return {
|
|
22083
22098
|
topic: `Episode ${ep.episode} of ${n} in a faceless short-video series about "${topic}". This episode: ${ep.title}. ${ep.brief}${how ? ` ${how}.` : ''} No presenter on camera.`,
|
|
22084
22099
|
durationSeconds: plan.durationSeconds, aspectRatio: a.aspectRatio || s.aspectRatio || '9:16',
|
|
@@ -22105,7 +22120,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
22105
22120
|
aspectRatio: z.enum(['9:16', '16:9', '1:1']).optional().describe('default: the reference\'s shape, else 9:16'),
|
|
22106
22121
|
style: z.string().optional().describe('override the look in words (e.g. "flat 2D cartoon") instead of the reference\'s'),
|
|
22107
22122
|
captions: z.boolean().optional().describe('burn subtitles of every spoken word. Default: on only when the reference itself burns its spoken words as subtitles, otherwise off; nothing else is ever put on screen (no app screens, URLs, cards or stickers) unless the user asks'),
|
|
22108
|
-
voice: z.
|
|
22123
|
+
voice: z.string().optional().describe("narrator: 'female' or 'male' (default follows the reference), or any narration voice name make_explainer takes"),
|
|
22109
22124
|
format: z.enum(['explainer', 'faceless_channel']).optional().describe("renderer lane: 'explainer' (default; exact length) or 'faceless_channel' (2K, whole 10s blocks)"),
|
|
22110
22125
|
planOnly: z.boolean().optional().describe('write the episode plan and style card, render nothing (a small planning charge)'),
|
|
22111
22126
|
plan: z.any().optional().describe('the `plan` a planOnly call returned, to run it without planning again'),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.474",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1089 tools. No markup on models. Publishing, scheduling, ad campaign management, comments, DMs and analytics are free on every plan. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|