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 +5 -1
- package/mcp/tools.mjs +57 -21
- package/package.json +1 -1
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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.
|
|
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",
|