hermoso 0.1.273 → 0.1.279

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/client.mjs CHANGED
@@ -92,7 +92,11 @@ function headers(extra = {}) {
92
92
  // their own activity, which is why this decides bookkeeping and nothing else.
93
93
  if (ctx) h['x-hermoso-inproc'] = '1';
94
94
  if (process.env.EDGE_SECRET) h['x-edge-auth'] = process.env.EDGE_SECRET; // belt: in-process self-calls satisfy the edge shield even if the loopback exemption ever changes
95
- const tok = ctx?.token || TOKEN;
95
+ // THE SAME RULE FOR THE BEARER (2026-09-23). This read `ctx?.token || TOKEN`, so a hosted request whose ctx carried
96
+ // no token would have been sent with the SERVER process's own HERMOSO_TOKEN — an operator credential standing in for
97
+ // a customer's missing one, the exact class lib/operator-credentials.mjs exists to end. A remote ctx carries its own
98
+ // bearer or none; only stdio / the CLI, where the process IS the caller, reads the environment.
99
+ const tok = ctx ? (ctx.token || '') : TOKEN;
96
100
  if (tok) h.Authorization = `Bearer ${tok}`;
97
101
  return h;
98
102
  }
package/mcp/tools.mjs CHANGED
@@ -146,7 +146,7 @@ export const INDEPENDENCE = 'INDEPENDENT AREAS, NOT A PIPELINE — research, cre
146
146
  // from any text that returns). Apple Ads' required set depends on which key path is used, so its handler checks it.
