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.
Files changed (2) hide show
  1. package/mcp/tools.mjs +26 -11
  2. 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
- export const WIDGET_HOST_SPLITS = Object.freeze({
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 d = await apiGet('/api/tiktok-account/videos', { ...a, videoIds: a.videoIds ? a.videoIds.join(',') : undefined });
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
- const voice = (a.voice || s.voice) === 'male' ? (vo.male || 'George') : (vo.female || 'Sarah');
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.enum(['female', 'male']).optional().describe('narrator; default follows the reference'),
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.472",
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",