hermoso 0.1.273 → 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.
Files changed (2) hide show
  1. package/mcp/tools.mjs +32 -4
  2. package/package.json +1 -1
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)'),
@@ -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"'),
@@ -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.273",
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",