hermoso 0.1.272 → 0.1.275

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/bin/hermoso.mjs CHANGED
@@ -109,7 +109,7 @@ async function main() {
109
109
  case 'capabilities': case 'caps': {
110
110
  const d = await api.apiGet('/api/generate/status');
111
111
  if (flags.json) return console.log(JSON.stringify(d, null, 2));
112
- console.log('IMAGE models:'); (d.options?.image?.models || []).forEach(m => console.log(` ${m.id.padEnd(18)} ${m.label} · ${m.credits}cr${m.best ? ' ★best' : ''}`));
112
+ console.log('IMAGE models:'); (d.options?.image?.models || []).forEach(m => console.log(` ${m.id.padEnd(18)} ${m.label} · ${m.credits}cr${m.best ? ' ★best' : ''}${m.aspectRatios?.length ? ` · aspect ${m.aspectRatios.join(' ')}` : ''}`));
113
113
  console.log('VIDEO models:'); (d.options?.video?.models || []).forEach(m => console.log(` ${m.id.padEnd(18)} ${m.label} · ${(m.durations || []).join('/')}s`));
114
114
  console.log(`flags: canEdit=${d.canEdit} canAvatar=${d.canAvatar} canPublish=${d.canPublish}`);
115
115
  console.log(`recipes: ${(d.recipes || []).map(r => r.id).join(', ')}`);
package/mcp/tools.mjs CHANGED
@@ -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)'),
@@ -3484,7 +3505,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3484
3505
  _meta: openaiMeta(CAPABILITIES_URI, 'Loading the model catalog…', 'Model catalog ready'),
3485
3506
  }, wrap(async () => {
3486
3507
  const d = await apiGet('/api/generate/status');
3487
- const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.refs ? `, ≤${m.refs.max} reference images` : ''}${m.creditsBySize && Object.keys(m.creditsBySize).length > 1 ? `, imageSize ${Object.entries(m.creditsBySize).map(([s, c]) => `${s}=${c}cr`).join(' ')}` : m.hiRes ? ', 2K' : ''}${m.best ? ', best' : ''})`).join('; ');
3508
+ const img = (d.options?.image?.models || []).map(m => `${m.id} (${m.label}, ${m.credits}cr${m.refs ? `, ≤${m.refs.max} reference images` : ''}${m.creditsBySize && Object.keys(m.creditsBySize).length > 1 ? `, imageSize ${Object.entries(m.creditsBySize).map(([s, c]) => `${s}=${c}cr`).join(' ')}` : m.hiRes ? ', 2K' : ''}${Array.isArray(m.aspectRatios) && m.aspectRatios.length ? `, aspect ${m.aspectRatios.join(' ')}` : ''}${m.best ? ', best' : ''})`).join('; ');
3488
3509
  // durations + per-duration credits MATTER: without them agents assume the generic "AI video caps at 8-10s"
3489
3510
  // prior and wrongly steer users to stitching (a real Claude.ai session did exactly that on a 15s ad)
3490
3511
  const vid = (d.options?.video?.models || []).map(m => `${m.id} (${m.label}: one continuous clip of ${(m.durations || []).map(x => `${x}s=${m.credits?.[x] ?? '?'}cr`).join(' ')}${m.audio ? ', native audio' : ', silent'}${m.refs ? `, ${m.refs.max} reference image${m.refs.max === 1 ? '' : 's'}${m.refs.required ? ' (required — image-to-video only)' : ''}` : ''}${m.resolutions ? `, resolutions ${m.resolutions.join('/')}` : ''}${Array.isArray(m.cameraMoves) && m.cameraMoves.length ? `, camera moves ${m.cameraMoves.map(c => c.id).join('/')} (generate_video cameraMove, or your own cameraTrajectory keyframes)` : ''}${m.best ? ', best' : ''})`).join('; ');
@@ -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"'),
@@ -16883,7 +16906,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
16883
16906
  refImages: z.array(z.string()).optional().describe('local file paths or URLs of product/logo references to composite in. ON THE POSE MODELS — any row hermoso_capabilities marks `needsRefs` with a `refsMax`, such as putting your product in someone’s hands or a virtual try-on — THE ORDER IS THE CONTRACT AND IT IS NOT A COMPOSITE: refImages[0] is the PERSON photo, and the rest (up to `refsMax` minus one) are the product or garment photos. Reversed, you get the product wearing the person. A 4th product is dropped and the reply says so.'),
16884
16907
  useBrand: z.boolean().optional().describe('default true: with no refImages, the server hydrates the SAVED brand’s product/logo references so the output lands on-brand; pass false for a pure prompt-only render'),
16885
16908
  raw: z.boolean().optional().describe('RAW MODEL ACCESS: run the caller’s prompt on the named model with no Hermoso adjustments at all — the prompt reaches the provider byte-identical (no hex-to-colour-name rewrite, no prepended fidelity preamble) and NO saved-brand product photos are attached, so the model you name is the model that renders. Use it to drive the raw catalog; leave it off for an on-brand ad. Billing, the durable Library landing and per-model validation are unchanged.'),
16886
- aspectRatio: z.string().optional().describe("e.g. '1:1', '9:16', '16:9'"),
16909
+ aspectRatio: z.string().optional().describe("e.g. '1:1', '9:16', '16:9', '4:5'. Each model draws its own list (hermoso_capabilities prints it per model, e.g. Nano Banana 2 goes to 1:8 and 8:1); a ratio the chosen model cannot draw is refused before anything is charged"),
16887
16910
  model: z.string().optional().describe('image model id from hermoso_capabilities. A model whose `refs.mode` is "edit" there (gpt-image-2.5) takes your refImages on ITS OWN editor, up to its `refs.max`, instead of the default compositor'),
16888
16911
  imageSize: z.string().optional().describe('pixel-size preset for models that support it: 1K/2K, and 4K on the models hermoso_capabilities lists with a 4K imageSize price (a 4K ask on any other model is refused, free) — omit for the default'),
16889
16912
  mask: z.string().optional().describe('MASKED EDIT — change ONE region of an image and keep the rest: a local path or URL of a mask image for refImages[0] (the image being edited). Either convention works and the reply says which it read: TRANSPARENT pixels = change, or, on a mask with no transparency, WHITE = change and black = keep. Any size; it is scaled to the image. The mask GUIDES the edit rather than stencilling it: the new content can blend a little past its edge. Runs on the model hermoso_capabilities marks `refs.mask` (gpt-image-2.5): leave `model` empty or name that one — any other named model is refused, free. Needs refImages; the result keeps the source image\'s own frame, so aspectRatio is not applied.'),
@@ -20014,11 +20037,12 @@ function memoryNoteVerdict(text) {
20014
20037
  ...MANAGE_BRAND,
20015
20038
  axis: z.enum(['hook', 'subject', 'recipe', 'channel', 'media', 'hour']).optional().describe('what to group by — default hook; recipe = the format of the creative'),
20016
20039
  channel: z.string().optional().describe('restrict to one channel'),
20040
+ days: z.number().optional().describe('look back N days (1-730), archived posts included; omit for the recent posts only'),
20017
20041
  },
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() },
20042
+ 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
20043
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
20020
20044
  }, wrap(async (a) => {
20021
- const d = await apiGet('/api/posts/performance', queryBrand(a, { ...(a.axis ? { axis: a.axis } : {}), ...(a.channel ? { channel: a.channel } : {}) }));
20045
+ 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
20046
  const gs = d.groups || [];
20023
20047
  // OVER TIME + MEASUREMENT HEALTH (2026-09-11): week-by-week medians per channel and why unmeasured posts have no
20024
20048
  // numbers. Printed even when no hook comparison exists yet — "is it getting better" does not need five hooks.
@@ -20033,7 +20057,11 @@ function memoryNoteVerdict(text) {
20033
20057
  // (what it shows, its format, its link) — the caption is only the fallback label (2026-09-11, Dave).
20034
20058
  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
20059
  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')}` : ''}`;
20060
+ // FOLLOWERS OVER TIME (2026-09-23): one count per account per day from the nightly snapshot; a count that could not be
20061
+ // read is printed as unknown WITH its reason — never as 0.
20062
+ 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}` : ''}`) : [];
20063
+ const archTxt = d.archive?.used ? `\n\nIncludes ${d.archive.rows} older post(s) from the brand's archive (the live record keeps the most recent 500).` : (d.archive?.unreadable ? `\n\n⚠ ${d.archive.why}` : '');
20064
+ 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
20065
  if (!gs.length) return ok(`Nothing to compare on "${d.axis}" yet. ${d.finding?.why || ''}`.trim() + overTime, d);
20038
20066
  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
20067
  const head = d.finding?.finding ? `FINDING: ${d.finding.finding}` : `NO FINDING YET: ${d.finding?.why || 'not enough measured posts'}`;
@@ -20072,7 +20100,7 @@ function memoryNoteVerdict(text) {
20072
20100
  const d = await apiPost('/api/posts/collect', bodyBrand(a, { ...(a.includeMetered ? { includeMetered: true } : {}), ...(a.max ? { max: a.max } : {}), ...(a.remeasure ? { remeasure: true } : {}) }));
20073
20101
  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
20102
  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);
20103
+ return ok(`${bits.join('. ')}.${d.xOwn?.note ? ` ${d.xOwn.note}` : ''}${d.collected ? ' Ask post_performance which hooks are winning.' : ''}`, d);
20076
20104
  }));
20077
20105
 
20078
20106
  server.registerTool('backfill_posts', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.272",
3
+ "version": "0.1.275",
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",