hermoso 0.1.86 → 0.1.88
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 +21 -12
- package/package.json +2 -2
package/mcp/tools.mjs
CHANGED
|
@@ -1525,7 +1525,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
1525
1525
|
// empty description box and no tags. The caption cannot stand in for them — it becomes the video TITLE.
|
|
1526
1526
|
description: z.string().optional().describe('YOUTUBE — the video DESCRIPTION, max 5000 characters: the box under the video carrying the links, the CTA and everything YouTube search reads. It is NOT the caption — a scheduled YouTube item’s text becomes its TITLE — so omitting this publishes the video with an empty description.'),
|
|
1527
1527
|
tags: z.array(z.string()).optional().describe('YOUTUBE — up to 30 search tags for the video (plain words, no #).'),
|
|
1528
|
-
altText: z.string().optional().describe('ACCESSIBILITY
|
|
1528
|
+
altText: z.string().optional().describe('ACCESSIBILITY \u2014 the screen-reader description of the attached image. X (max 1000 characters), Pinterest (max 500), LinkedIn COMPANY PAGE posts (max 4086) and INSTAGRAM (max 1000) each attach it to the picture; the schedule is REFUSED if it is longer than the tightest of the channels on it, rather than truncated on the way out. Write one whenever the post carries an image: describe what is IN the picture, never a repeat of the caption, which a screen reader already reads. TWO CHANNELS CARRY NONE, and neither is refused \u2014 the post still publishes, just undescribed there: a LinkedIn post to a PERSONAL profile (LinkedIn\u2019s member posting API has no alt-text field at all), and an INSTAGRAM VIDEO/Reel (Meta support alt_text on image posts only). On an Instagram CAROUSEL the one description is applied to every image slide.'),
|
|
1529
1529
|
link: z.string().optional().describe('a link to attach (Facebook)'),
|
|
1530
1530
|
// ── PER-CHANNEL PUBLISH SETTINGS (server-side SCHED_META_FIELDS, 2026-08-03). Every one of these has been on
|
|
1531
1531
|
// the immediate publish helper since the day it was written — tiktokPost's disclosures and interaction
|
|
@@ -1602,7 +1602,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
1602
1602
|
title: z.string().optional().describe('PINTEREST / YOUTUBE — replace the headline; "" clears it and goes back to deriving one from the caption'),
|
|
1603
1603
|
description: z.string().optional().describe('YOUTUBE — replace the video description; "" clears it. Remember the caption is the TITLE, not the description.'),
|
|
1604
1604
|
tags: z.array(z.string()).optional().describe('YOUTUBE — replaces the WHOLE tag list; an empty array [] clears the tags.'),
|
|
1605
|
-
altText: z.string().optional().describe('ACCESSIBILITY
|
|
1605
|
+
altText: z.string().optional().describe('ACCESSIBILITY \u2014 replace the image\u2019s screen-reader description (used by X, Pinterest, LinkedIn company Pages and Instagram image posts); "" clears it.'),
|
|
1606
1606
|
link: z.string().optional(),
|
|
1607
1607
|
// The per-channel publish settings (SCHED_META_FIELDS), on the same omitted-means-keep rule as everything else
|
|
1608
1608
|
// here: send only what changes. A whole-record field (`event`, `offer`, `poll`, `thread`) REPLACES the record.
|
|
@@ -1652,12 +1652,18 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
1652
1652
|
// HTTP route and the in-app agent call.
|
|
1653
1653
|
server.registerTool('retry_scheduled', {
|
|
1654
1654
|
title: 'Retry a failed scheduled post',
|
|
1655
|
-
description: 'Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause:
|
|
1655
|
+
description: 'Send a post that FAILED again. A scheduled post fans out across its channels INDEPENDENTLY, so a failure is usually PARTIAL — LinkedIn 401s while Instagram published fine — and this re-fires ONLY the channels that did not succeed by default (list_scheduled reports them as `retryable`). It re-queues the same content as a NEW post that goes out RIGHT AWAY — the queue picks it up on its next pass, within seconds — and the original keeps its failure record so the history still shows what went wrong. Naming a channel that already published is REFUSED rather than quietly posting a second time. Two independent belts stop a double-post: a channel that genuinely published can only REPLAY (nothing is posted), and a channel whose outcome is UNRESOLVED — the platform timed out and may be holding the post — refuses with that reason instead of guessing. Retry after fixing the cause — and you can fix it IN THIS CALL: pass `boardId`, `pageId`, `linkedinOrganizationId`, `locationId`, `message` or `captions` to correct the value that failed, and the corrected post is re-validated exactly like a fresh schedule. That matters because the commonest cause is a field, not an outage: a Pin aimed at the wrong board fails identically however many times it is re-sent. Anything you do not name is copied from the original. To send the same thing again ON PURPOSE, use duplicate_scheduled.',
|
|
1656
1656
|
inputSchema: {
|
|
1657
1657
|
id: z.string().describe('the scheduled post id from list_scheduled'),
|
|
1658
1658
|
channels: z.array(z.enum(['facebook', 'instagram', 'threads', 'tiktok', 'youtube', 'linkedin', 'x', 'pinterest', 'google_business'])).optional().describe('retry only these channels (default: every channel that did not publish)'),
|
|
1659
1659
|
at: z.string().optional().describe('hold the retry until a later time — ISO timestamp or epoch milliseconds. Leave it out to retry immediately, which is almost always what you want. A time you name here must be at least a minute from now, exactly like any other scheduled post.'),
|
|
1660
1660
|
allowDuplicate: z.boolean().optional().describe('ONLY for a channel you have checked by hand and confirmed the post is genuinely NOT there. It bypasses the double-post protection and can publish a second public copy, so never set it to work around a refusal you have not investigated.'),
|
|
1661
|
+
boardId: z.string().optional().describe('CORRECT THE BOARD on retry — the Pinterest board the Pin goes on, from list_pinterest_boards. A Pin aimed at a board Pinterest refuses fails the same way on every retry until this is changed.'),
|
|
1662
|
+
pageId: z.string().optional().describe('CORRECT THE PAGE on retry — which connected Facebook Page (and its linked Instagram/Threads) publishes, from list_meta_pages.'),
|
|
1663
|
+
linkedinOrganizationId: z.string().optional().describe('CORRECT THE LINKEDIN AUTHOR on retry — the company Page id from list_linkedin_pages. Set it to an empty string to fall back to the personal profile.'),
|
|
1664
|
+
locationId: z.string().optional().describe('CORRECT THE LISTING on retry — which Google Business Profile location, e.g. "locations/123" from list_business_locations.'),
|
|
1665
|
+
message: z.string().optional().describe('CORRECT THE CAPTION on retry — use this when the original was refused for length or content. Anything not named here is copied from the original post.'),
|
|
1666
|
+
captions: z.record(z.string()).optional().describe('CORRECT ONE CHANNEL’S CAPTION on retry, e.g. { "x": "..." } when only that channel refused the text.'),
|
|
1661
1667
|
},
|
|
1662
1668
|
outputSchema: { id: z.string().optional(), at: z.string().optional(), channels: z.array(z.string()).optional(), retryOf: z.string().optional(), retrying: z.array(z.string()).optional(), alreadyPublished: z.array(z.string()).optional(), note: z.string().optional() },
|
|
1663
1669
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
@@ -7341,7 +7347,7 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
7341
7347
|
// leg priced by the same videoCostUsd the Models catalog quotes. Every lane SETTLES to the exact cost afterwards.
|
|
7342
7348
|
server.registerTool('clip_video', {
|
|
7343
7349
|
title: 'Clip a long video',
|
|
7344
|
-
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut → Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. ACCEPTS: (a) a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED) — the server pulls the video down itself; (b) a direct https .mp4/.mov/.webm; (c) a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source must be at least ~15s and under ~600MB; only the first ~40 minutes is analysed (the result reports truncated:true when it hits that). Cost: a ~7-credit hold, settled to the exact transcription + encode cost, plus the clip-selection model's tokens billed as their own small event. RETURNS clips[] — each with its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode — not a single video. SUBTITLES ARE BURNED IN BY DEFAULT — slim white CAPS, thin black outline, bottom safe band, no box and no plate — because short-form is watched on mute; pass captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL: each cue is anchored to the transcript's own per-sentence timestamp and split inside a sentence by character count, so it tracks the speech closely but is not frame-accurate sync — never promise that. Read captionsBurned back off the result: it counts the clips that actually carry a burned track, and captionNote says why any are bare.",
|
|
7350
|
+
description: "Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, conference talk, long ad cut → Reels/Shorts/TikTok). Transcribes the source with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, which is why it's fast and cheap. THE VERTICAL REFRAME IS SUBJECT-AWARE: a few stills per clip go to ONE cheap vision call, which decides a SINGLE crop offset that is held for that clip's whole length — so a speaker sitting camera-left is not cropped out of their own clip, while the framing still never drifts INSIDE a clip (a per-frame crop truncates to whole pixels and shimmers, so it is deliberately not tracked). It costs one small vision call per clip, billed as its own event. When nothing is being discarded, or no single subject can be located, the crop stays dead centre exactly as before — read `reframedToSubject` and each clip's `reframeWhy` back off the result rather than assuming either way. ACCEPTS: (a) a YouTube link (or Vimeo / Loom / Dailymotion / Streamable / Rumble / Wistia / Twitch / TED) — the server pulls the video down itself; (b) a direct https .mp4/.mov/.webm; (c) a Hermoso /generated/ URL (upload_file turns a local file into one). NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. Source must be at least ~15s and under ~600MB; only the first ~40 minutes is analysed (the result reports truncated:true when it hits that). Cost: a ~7-credit hold, settled to the exact transcription + encode cost, plus the clip-selection model's tokens billed as their own small event. RETURNS clips[] — each with its OWN served mp4 URL, title, hook, ready-to-post caption, 0-100 score and source timecode — not a single video. SUBTITLES ARE BURNED IN BY DEFAULT — slim white CAPS, thin black outline, bottom safe band, no box and no plate — because short-form is watched on mute; pass captions:false for clean footage. TIMING IS APPROXIMATE, NOT WORD-LEVEL: each cue is anchored to the transcript's own per-sentence timestamp and split inside a sentence by character count, so it tracks the speech closely but is not frame-accurate sync — never promise that. Read captionsBurned back off the result: it counts the clips that actually carry a burned track, and captionNote says why any are bare.",
|
|
7345
7351
|
inputSchema: {
|
|
7346
7352
|
video: z.string().describe('the long video to clip — a YouTube/Vimeo/Loom/Dailymotion/Streamable/Rumble/Wistia/Twitch/TED watch URL, a direct https .mp4/.mov/.webm, or a Hermoso /generated/ URL'),
|
|
7347
7353
|
count: z.number().optional().describe('how many clips to cut, 1-8 (default 4)'),
|
|
@@ -8193,16 +8199,19 @@ export function registerTools(rawServer, opts = {}) {
|
|
|
8193
8199
|
const hit = live.find(c => c.provider === provider);
|
|
8194
8200
|
if (!hit) return { content: [{ type: 'text', text: `Nothing connected for "${provider}". Connected: ${live.map(c => c.provider).join(', ') || '(none)'}.` }], isError: true };
|
|
8195
8201
|
// agentLabel, never accountLabel — the confirm sentence is model-visible text like any other (2026-08-04).
|
|
8196
|
-
// THE REVOKE CLAIM IS THE SERVER'S
|
|
8197
|
-
//
|
|
8198
|
-
//
|
|
8199
|
-
//
|
|
8200
|
-
//
|
|
8202
|
+
// THE REVOKE CLAIM IS THE SERVER'S, AND SINCE 2026-08-13 IT IS THE SAME FOR EVERY PROVIDER: a disconnect
|
|
8203
|
+
// removes THIS BRAND'S row and calls nothing at the provider. That is not a limitation to apologise for, it is
|
|
8204
|
+
// the point — an OAuth grant belongs to the (user, app) pair, so revoking from one brand would have killed the
|
|
8205
|
+
// same person's connection on every OTHER brand they had connected the account to. `removalNote` comes from
|
|
8206
|
+
// disconnectClaim() and names where the user can really revoke us. It is printed UNCONDITIONALLY on both the
|
|
8207
|
+
// confirm prompt and the result, because "this is brand-scoped" is a capability limit that stays true forever
|
|
8208
|
+
// rather than a per-provider quirk — the connector-card rule about desc-vs-setup copy, applied to an agent.
|
|
8201
8209
|
if (a.confirm !== true) return ok(`This disconnects ${hit.provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''} from this workspace: the stored credentials are deleted and every ${hit.provider} tool stops working until someone reconnects it IN A BROWSER — I can't do that step. ${hit.removalNote || ''} Already-published posts and running campaigns are untouched. Confirm with the user, then call again with confirm:true.`, { ok: false, provider, revokesAtProvider: hit.revokesAtProvider || null });
|
|
8202
|
-
// Straight through the app's own disconnect route → Connectors.remove()
|
|
8203
|
-
// (all
|
|
8210
|
+
// Straight through the app's own disconnect route → Connectors.remove(). The shared-grant guard that used to
|
|
8211
|
+
// matter here (all seven google_* connectors ride ONE OAuth client id, so a naive revoke killed the siblings)
|
|
8212
|
+
// is moot now that nothing revokes — the hazard is closed rather than guarded.
|
|
8204
8213
|
await apiPost(`/api/connectors/${encodeURIComponent(provider)}/disconnect`, {});
|
|
8205
|
-
return ok(`Disconnected ${provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''}
|
|
8214
|
+
return ok(`Disconnected ${provider}${hit.agentLabel ? ` (${hit.agentLabel})` : ''} from this brand.${hit.removalNote ? ` ${hit.removalNote}` : ''} Reconnect from the app: Workspace ▸ Connectors ▸ ${provider}.`, { ok: true, provider, disconnected: true, revokesAtProvider: hit.revokesAtProvider || null });
|
|
8206
8215
|
}));
|
|
8207
8216
|
// ── REMOVE ONLY WHAT I CONTRIBUTED (2026-08-11) ────────────────────────────────────────────────────────────────
|
|
8208
8217
|
// The web gained a "Remove my account" control the same day, and a WEB-ONLY capability is a defect
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.88",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI ad studio + marketing MCP (415 tools): build and manage ad campaigns on Meta, Google Ads, Reddit, X, TikTok, Snapchat, LinkedIn, Pinterest, Microsoft Advertising and ChatGPT Ads
|
|
5
|
+
"description": "AI ad studio + marketing MCP (415 tools): build and manage ad campaigns on Meta, Google Ads, Reddit, X, TikTok, Snapchat, LinkedIn, Pinterest, Microsoft Advertising and ChatGPT Ads \u2014 generate finished video, image and UGC avatar ads, publish them to Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn and Pinterest, spy on competitor ads across the Meta, Google and LinkedIn ad libraries plus organic TikTok/Instagram/YouTube/Reddit, and read what they achieved in Google Analytics 4. CLI and Claude skills for Hermoso, the AI ad studio: brand onboarding, 30+ image/video models, finished-ad pipeline (script, voiceover, music, brand end card), ad scoring and competitor teardowns.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|