147
147
  export const KEY_CONNECTORS = {
148
148
  stripe: { label: 'Stripe', route: 'stripe', fields: { apiKey: 'rs' }, how: 'a secret (sk_) or restricted (rk_) key from Stripe ▸ Developers ▸ API keys' },
149
- openai_ads: { label: 'ChatGPT Ads', route: 'openai-ads', fields: { apiKey: 'rs' }, how: 'an Advertiser API key from ChatGPT Ads Manager ▸ Settings ▸ API keys ▸ Create' },
149
+ openai_ads: { label: 'ChatGPT Ads', route: 'openai-ads', fields: { apiKey: 'rs' }, how: 'an Advertiser API key from ChatGPT Ads Manager ▸ Settings ▸ General ▸ API Keys ▸ Create New Key' },
150
150
  apple_ads: { label: 'Apple Ads', route: 'apple-ads', fields: { clientId: '', teamId: '', keyId: '', privateKey: 's', setupToken: 's', orgId: '' }, how: 'clientId, teamId and keyId (Apple Ads ▸ Account Settings ▸ API shows all three once a public key is saved there), plus privateKey for a key already registered with Apple, or setupToken from a first call with no fields, which generates the key pair' },
151
151
  bluesky: { label: 'Bluesky', route: 'bluesky', fields: { identifier: 'r', appPassword: 'rs', pds: '' }, how: 'the handle and an APP password from Bluesky ▸ Settings ▸ Privacy and Security ▸ App Passwords (pds only for a self-hosted server)' },
152
152
  telegram: { label: 'Telegram', route: 'telegram', fields: { token: 'rs' }, how: 'the bot token from @BotFather ▸ /mybots ▸ your bot ▸ API Token' },
@@ -450,7 +450,7 @@ const wrap = (fn) => {
450
450
  // Read from the STRUCTURED signal, exactly as the sentence above is — never from the prose.
451
451
  // META'S SECURITY HOLD (code 31/3858385): the server's sentence already carries the steps; this names the move
452
452
  // so an agent relays it instead of retrying or telling the user to reconnect. Read from the STRUCTURED field.
453
- if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security hold: as the Facebook profile that connected Hermoso, turn on two-factor authentication, then in Ads Manager open Billing and payments and click Start authentication (or facebook.com/accountquality if there is no button), then run the same call again', why: 'Meta refuses new or edited ads from that profile until it re-authenticates; the connection and permissions are fine and reconnecting with the same profile does not clear it' });
453
+ if (e?.metaAuthHold === true) _hints.push({ do: 'stop retrying; ask the user to clear Meta\u2019s security check: as the Facebook profile that connected Hermoso, open Ads Manager within 24 hours and follow the red banner, or if there is none create or slightly edit an ad in Ads Manager, wait for Verifying your edits, then follow the Fix errors prompt to the email verification; then run the same call again', why: 'Meta refuses new or edited ads from that profile until it re-authenticates; the connection and permissions are fine and reconnecting with the same profile does not clear it' });
454
454
  if (Number(e?.status) === 401 && e?.connector) _hints.push({ do: `have the user connect "${e.connector}" (Settings \u25b8 Connectors, or the one-click link in this message)`, why: `${e.connector} is not connected in this workspace, so this tool can only answer 401 until it is` });
455
455
  }
456
456
  // ── THE STRUCTURED ERROR MARKER (2026-08-26) ──────────────────────────────────────────────────────────
@@ -628,6 +628,23 @@ const HOOK_ATTR = {
628
628
  brand: z.string().optional().describe('WHICH BRAND this post belongs to — the id or exact name from list_brands (a workspace shared with you: its profile id). Use it whenever the account has more than one brand and you are not certain which one this connection is pinned to: it beats the pin for THIS CALL ONLY and changes nothing about the connection. A name that matches no brand, or two brands, is REFUSED and nothing is posted — never resolved to the pin, which is the account you were guarding against.'),
629
629
  hook: z.string().optional().describe('WHAT ANGLE THIS POST IS BUILT ON — the single most valuable field here, and the only moment it can ever be recorded. post_performance groups on it to answer "which hooks work", and it needs 5 posts sharing ONE hook before it will call anything a winner, so REUSE THE SAME WORDING across a campaign instead of rephrasing it every time. Best of all, pass a hook id from list_hooks (e.g. "direct_callout", "mid_problem", "before_after") — those fold onto a stable key however they are spelled, so a whole brand accumulates evidence on one row. Your own wording is fine too; it just only groups when you repeat it exactly. Omitting it means this post can never vote on which hook works.'),
630
630
  subject: z.string().optional().describe('WHAT THIS POST IS ABOUT — the product, feature, offer or theme (e.g. "winter coat", "free trial", "founder story"). The second grouping axis in post_performance. Same rule as hook: reuse the exact wording so posts about one subject land in one group.'),
631
+ // THE FORMAT AND THE IDEA (2026-09-23). The server's publish seam has recorded `recipe` since 2026-09-11, and no
632
+ // agent surface could send it, so every post published over MCP or the CLI said nothing about its format and
633
+ // post_performance's recipe axis returned no groups at all. Same spread, so every publish and schedule tool gains both.
634
+ recipe: z.string().optional().describe('the post\'s FORMAT id, e.g. "slideshow" or "imessage_chat" — post_performance groups by it, so reuse one id per format'),
635
+ ideaId: z.string().optional().describe('short id of the content-plan idea this post came from'),
636
+ };
637
+ // The format and idea WITHOUT `brand`, for the tools that already carry their own brand field (reschedule, duplicate):
638
+ // editing or copying a queued post can set or change them.
639
+ const POST_INTENT = { recipe: HOOK_ATTR.recipe, ideaId: HOOK_ATTR.ideaId };
640
+ // Bluesky and Telegram publish tools had no attribution fields at all; they get the short forms (the roster is re-sent
641
+ // on every request, so the long hook guidance is carried once per tool family, not per tool).
642
+ const INTENT_SHORT = { hook: z.string().optional().describe('the post\'s angle — a list_hooks id or your own wording, reused exactly'), subject: z.string().optional().describe('what the post is about'), ...POST_INTENT };
643
+ // Publish-safety pair for the two channels whose tools did not declare it (2026-09-23): their routes now go through the
644
+ // one publish seam, which replays an identical post rather than sending it twice unless the caller says otherwise.
645
+ const PUBLISH_SAFETY = {
646
+ idempotencyKey: z.string().optional().describe('any stable string: a repeat within 24h returns the original post instead of posting again'),
647
+ allowDuplicate: z.boolean().optional().describe('post it even though an identical post was just made'),
631
648
  };
632
649
  // A POST YOU CAN CREATE IN A BRAND MUST BE MANAGEABLE THERE (2026-09-21, measured on the hosted MCP). With the
633
650
  // connection pinned to a brand that has no X, `post_to_x {brand:'Hermoso'}` posted on Hermoso's X — and then
@@ -3200,6 +3217,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
3200
3217
  inputSchema: {
3201
3218
  account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one bluesky account connected (several and none named is refused by name, never guessed); omit when there is one."),
3202
3219
  brand: HOOK_ATTR.brand,
3220
+ ...INTENT_SHORT,
3221
+ ...PUBLISH_SAFETY,
3203
3222
  text: z.string().describe('The post, up to 300 characters / 3000 UTF-8 bytes.'),
3204
3223
  imageUrls: z.array(z.string()).optional().describe('Up to 4 public image URLs to attach. Cannot be combined with videoUrl.'),
3205
3224
  altText: z.union([z.string(), z.array(z.string())]).optional().describe('Alt text \u2014 an ARRAY, one per image in the same order, or a single STRING to describe every image with it. WRITE ONE: Bluesky\u2019s own lexicon makes `alt` a REQUIRED property of every image, so a post without it is undescribed by design rather than by omission, and Bluesky users expect it. No maximum length is published, so nothing is truncated.'),
@@ -3260,6 +3279,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
3260
3279
  inputSchema: {
3261
3280
  account: z.string().optional().describe("WHICH connected account of this channel to post as — its @handle or id from list_connector_accounts. Needed only when the brand has more than one telegram account connected (several and none named is refused by name, never guessed); omit when there is one."),
3262
3281
  brand: HOOK_ATTR.brand,
3282
+ ...INTENT_SHORT,
3283
+ ...PUBLISH_SAFETY,
3263
3284
  chatId: z.string().describe("REQUIRED — the destination: a public channel's @username, or the numeric chat id. Never guessed; ask the user, or use list_telegram_chats."),
3264
3285
  text: z.string().optional().describe('the message. ≤4096 characters on its own; ≤1024 once any image or video is attached.'),
3265
3286
  imageUrl: z.string().optional().describe('one image (≤10MB after upload)'),
@@ -4976,6 +4997,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4976
4997
  description: 'Change a post that is still QUEUED — move it to a different time, rewrite the caption, swap the media, add or drop a channel, or change which board / Page / company Page / listing it goes to. PASS ONLY WHAT CHANGES: an omitted field is left exactly as it was, and an explicit empty string CLEARS one (linkedinOrganizationId:"" moves a company-Page post back to the person\u2019s own profile). The edited item is re-checked against the identical rules its create passed — visibility the channel can honour, per-channel length, media the channel can carry — so an edit can never slip past a refusal that a create would have caught. Get the id from list_scheduled. Something that already went out cannot be changed: a published post is edited or removed with manage_meta_post / manage_linkedin_post / delete_x_post, not rescheduled.',
4977
4998
  inputSchema: {
4978
4999
  brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
5000
+ ...POST_INTENT,
4979
5001
  id: z.string().describe('the scheduled post id from list_scheduled'),
4980
5002
  at: z.string().optional().describe('the new time — ISO timestamp (2026-08-05T09:00:00Z) or epoch milliseconds. Must be in the future, at most 365 days out.'),
4981
5003
  message: z.string().optional().describe('replace the caption used for every channel that has no override'),
@@ -5109,6 +5131,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5109
5131
  inputSchema: {
5110
5132
  brand: z.string().optional().describe('WHICH BRAND this post is in — the id or exact name from list_brands. Needed when the post lives in a brand this connection is not pinned to: a post you can CREATE in a brand must be manageable there too, without switching the whole connection. A name that matches no brand, or two, is REFUSED.'),
5111
5133
  id: z.string().describe('the post to copy, from list_scheduled'),
5134
+ ...POST_INTENT, // the copy inherits the original's format and idea (and hook and subject); pass either to change it
5112
5135
  at: z.string().optional().describe('when the copy goes out — ISO timestamp or epoch milliseconds (default: an hour from now)'),
5113
5136
  useQueue: z.boolean().optional().describe('instead of naming a time, take the brand’s next free posting slot'),
5114
5137
  timezone: z.string().optional().describe('IANA zone for the queue, e.g. "America/New_York"'),
@@ -17186,7 +17209,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17186
17209
 
17187
17210
  server.registerTool('make_template_ad', {
17188
17211
  title: 'Make template ad',
17189
- description: "Render a NATIVE-STYLE TEMPLATE ad from pure HTML — no AI video/image model in the loop, renders in ~30 seconds for a couple of credits. Perfect for native-feel social ads at volume. YOU author the content (short, casual, believable — never marketing-speak). Templates (pass as config.template): 'imessage-chat' (VIDEO ~15s: a real-looking iMessage thread where a friend reveals the product as a rich-link card; config: { thread: { contactName, messages: [{from:'them'|'me', text?, product?:{image,title,domain}}] }, theme?:'dark'|'light', endCard:{headline,cta,domain,logo?,color} } — 4-6 short lowercase bubbles, product card mid-thread from 'me', 1-2 excited replies after); 'chatgpt-chat' (VIDEO: a ChatGPT answer streams the punchline; config: { question, answer (may **bold** the brand), productImage?, endCard }); 'apple-notes' (VIDEO: an iPhone note types itself out; config: { title, lines: string[], theme?, endCard }); 'value-prop' (VIDEO ~17s kinetic typography: config: { hook (≤40 chars), claims: string[] (3-5 COMPLETE phrases, ≤6 words / ≤34 chars each — a finished thought, NEVER a clipped clause like 'Looks good on any'), productImages: string[] (2-3 DISTINCT photos — one rotates per card), palette: string[], endCard }); 'static-mockup' (IMAGE: config: { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }); 'airdrop-carousel' (VIDEO ~10s: an iOS AirDrop share card springs up and cycles 3-16 REAL product photos to a full-lineup payoff; config: { brandName, products: [{image, title?}], contactLine?, endCard }); 'app-ui-tour' (VIDEO ~12-16s for APP brands: floating-iPhone mockup walks through REAL app screenshots with kinetic captions; config: { hook?, appName, iconImage?, beats: [{screenImage, caption}] (2-6), palette?, fontStack?, endCard }); 'imessage-cascade' (VIDEO ~12s: iOS notification banners spring in and stack over a blurred backdrop; config: { notifications: [{sender, text}] (4-8), backgroundImage?, endCard }); 'photo-grid' (VIDEO ~8s: collage assembles real photos one at a time; config: { title?, photos: [{image, label?}] (4-9), palette?, fontStack?, endCard }); 'vignette' (VIDEO ~12s: cinematic Ken-Burns hero film; config: { hook, lines: [2-4 ≤40ch], heroImage, palette?, fontStack?, endCard }); 'kinetic-type' (VIDEO ~9-15s typographic motion design with NO VOICEOVER — it is NOT a silent asset: it always carries its own synthesised SFX (whoosh/tick/chime) and, once a curated track is on file, the family's loudest music bed at -16 LUFS; config.music:'off' silences the bed but never the SFX: 3-6 short phrases each land word by word on a full-bleed brand card (product beats caption the phrase over the photo instead), the longest word picked out in the brand accent, and a skewed accent slab wipes every cut; supply productImages and every OTHER beat becomes a full-bleed product shot with its phrase captioned over it — with none it renders as pure typography, so it needs NO photos; config: { phrases: string[] (3-6, ≤34 chars each — punchy, declarative, ONE idea per phrase, a finished thought never a clipped clause), productImages?: string[] (up to 4 DISTINCT photos), palette?: string[], fontStack?, endCard }); 'myth-vs-fact' (VIDEO ~15-26s VO-FIRST kinetic explainer with a real VOICEOVER — the family's ONE paid-audio format: a calm-authority read busts 2-4 myths, each MYTH line slamming in with a red per-line strike then the counter FACT line landing bold+affirmative, word-level KARAOKE lighting each word as the VO speaks it; config: { pairs: [{ myth (≤50ch, the common wrong belief), fact (≤60ch, the corrective truth — wrap its payoff phrase in [brackets] to accent it) }] (2-4), palette?, fontStack?, endCard }. Real product truths only — NEVER invent stats. Costs the flat template credits PLUS a small voiceover charge); 'carousel' (MULTI-IMAGE: 5-10 branded 1080×1080 PNG slides for Meta/LinkedIn/IG carousels — returns an images[] array, one PNG per slide; config: { cover: { hook?, title }, slides: [{ headline (≤8 words), support? (≤16 words), stat?: { value, label } }] (3-8; a stat slide is a REAL user-supplied number like '94%' or '40k+' + a label, never invented), cta: { headline, cta?, domain? }, productImage?, logo?, palette?, fontStack?, endCardColor? }). Every VIDEO format except myth-vs-fact (VO-first, deliberately dry) also gets a mood-matched MUSIC BED when a curated track is on file (the library ships empty — no track means no bed, never a paid generation) under its own SFX, from the curated library — free, no model, no extra credits; set config.music:'off' for a silent cut or a mood name (upbeat/calm/warm/epic/tense/playful/elegant/hype/chill/dramatic) to re-mood it. Image URLs may be any public URL — the server localizes them. Spends a couple of credits.",
17212
+ description: "Render a NATIVE-STYLE TEMPLATE ad or organic post from pure HTML: no AI model, about 30 seconds, a couple of credits. YOU author the copy: short, casual, believable, never marketing-speak, every line a finished phrase within its budget. Pass config.template plus its fields. 'slideshow' (IMAGES: a native photo slideshow for TikTok photo mode / Reels at 1080x1920, or feed carousels with size:'4:5' at 1080x1350; no branding, no end card): { slides:[{text, sub?, image?, blur?, background?:'#hex', position?}] (2-35; slide 1 is the hook, then one point per slide; the words are never rewritten), style?:'tiktok-classic'|'clean-minimal'|'note-style', textStyle? (add_subtitles' vocabulary), video?:true (also an MP4, about 2.5s a slide, for Shorts / X) }; returns images[] (+video) that post_to_tiktok imageUrls and post_to_meta carousels take as-is; 2 credits, +1 per slide past 5, +2 for the MP4. 'imessage-chat' (VIDEO ~15s): { thread:{contactName, messages:[{from:'them'|'me', text?, product?:{image,title,domain}}]}, theme?, endCard }, 4-6 short lowercase bubbles, the product card from 'me' mid-thread. 'chatgpt-chat' (VIDEO): { question, answer (may **bold** the brand), productImage?, endCard }. 'apple-notes' (VIDEO): { title, lines[], theme?, endCard }. 'value-prop' (VIDEO ~17s): { hook (≤40ch), claims[3-5 finished phrases ≤34ch], productImages[2-3 distinct], palette[], endCard }. 'static-mockup' (IMAGE): { style:'imessage'|'notes'|'card', size?:{w,h}, ...style fields }. 'airdrop-carousel' (VIDEO ~10s): { brandName, products:[{image, title?}] (3-16 real photos), contactLine?, endCard }. 'app-ui-tour' (VIDEO, app brands): { hook?, appName, iconImage?, beats:[{screenImage, caption}] (2-6), endCard }. 'imessage-cascade' (VIDEO ~12s): { notifications:[{sender, text}] (4-8), backgroundImage?, endCard }. 'photo-grid' (VIDEO ~8s): { title?, photos:[{image, label?}] (4-9), endCard }. 'vignette' (VIDEO ~12s): { hook, lines[2-4 ≤40ch], heroImage, endCard }. 'kinetic-type' (VIDEO 9-15s, no voiceover, its own SFX): { phrases[3-6 ≤34ch, one idea each], productImages?[≤4], endCard }, pure typography when there are no photos. 'myth-vs-fact' (VIDEO 15-26s with a real VOICEOVER and word karaoke): { pairs:[{myth ≤50ch, fact ≤60ch, [brackets] accent the payoff}] (2-4), endCard }, real product truths only, never invented stats, plus a small voiceover charge. 'carousel' (IMAGES: 5-10 branded 1080x1080 PNGs): { cover:{hook?, title}, slides:[{headline ≤8 words, support? ≤16 words, stat?:{value, label}}] (3-8; a stat is a real user number), cta:{headline, cta?, domain?}, productImage?, logo? }. endCard = { headline, cta, domain?, logo?, color? }; palette and fontStack are optional everywhere. Every VIDEO format except myth-vs-fact gets a mood-matched bed from the curated library when one is on file (free, never a generated track; config.music:'off' or a mood name). Image URLs may be any public URL.",
17190
17213
  inputSchema: {
17191
17214
  config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
17192
17215
  },
@@ -17198,7 +17221,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
17198
17221
  if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
17199
17222
  const urls = r.raw.images.map((u) => abs(u));
17200
17223
  const first = await imageBlock(urls[0]).catch(() => null);
17201
- return { content: [{ type: 'text', text: `Carousel ready — ${urls.length} slides:\n${urls.map((u, i) => ` ${i + 1}. ${u}`).join('\n')} [job ${r.jobId}]` }, ...(first ? [first] : [])], structuredContent: r ?? {} };
17224
+ const vid = r.raw.video ? `\nVideo version (${Math.round(r.raw.durationSeconds || 0)}s): ${abs(r.raw.video)}` : '';
17225
+ const notes = Array.isArray(r.raw.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.join('; ')}` : '';
17226
+ return { content: [{ type: 'text', text: `${String(a.config?.template || '') === 'slideshow' ? 'Slideshow' : 'Carousel'} ready — ${urls.length} slides:\n${urls.map((u, i) => ` ${i + 1}. ${u}`).join('\n')}${vid}${notes} [job ${r.jobId}]` }, ...(first ? [first] : [])], structuredContent: r ?? {} };
17202
17227
  }
17203
17228
  if (r?.raw?.image || /\.png($|\?)/.test(r?.url || '')) { const img = r?.url ? await imageBlock(r.url) : null; return { content: [{ type: 'text', text: `Template ad ready: ${r.url} [job ${r.jobId}]` }, ...(img ? [img] : [])], structuredContent: r ?? {} }; }
17204
17229
  return okVideo(`Template ad ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
@@ -17226,19 +17251,24 @@ function buildTools(rawServer, opts = {}, sink = null) {
17226
17251
 
17227
17252
  server.registerTool('post_edit', {
17228
17253
  title: 'Post-production edit',
17229
- description: "MECHANICAL post-production on an EXISTING rendered video (its served mp4 URL) — an ordered plan of whitelisted primitives executed by ffmpeg (+ Chrome for typeset cards) in seconds for ~2 credits flat, NO AI model, the original untouched (returns a NEW video). The lane for: append a branded end card ('add an end card with our logo and website' — ADDS its seconds, never re-renders), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, corner logo watermark, anti-AI film grain. Up to 6 ops per plan, applied in order. Brand assets (name/domain/logo/accent) load from the workspace brand automatically; override per-call if needed. NEVER use generate_video/render_ad for these mechanical asks.",
17254
+ description: "MECHANICAL post-production on an EXISTING video (its URL): an ordered plan of whitelisted primitives run by ffmpeg (+ Chrome for type) in seconds for ~2 credits flat, NO AI model, the original untouched (returns a NEW video). Ops: a branded end card (ADDS its seconds, never re-renders), trim, speed (0.5-2x), mute (whole or a window), audio_gain (-20..+6 dB), fade_out, watermark (corner logo), grain (anti-AI), text (timed words over the clip in a native look, no branding: style 'tiktok-classic' default / 'clean-minimal' / 'note-style' or a textStyle, position, start/end), join (this video FOLLOWED BY clips[], each a Library URL, a direct file or a public TikTok / Reel / Facebook / X / YouTube post link, as one 1080x1920 video with matched loudness; transition 'cut' or 'crossfade'). Up to 6 ops, in order. 'A viral hook, then our clip' = videoUrl: the hook's post link + [{op:'join', clips:[{url: ours}]}]. Brand assets load from the workspace brand. NEVER use generate_video/render_ad for these.",
17230
17255
  inputSchema: {
17231
- videoUrl: z.string().describe('the served URL of the video to edit'),
17256
+ videoUrl: z.string().describe('the video to edit: a render / Library URL, a direct file, or a public post link'),
17232
17257
  ops: z.array(z.object({
17233
- op: z.enum(['trim', 'speed', 'mute', 'audio_gain', 'fade_out', 'append_card', 'watermark', 'grain']),
17234
- start: z.number().optional().describe('trim/mute window start (s)'),
17235
- end: z.number().optional().describe('trim/mute window end (s)'),
17258
+ op: z.enum(['trim', 'speed', 'mute', 'audio_gain', 'fade_out', 'append_card', 'watermark', 'grain', 'text', 'join']),
17259
+ start: z.number().optional().describe('trim/mute/text window start (s)'),
17260
+ end: z.number().optional().describe('trim/mute/text window end (s)'),
17261
+ text: z.string().optional().describe('text: the words, verbatim'),
17262
+ position: z.enum(['top', 'center', 'lower', 'bottom']).optional().describe('text: where'),
17263
+ style: z.union([z.string(), z.object({}).passthrough()]).optional().describe('text: a look name or a textStyle'),
17264
+ clips: z.array(z.object({ url: z.string(), start: z.number().optional(), end: z.number().optional() })).optional().describe('join: the clips after this video'),
17265
+ transition: z.enum(['cut', 'crossfade']).optional().describe('join'),
17236
17266
  factor: z.number().optional().describe('speed 0.5-2'),
17237
17267
  db: z.number().optional().describe('audio_gain -20..+6 dB'),
17238
- seconds: z.number().optional().describe('fade_out 0.3-3s / append_card 2-5s'),
17268
+ seconds: z.number().optional().describe('fade_out 0.3-3s / append_card 2-5s / crossfade 0.2-1.5s'),
17239
17269
  headline: z.string().optional().describe('append_card: big line (defaults to the brand name)'),
17240
17270
  tagline: z.string().optional().describe('append_card: smaller line under the headline'),
17241
- sub: z.string().optional().describe('append_card: the pill line (defaults to the brand website)'),
17271
+ sub: z.string().optional().describe('append_card: the pill line (defaults to the website) / text: a smaller second line'),
17242
17272
  background: z.string().optional().describe("append_card: card background — hex or a color name ('red', 'navy'…); the user's stated color always wins over the brand palette"),
17243
17273
  card_html: z.string().optional().describe('append_card: your OWN full-frame card design as inline-styled HTML ({{logo}} inserts the real brand logo) — use when the standard layout cannot honor the request'),
17244
17274
  corner: z.enum(['tl', 'tr', 'bl', 'br']).optional().describe('watermark corner (default br)'),
@@ -17254,7 +17284,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17254
17284
  let b = await readStore('heist.brand.v1'); if (!b || typeof b !== 'object') b = {}; // via /api/store/bootstrap — there is no GET /api/store/:key route
17255
17285
  const pal = (Array.isArray(b.palette) ? b.palette : []).filter(c => /^#[0-9a-f]{6}$/i.test(String(c || '')));
17256
17286
  const r = await renderJob('postedit', { videoUrl: a.videoUrl, ops: (a.ops || []).slice(0, 6), brandName: a.brandName || b.name || '', domain: a.domain || b.domain || '', logo: b.logo || '', accent: a.accent || pal[0] || '' }, 'MCP post edit');
17257
- return okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.applied) ? ` (${r.raw.applied.join(', ')})` : ''} [job ${r.jobId}]`, r);
17287
+ return okVideo(`Edited video ready: ${r.url}${Array.isArray(r?.raw?.applied) ? ` (${r.raw.applied.join(', ')})` : ''}${Array.isArray(r?.raw?.notes) && r.raw.notes.length ? `\nNOTE: ${r.raw.notes.join('; ')}` : ''} [job ${r.jobId}]`, r);
17258
17288
  }));
17259
17289
 
17260
17290
  server.registerTool('fix_beat', {
@@ -19952,24 +19982,25 @@ function memoryNoteVerdict(text) {
19952
19982
 
19953
19983
  server.registerTool('list_published_posts', {
19954
19984
  title: 'List what this brand has published',
19955
- description: "List every post Hermoso has recorded publishing for this brand — channel, permalink, caption, format, the HOOK and SUBJECT it was written to, and its measured engagement. This is the brand's own publishing history across all nine channels in one place, and it is the memory that makes 'which hook worked?' answerable at all. Each row says how it was recorded: 'captured' (written at publish time — the hook is what the author actually intended) or 'backfilled' (reconstructed from the platform afterwards, where the hook is only known if the post matched a Hermoso creation). A dash for engagement means the platform reported no number — that is NOT zero engagement. Read-only, 0 credits.",
19985
+ description: "List every post Hermoso has recorded publishing for this brand, newest first, across all channels — channel, permalink, caption, format, the HOOK and SUBJECT it was written to, and its measured engagement. The WHOLE history, no cap: pass the reply's nextCursor as `cursor` for older posts. Each row says how it was recorded: 'captured' (written at publish time — the hook is what the author intended) or 'backfilled' (reconstructed from the platform afterwards). A dash for engagement means the platform reported no number — NOT zero. Read-only, 0 credits.",
19956
19986
  inputSchema: {
19957
19987
  ...MANAGE_BRAND,
19958
19988
  channel: z.string().optional().describe('filter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest, google_business'),
19959
19989
  limit: z.number().optional().describe('max posts (default 50, max 200), newest first'),
19990
+ cursor: z.string().optional().describe('nextCursor from a previous reply: the next, older page'),
19960
19991
  },
19961
- outputSchema: { posts: z.array(z.any()).optional(), total: z.number().optional(), windows: z.array(z.string()).optional() },
19992
+ outputSchema: { posts: z.array(z.any()).optional(), total: z.number().optional(), nextCursor: z.string().nullable().optional(), windows: z.array(z.string()).optional() },
19962
19993
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
19963
19994
  }, wrap(async (a) => {
19964
- const d = await apiGet('/api/posts', queryBrand(a, { ...(a.channel ? { channel: a.channel } : {}), ...(a.limit ? { limit: a.limit } : {}) }));
19995
+ const d = await apiGet('/api/posts', queryBrand(a, { ...(a.channel ? { channel: a.channel } : {}), ...(a.limit ? { limit: a.limit } : {}), ...(a.cursor ? { cursor: a.cursor } : {}) }));
19965
19996
  const posts = d.posts || [];
19966
- if (!posts.length) return ok('No published posts recorded for this brand yet. Everything published from now on is recorded automatically; to import history, call backfill_posts for a channel.', d);
19997
+ if (!posts.length) return ok(a.cursor ? 'No older posts: that was the start of this brand\'s history.' : 'No published posts recorded for this brand yet. Everything published from now on is recorded automatically; to import history, call backfill_posts for a channel.', d);
19967
19998
  const rows = posts.map(p => {
19968
19999
  const er = p.engagement || {};
19969
20000
  const eng = er.present ? `${(er.rate * 100).toFixed(2)}%` : `— (${er.reason || 'not measured'})`;
19970
20001
  return `• ${p.channel} · ${String(p.publishedAt ? new Date(p.publishedAt).toISOString().slice(0, 10) : '?')} · ${p.media} — ${String(p.caption || '(no caption)').replace(/\s+/g, ' ').slice(0, 60)}\n hook: ${p.hook || `(none — ${p.attribution})`} · engagement ${eng}${p.url ? ` · ${p.url}` : ''}`;
19971
20002
  });
19972
- return ok(`${posts.length} of ${d.total} recorded post(s):\n${rows.join('\n')}\n\nA dash is "the platform reported no number", never zero engagement.`, d);
20003
+ return ok(`${posts.length} of ${d.total} recorded post(s):\n${rows.join('\n')}\n\nA dash is "the platform reported no number", never zero engagement.${d.nextCursor ? `\nOlder posts follow: pass cursor:"${d.nextCursor}".` : ''}`, d);
19973
20004
  }));
19974
20005
 
19975
20006
  server.registerTool('list_hooks', {
@@ -20014,11 +20045,12 @@ function memoryNoteVerdict(text) {
20014
20045
  ...MANAGE_BRAND,
20015
20046
  axis: z.enum(['hook', 'subject', 'recipe', 'channel', 'media', 'hour']).optional().describe('what to group by — default hook; recipe = the format of the creative'),
20016
20047
  channel: z.string().optional().describe('restrict to one channel'),
20048
+ days: z.number().optional().describe('look back N days (1-730) over the whole history; omit for the recent posts only'),
20017
20049
  },
20018
- outputSchema: { axis: z.string().optional(), groups: z.array(z.any()).optional(), finding: z.any().optional(), excludedUnattributed: z.number().optional(), minN: z.number().optional(), totalPosts: z.number().optional(), trend: z.any().optional(), leaderboard: z.any().optional(), health: z.any().optional() },
20050
+ outputSchema: { axis: z.string().optional(), groups: z.array(z.any()).optional(), finding: z.any().optional(), excludedUnattributed: z.number().optional(), minN: z.number().optional(), totalPosts: z.number().optional(), trend: z.any().optional(), leaderboard: z.any().optional(), health: z.any().optional(), followers: z.any().optional(), archive: z.any().optional(), days: z.number().optional() },
20019
20051
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
20020
20052
  }, wrap(async (a) => {
20021
- const d = await apiGet('/api/posts/performance', queryBrand(a, { ...(a.axis ? { axis: a.axis } : {}), ...(a.channel ? { channel: a.channel } : {}) }));
20053
+ const d = await apiGet('/api/posts/performance', queryBrand(a, { ...(a.axis ? { axis: a.axis } : {}), ...(a.channel ? { channel: a.channel } : {}), ...(a.days ? { days: a.days } : {}) }));
20022
20054
  const gs = d.groups || [];
20023
20055
  // OVER TIME + MEASUREMENT HEALTH (2026-09-11): week-by-week medians per channel and why unmeasured posts have no
20024
20056
  // numbers. Printed even when no hook comparison exists yet — "is it getting better" does not need five hooks.
@@ -20033,7 +20065,11 @@ function memoryNoteVerdict(text) {
20033
20065
  // (what it shows, its format, its link) — the caption is only the fallback label (2026-09-11, Dave).
20034
20066
  const cap = (p) => `${p.subject || p.recipe ? String(p.subject || p.recipe).slice(0, 80) : `"${String(p.caption || '(no caption)').slice(0, 60)}"`}${p.media ? ` [${p.media}${p.recipe && p.subject ? `, ${p.recipe}` : ''}]` : ''}${p.url ? ` ${p.url}` : ''} (${fmtN(p.score)})`;
20035
20067
  const boardTxt = (d.leaderboard || []).filter(b => b.measured >= 2).slice(0, 10).map(b => `• ${b.channel} by ${b.rankedBy}: best ${cap(b.best[0])}${b.allEqual ? ' — every measured post scored the same' : (b.worst[0] ? `; worst ${cap(b.worst[0])}` : '')} · ${b.measured} measured`);
20036
- const overTime = `${boardTxt.length ? `\n\nBEST AND WORST POSTS (last 30 days):\n${boardTxt.join('\n')}` : ''}${trendTxt.length ? `\n\nOVER TIME (last ${d.trend.weeks.length} weeks, 7-day readings where they exist):\n${trendTxt.join('\n')}` : ''}${healthTxt.length ? `\n\nMEASUREMENT GAPS:\n${healthTxt.join('\n')}` : ''}`;
20068
+ // FOLLOWERS OVER TIME (2026-09-23): one count per account per day from the nightly snapshot; a count that could not be
20069
+ // read is printed as unknown WITH its reason — never as 0.
20070
+ const folTxt = Array.isArray(d.followers) ? d.followers.slice(0, 12).map(f => `• ${f.channel}${f.label ? ` ${f.label}` : ''}: ${f.last ? `${fmtN(f.last.followers)} on ${f.last.day}${f.change != null && f.first && f.first.day !== f.last.day ? ` (${f.change >= 0 ? '+' : ''}${fmtN(f.change)} since ${f.first.day})` : ''}` : 'unknown'}${f.lastWhy ? ` — latest read unknown: ${f.lastWhy.why}` : ''}`) : [];
20071
+ const archTxt = d.archive?.used ? `\n\nIncludes ${d.archive.rows} older post(s) from the brand's archive.` : (d.archive?.unreadable ? `\n\n⚠ ${d.archive.why}` : '');
20072
+ const overTime = `${boardTxt.length ? `\n\nBEST AND WORST POSTS (last ${d.days || 30} days):\n${boardTxt.join('\n')}` : ''}${trendTxt.length ? `\n\nOVER TIME (last ${d.trend.weeks.length} weeks, 7-day readings where they exist):\n${trendTxt.join('\n')}` : ''}${healthTxt.length ? `\n\nMEASUREMENT GAPS:\n${healthTxt.join('\n')}` : ''}${folTxt.length ? `\n\nFOLLOWERS (daily snapshot):\n${folTxt.join('\n')}` : (d.followers?.unreadable ? `\n\nFOLLOWERS: ${d.followers.why}` : '')}${archTxt}`;
20037
20073
  if (!gs.length) return ok(`Nothing to compare on "${d.axis}" yet. ${d.finding?.why || ''}`.trim() + overTime, d);
20038
20074
  const rows = gs.map(g => `• "${g.key}" · ${g.channel} — ${g.meanRate == null ? (g.meanEngagement == null ? 'no measurable engagement' : `${g.meanEngagement.toFixed(1)} engagements (no reach denominator on this channel, so no rate)`) : `${(g.meanRate * 100).toFixed(2)}% engagement`} · ${g.n} post(s), ${g.nRated} measured${g.verdict === 'ready' ? '' : ` — ${g.suppressed}`}`);
20039
20075
  const head = d.finding?.finding ? `FINDING: ${d.finding.finding}` : `NO FINDING YET: ${d.finding?.why || 'not enough measured posts'}`;
@@ -20072,7 +20108,7 @@ function memoryNoteVerdict(text) {
20072
20108
  const d = await apiPost('/api/posts/collect', bodyBrand(a, { ...(a.includeMetered ? { includeMetered: true } : {}), ...(a.max ? { max: a.max } : {}), ...(a.remeasure ? { remeasure: true } : {}) }));
20073
20109
  if (a.remeasure) return ok(`Re-read ${d.remeasured || 0} old post(s) whose earlier readings were empty or failed; ${d.remeasuredWithNumbers || 0} now have numbers. Read ${d.collected} post(s) in total${d.remaining ? `, ${d.remaining} still waiting — run it again to continue` : ''}.${d.meteredNote ? ` ${d.meteredNote}` : ''}`, d);
20074
20110
  const bits = [`Read ${d.collected} post(s)`, d.couldNotTell ? `${d.couldNotTell} could NOT be read (that is "could not tell", not zero engagement)` : null, d.gone ? `${d.gone} no longer exist at the platform (deleted or taken down) and will not be read again` : null, d.remaining ? `${d.remaining} still due — call again` : null, d.meteredNote || null].filter(Boolean);
20075
- return ok(`${bits.join('. ')}.${d.collected ? ' Ask post_performance which hooks are winning.' : ''}`, d);
20111
+ return ok(`${bits.join('. ')}.${d.xOwn?.note ? ` ${d.xOwn.note}` : ''}${d.collected ? ' Ask post_performance which hooks are winning.' : ''}`, d);
20076
20112
  }));
20077
20113
 
20078
20114
  server.registerTool('backfill_posts', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.273",
3
+ "version": "0.1.279",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
5
  "description": "Marketing on autopilot, run from your own AI agent. 856 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. 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",