hermoso 0.1.275 → 0.1.280

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/http.mjs CHANGED
@@ -296,7 +296,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
296
296
  // A HANDSHAKE IS NOT USE. `verifyBearer` stamps the key's last_used_at, and the admin dashboard's "last
297
297
  // active" takes the max of that, the billed ledger and the user's last_seen — so an agent that merely holds a
298
298
  // connection open (initialize, tools/list, ping, a notification) kept reporting the account as ACTIVE while
299
- // nobody had done anything with the product (Dave 2026-09-03: "many users are being shown active who arent
299
+ // nobody had done anything with the product (2026-09-03: "many users are being shown active who arent
300
300
  // actually using the app ... they may just be using an AI agent which has our mcp connected, but not actually
301
301
  // doing anything"). Only a tools/call is somebody doing something, so only a tools/call stamps. Auth itself is
302
302
  // unchanged in both branches: this decides bookkeeping, never access.
@@ -109,7 +109,7 @@ export const TOOL_PROVIDER_RULES = [
109
109
  ];
110
110
 
111
111
  // The provider a tool needs, or null when this module cannot attribute it. null ⇒ NEVER dropped (property 2).
112
- // ── PROVIDERS HERMOSO DOES NOT OFFER, AND AS OF NOW WILL NOT (Dave, 2026-09-03) ────────────────────────────────
112
+ // ── PROVIDERS HERMOSO DOES NOT OFFER, AND AS OF NOW WILL NOT (2026-09-03) ────────────────────────────────
113
113
  // "make sure posting to reddit or snapchat are never offered, never described, never wasting context in a tools
114
114
  // list". Reddit organic posting needs Reddit's API approval we do not have; Snapchat posting is not even built. Both
115
115
  // have ADS connectors that are live and are NOT in this set (`reddit_ads`, `snapchat_ads`). A tool whose provider
@@ -143,7 +143,7 @@ export const INSTAGRAM_LOGIN_TOOLS = new Set([
143
143
  'list_meta_conversations', 'read_meta_conversation', 'reply_to_meta_message',
144
144
  'list_instagram_collab_invites', 'list_instagram_collab_media', 'respond_instagram_collab_invite', 'search_instagram_audio',
145
145
  ]);
146
- // THE SAME SHAPE FOR WHATSAPP (2026-09-15, Dave: "do we properly explain to users when they need the meta connector vs
146
+ // THE SAME SHAPE FOR WHATSAPP (2026-09-15: "do we properly explain to users when they need the meta connector vs
147
147
  // individuals like instagram or whatsapp? and when they need both?"). Every whatsapp tool maps to 'meta' above because a
148
148
  // WhatsApp Business Account the business already administers is a Meta ASSET, ticked on Meta's assets step and reached
149
149
  // through the Meta user token. But a brand that onboarded its OWN number through Embedded Signup holds a 'whatsapp'
@@ -57,7 +57,7 @@ export function withHints(result, hints) {
57
57
  export const hintsOf = (result) => (result && result._meta && Array.isArray(result._meta[HINTS_KEY])) ? result._meta[HINTS_KEY] : [];
58
58
 
59
59
 
60
- // ── A VIDEO THE CALLER EXPECTS AND CANNOT AFFORD IS A CHOICE, NOT A SWAP (2026-09-22, Dave) ─────────────────────
60
+ // ── A VIDEO THE CALLER EXPECTS AND CANNOT AFFORD IS A CHOICE, NOT A SWAP (2026-09-22) ─────────────────────
61
61
  // The server refuses BEFORE planning or reserving — nothing billed — and the refusal carries `videoChoice`
62
62
  // (server.js videoChoiceFor): the video's price against the balance, the image alternative priced, a top-up, and,
63
63
  // only when one fits the balance together with the plan, a light draft. The text spells the same three options so
package/mcp/tools.mjs CHANGED
@@ -92,7 +92,7 @@ const channelOutcomeLine = (res) => {
92
92
  return ` — published to ${chs.length - failed.length}/${chs.length} channel${chs.length === 1 ? '' : 's'}` +
93
93
  (failed.length ? `. FAILED: ${failed.map(f => `${f.channel} (${f.error || 'failed'})`).join('; ')}` : '');
94
94
  };
95
- // A JOB THAT IS STILL RUNNING MUST NOT BE NARRATED AS FINISHED (2026-08-24, Dave: the card read "Rendering — job
95
+ // A JOB THAT IS STILL RUNNING MUST NOT BE NARRATED AS FINISHED (2026-08-24: the card read "Rendering — job
96
96
  // …" while the sentence under it read "Done — I rendered the 4-second 9:16 vertical coffee ad"). The old text
97
97
  // told the model to keep polling and never told it not to CLAIM the result, so it narrated an unfinished job as a
98
98
  // delivered one — the render-side twin of get_job's `done (100%)` printed over posted:0.
@@ -107,7 +107,7 @@ const stillMsg = (r, widget = hostRendersWidgets()) => widget
107
107
  const okVideo = async (text, r) => {
108
108
  if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; const t = text + geoLine(r) + qaLine(r); return { content: [{ type: 'text', text: p ? t + '\n(first frame attached — open the URL for the full video)' : t }, ...(p ? [p] : [])], structuredContent: r ?? {} }; };
109
109
 
110
- // ── INDEPENDENT AREAS, NOT A PIPELINE (Dave, 2026-08-04) ────────────────────────────────────────────────────────
110
+ // ── INDEPENDENT AREAS, NOT A PIPELINE (2026-08-04) ────────────────────────────────────────────────────────
111
111
  // "the app isnt all or nothing, you dont need to use our content generation, you dont need to use our scheduled
112
112
  // posting or ads management, you can pick and choose individual features and use whatever specifically you need,
113
113
  // or all of it together."
@@ -135,7 +135,7 @@ const okVideo = async (text, r) => {
135
135
  export const INDEPENDENCE = 'INDEPENDENT AREAS, NOT A PIPELINE — research, creation, publishing/scheduling and ads management each work ON THEIR OWN, and NO tool requires that you used another one first: publish or schedule media the user already has and generate nothing here (upload_file turns any local or external file into a URL the publish, schedule and ad-build tools accept), build and read campaigns on their OWN ad accounts with their OWN creative across all eleven ad platforms, research competitors with no brand drafted and no channel connected, or generate a file with nothing connected at all and simply hand back the URL. Use one area, several, or all of it together — never tell a user they have to start somewhere else first.';
136
136
 
137
137
  // ── PASTE-A-KEY CONNECTORS AN AGENT MAY CONNECT ITSELF (2026-09-12) ──────────────────────────────────────────────────
138
- // Dave: "AI agents should be able to connect key based accounts, we should offer both and its up to users what they
138
+ // Product feedback: "AI agents should be able to connect key based accounts, we should offer both and its up to users what they
139
139
  // prefer." An OAuth account needs its provider's consent screen, which only a browser can show. A paste-a-key account
140
140
  // needs a value the user already holds, and the app's own route checks that value live with the vendor before it saves
141
141
  // anything. So connect_connector posts to the SAME route the Connectors page posts to (server validation, workspace
@@ -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' },
@@ -307,7 +307,7 @@ async function videoPosterBlock(videoUrl) {
307
307
  const f = (d.frames || [])[0]; if (!f || !/^data:image\//.test(f)) return null;
308
308
  const [head, b64] = f.split(',');
309
309
  return { type: 'image', data: b64, mimeType: head.slice(5).split(';')[0] };
310
- } catch (e) { console.error('[mcp] video poster failed:', String(e?.message || e).slice(0, 160)); return null; } // silent-null keeps the link usable; log so a missing poster is diagnosable (Dave hit this on Claude.ai)
310
+ } catch (e) { console.error('[mcp] video poster failed:', String(e?.message || e).slice(0, 160)); return null; } // silent-null keeps the link usable; log so a missing poster is diagnosable (the owner hit this on Claude.ai)
311
311
  }
312
312
  async function imageBlock(url) {
313
313
  // A HOST WITH A WIDGET DOES NOT NEED A MEGABYTE OF BASE64, AND IS HARMED BY IT (2026-08-23).
@@ -444,13 +444,13 @@ const wrap = (fn) => {
444
444
  if (e?.videoChoice && typeof e.videoChoice === 'object') { msg = 'Error: ' + videoChoiceText(_tool, e.videoChoice); _hints.push(...videoChoiceHints(_tool, e.videoChoice)); }
445
445
  else if (/not enough credits|out of credits|needs (a paid plan|the Pro plan)/i.test(msg)) _hints.push({ do: 'buy_credits({})', why: 'this account cannot cover the call; buy_credits quotes on a saved card or returns a checkout link, and billing_status shows the balance and the billing role' }), msg += `\nRun buy_credits to top up (credit packs): with a saved card it quotes (quoteToken included) then one-click charges on confirm:true + quote_token; with no card yet it returns a checkout link your human pays once (the card saves for one-click after). billing_status shows your balance, plan + billing role; if you're an admin, upgrade_plan moves to a bigger monthly plan (a person pays on Stripe). hermoso_credits shows the balance; hermoso_capabilities lists per-model credit costs.`;
446
446
  // connector not connected → hand the human a ONE-CLICK connect link (OAuth needs a browser, so it can't happen
447
- // in-agent) — Dave 2026-07-23. Detected from the STRUCTURED signal, never from the prose (see notConnectedHint).
447
+ // in-agent) — 2026-07-23. Detected from the STRUCTURED signal, never from the prose (see notConnectedHint).
448
448
  else {
449
449
  msg += notConnectedHint(e, msg);
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) ──────────────────────────────────────────────────────────
@@ -488,7 +488,7 @@ const publishWrap = (fn) => {
488
488
  };
489
489
  // The registry tags the function it REGISTERS, which here is this outer one — forward the name down to the wrap()
490
490
  // that actually reads it, or every publish tool would report its errors with an empty op. (Without this the whole
491
- // publishing surface — the exact area Dave named — is the one part of the ledger with no tool names in it.)
491
+ // publishing surface — the exact area the owner named — is the one part of the ledger with no tool names in it.)
492
492
  Object.defineProperty(outer, '_hermosoTool', { set(v) { inner._hermosoTool = v; }, get() { return inner._hermosoTool; }, configurable: true });
493
493
  return outer;
494
494
  };
@@ -735,7 +735,7 @@ const AD_RESULT_HTML = String.raw`<div id="root"></div>
735
735
  #root { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', sans-serif; color: #16181c; }
736
736
  @media (prefers-color-scheme: dark) { #root { color: #ececf1; } }
737
737
  /* FILL THE BUBBLE. A fixed 520px left the card occupying about two thirds of ChatGPT's much wider container
738
- with dead space beside it (Dave, 2026-08-24). The host already bounds the width; we should not bound it
738
+ with dead space beside it (2026-08-24). The host already bounds the width; we should not bound it
739
739
  again and smaller. Media still has its own max-height, so a tall 9:16 clip cannot run away. */
740
740
  .card { width: 100%; border: 1px solid rgba(128,128,128,.28); border-radius: 14px; overflow: hidden; background: rgba(128,128,128,.05); }
741
741
  /* A VERTICAL AD MUST RENDER VERTICAL (2026-08-24). width:100% forced a 1080x1920 clip to the full card width,
@@ -830,7 +830,7 @@ const AD_RESULT_HTML = String.raw`<div id="root"></div>
830
830
  }
831
831
  return null;
832
832
  }
833
- // NOTHING TO SHOW IS NOT A CARD (2026-08-23). Driving Hermoso inside ChatGPT, Dave's conversation filled up with
833
+ // NOTHING TO SHOW IS NOT A CARD (2026-08-23). Driving Hermoso inside ChatGPT, the owner's conversation filled up with
834
834
  // empty Hermoso cards reading "No media in this result yet." — a full-height bordered box with a wordmark and no
835
835
  // content, sometimes three or four in a row. Every widget-bound tool draws this card on EVERY call, and several
836
836
  // of their results legitimately carry no media at all: render_ad's dryRun and its needsProductPhoto ask,
@@ -898,7 +898,7 @@ const AD_RESULT_HTML = String.raw`<div id="root"></div>
898
898
  // MEDIA BEATS A STALE STATUS, and an error only counts when nothing was delivered.
899
899
  v.pending = !v.media && !v.error && !v.notFound && (!!out.stillRendering || v.status === 'queued' || v.status === 'running' || (!!v.jobId && v.status !== 'done' && v.status !== 'error'));
900
900
  // AND A CARD THAT HAS STOPPED CHECKING IS NOT "RENDERING". The give-up limits used to stop the TIMER and leave
901
- // the spinner and the word "Rendering" on screen, which is a lie about what the product is doing — Dave watched
901
+ // the spinner and the word "Rendering" on screen, which is a lie about what the product is doing — the owner watched
902
902
  // exactly that for ten minutes after the job had already failed. "stalled" is a pending render this card can no
903
903
  // longer follow: it still names the job id, it just stops pretending to watch it.
904
904
  // A HOST WITH NO BRIDGE IS NOT A STALL: the model's own get_job loop still runs and set_globals still lands
@@ -915,7 +915,7 @@ const AD_RESULT_HTML = String.raw`<div id="root"></div>
915
915
  // callTool with an ENVELOPE, so the envelope itself was taken as the payload. An envelope has a truthy result
916
916
  // key, so the "is this usable?" test passed, missed was reset to 0 on every attempt, and the card sat in pending
917
917
  // claiming it updates itself. A card that gives up at least says so; this one could not reach either terminal
918
- // state, which is exactly what Dave watched: "the video is taking very long to render, not sure if it ever will".
918
+ // state, which is exactly what the owner watched: "the video is taking very long to render, not sure if it ever will".
919
919
  //
920
920
  // So unwrap, and DECIDE BY SHAPE rather than by which key the host happened to use. A job payload is the object
921
921
  // carrying status / url / result.data; an envelope carries content and structuredContent. Candidates are walked
@@ -1232,7 +1232,7 @@ const CAPABILITIES_HTML = String.raw`<div id="root"></div>
1232
1232
 
1233
1233
  // ── THE AD-SPY CARD — RESEARCH RESULTS SHOW THE CREATIVE (2026-08-24) ─────────────────────────────────────────
1234
1234
  // "Using Hermoso, show me the ads Liquid Death is running right now" came back in ChatGPT as a wall of text with
1235
- // numbered "View Liquid Death ad #…" links and ZERO images (Dave: "shouldnt it show images natively?"). The cause
1235
+ // numbered "View Liquid Death ad #…" links and ZERO images ("shouldnt it show images natively?"). The cause
1236
1236
  // is a gate that is right for the tools it was written for and wrong here: imageBlock() returns null on a widget
1237
1237
  // host, because a 1.03MB inline base64 block once made ChatGPT drop structuredContent entirely — and the RENDER
1238
1238
  // tools it was written for have the ad-result card to show the media instead. The RESEARCH tools had no card, so
@@ -1279,7 +1279,7 @@ const AD_SPY_HTML = String.raw`<div id="root"></div>
1279
1279
  .noshot { display: flex; align-items: center; justify-content: center; width: 100%; aspect-ratio: 4 / 5; font-size: 11px; opacity: .5; color: #ececf1; }
1280
1280
  /* A SEARCH AD IS TEXT, AND TEXT IS ITS CREATIVE — not a picture that failed to load. Google's ad library returns
1281
1281
  format:'text' rows with no image by design, and painting the grey "no creative" plate over them made a whole
1282
- row of real, live search ads read as broken (Dave, 2026-08-24). The headline gets the slot instead, set like
1282
+ row of real, live search ads read as broken (2026-08-24). The headline gets the slot instead, set like
1283
1283
  the ad it is: a search result. */
1284
1284
  .textad { display: flex; align-items: center; width: 100%; aspect-ratio: 4 / 5; padding: 14px 12px; color: #ececf1;
1285
1285
  background: linear-gradient(160deg, rgba(120,140,255,.10), rgba(0,0,0,.55)); }
@@ -1377,7 +1377,7 @@ const AD_SPY_HTML = String.raw`<div id="root"></div>
1377
1377
  // A CALL THAT HAS NOT ANSWERED IS NOT A CALL THAT FOUND NOTHING. The host mounts this component as soon as the
1378
1378
  // tool is invoked, so for the whole length of a research call (a minute is normal) toolOutput is simply absent
1379
1379
  // and the old code took that for "nothing found" and painted an empty box — a blank region under the prompt
1380
- // with no sign anything was happening (Dave, 2026-08-24). Absent output means PENDING; output that arrived
1380
+ // with no sign anything was happening (2026-08-24). Absent output means PENDING; output that arrived
1381
1381
  // carrying no cards is the real empty case and still collapses to nothing.
1382
1382
  // The FIRST attempt at this read toolResponseMetadata's mere PRESENCE as "answered", which is wrong in the
1383
1383
  // expensive direction: that object exists DURING the call, because it is what carries the status. So every
@@ -1502,7 +1502,7 @@ export function adSpyCard(row, platform = '') {
1502
1502
  // A TILE MUST SHOW SOMETHING A HUMAN CAN USE: a picture, or copy to read. The old guard also accepted an
1503
1503
  // ADVERTISER NAME or a bare link, and that is how a Google pull filled half the grid with identical dead tiles
1504
1504
  // reading "no creative / Liquid Death GOOGLE" — the advertiser is the same on every tile in the grid, so it
1505
- // carries no information at all, and there was nothing to click through to (Dave, 2026-08-24: "if there's
1505
+ // carries no information at all, and there was nothing to click through to (2026-08-24: "if there's
1506
1506
  // actually no creative, why would we even show them, you cant click or see more details so its completely
1507
1507
  // useless"). Google's basic ad-library tier returns exactly this shape: an advertiser and nothing else.
1508
1508
  // A LINK still counts: a video ad whose poster is missing shows no picture but is genuinely clickable, and
@@ -1983,7 +1983,7 @@ export const DEFAULT_TOOL_GROUPS = TOOL_GROUP_NAMES.filter((g) => !OPT_IN_TOOL_G
1983
1983
  // • `MCP_CORE_FIRST=1` in the environment turns the small roster on for the whole process, and
1984
1984
  // • `?tools=core` turns it on for one connection; any explicit `tools=` scope or `enable_tools({groups:[…]})`
1985
1985
  // mid-session decides the roster outright. An explicit scope ALWAYS wins.
1986
- // 🚨 CORE-FIRST IS OPT-IN, NOT THE DEFAULT (2026-09-17, Dave: "can chatgpt, cursor etc and other mcps properly use that
1986
+ // 🚨 CORE-FIRST IS OPT-IN, NOT THE DEFAULT (2026-09-17: "can chatgpt, cursor etc and other mcps properly use that
1987
1987
  // to access all our tools or will they think we're missing a lot of functionality? We DO NOT want to hurt quality or
1988
1988
  // make it seem like we have less functionality"). `find_tools` is OUR tool, not a host feature, so a client only reaches
1989
1989
  // the other 800 tools if its model READS the instructions that say so — and ChatGPT's connector truncates server
@@ -2005,7 +2005,7 @@ export const coreFirstRoster = (env = process.env) => CORE_FIRST_ENV.some((k) =>
2005
2005
  // every existing caller and every per-group count the site and the docs derive. tools/core-first-roster-check.mjs
2006
2006
  // asserts each name is registered and that NONE of them is connector-gated (one that were would be listed and
2007
2007
  // then answer 401, which is the thing the connector gate exists to prevent).
2008
- // THE SHORT LIST HAS TO LOOK LIKE THE PRODUCT (2026-09-20, Dave: "we dont want to degrade quality or make it look like
2008
+ // THE SHORT LIST HAS TO LOOK LIKE THE PRODUCT (2026-09-20: "we dont want to degrade quality or make it look like
2009
2009
  // there's less capability"). The first core-first list was the `core` group plus the five above, and read live off
2010
2010
  // production it was: enable_tools, find_tools, call_tool, hermoso_capabilities, hermoso_credits, buy_credits,
2011
2011
  // report_bug, request_feature, billing_status, upgrade_plan, set_auto_reload, list_brands, use_brand, create_brand,
@@ -2129,7 +2129,7 @@ export async function legacyToolAnswer(name, request, extra, ctx) {
2129
2129
  // audio content" (design aids excepted) and software that "executes financial transactions on behalf of users".
2130
2130
  // Hermoso as a whole does both, so the Claude Connectors Directory listing is a SCOPED server: the `create` group
2131
2131
  // is out, every tool that calls a generative image/video/audio model is out wherever it lives, and the three tools
2132
- // that move money are out (the same three ChatGPT is denied). Dave, 2026-09-02: "We're way more than ad generation,
2132
+ // that move money are out (the same three ChatGPT is denied). Product feedback (2026-09-02): "We're way more than ad generation,
2133
2133
  // we can focus on all the other huge benefits like our organic posting, ads management, analytics, dms, etc."
2134
2134
  // It is a CAGE, deliberately, unlike every other scope: enable_tools may not widen it into `create` or `all`,
2135
2135
  // because the listing's compliance acknowledgments are only honest if no path on the connection reaches a
@@ -2146,7 +2146,7 @@ export const WITHHELD_FROM_DIRECTORY = new Set([
2146
2146
  'upscale_video', 'reframe_video', 'multiply_ad', 'clone_static', 'remix_static', 'stitch_video', 'fix_beat',
2147
2147
  'hook_variants',
2148
2148
  ]);
2149
- // TWO DIRECTORY MODES (Dave, 2026-09-02, after the directory turned out to list Tofu Ads — an AI ad-image generator
2149
+ // TWO DIRECTORY MODES (2026-09-02, after the directory turned out to list Tofu Ads — an AI ad-image generator
2150
2150
  // — under the policy's design-asset carve-out): `directory` is the fully scoped cage above; `directory-full` keeps
2151
2151
  // generation (an ad-creation workflow with the brand's own product and copy, the carve-out's shape) and withholds
2152
2152
  // ONLY the three tools that move money, which is the listing that was actually submitted. Both are cages for what
@@ -2161,7 +2161,7 @@ export const toolHeldBackByDirectory = (name, group, ctx) => {
2161
2161
  // ── A REFERENCE LOOKUP MUST NOT BE AN APP SURFACE (2026-08-24) ─────────────────────────────────────────────────
2162
2162
  // MEASURED IN CHATGPT, not theorised: "make a 4 second vertical video ad for a coffee roaster" produced a
2163
2163
  // full-height scrolling MODEL CATALOG card ahead of the ad, and the catalog is ~70 rows, so the thing the user
2164
- // asked for sat below a scroll-trap they had to get past. Dave, watching it: "why are there all these boxes ...
2164
+ // asked for sat below a scroll-trap they had to get past. The owner, watching it: "why are there all these boxes ...
2165
2165
  // why does it all look kind of weird."
2166
2166
  //
2167
2167
  // THE CAUSE IS THE WIDGET, NOT THE WORDING. `hermoso_capabilities` carried an `openai/outputTemplate`, and in the
@@ -2559,7 +2559,7 @@ function newToolScope(opts) {
2559
2559
  // AN EXPLICIT SCOPE ALWAYS WINS. `only` is what `?tools=`, `HERMOSO_TOOLS` and `/v1` pass; core-first is only
2560
2560
  // what an UNSTATED default resolves to, so a caller who named their groups gets exactly those and nothing here
2561
2561
  // narrows them. `coreFirst` is therefore false for every explicit scope, including `?tools=all`.
2562
- // …AND ONLY A HOST WE HAVE SEEN SEARCH GETS THE SHORT LIST (2026-09-20, Dave, asked twice: "will they all be able to
2562
+ // …AND ONLY A HOST WE HAVE SEEN SEARCH GETS THE SHORT LIST (2026-09-20, asked twice: "will they all be able to
2563
2563
  // search the other tools and understand that more is available? we dont want to degrade quality or make it seem like
2564
2564
  // we have less functionality"). The honest answer was "not provably": `find_tools` is an instruction, the ledger keeps
2565
2565
  // failures and not successful calls, so which hosts follow it could not be read from history. What HAS been seen:
@@ -2820,7 +2820,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
2820
2820
  // exactly that. The same policy explicitly ALLOWS a user to "sign in to an existing paid account and access
2821
2821
  // features already included in their subscription", which is why nothing else here is affected.
2822
2822
  // Deliberately NOT a group: all three live in `core`, which every roster force-adds, and moving them would
2823
- // take them away from Claude, Cursor and the CLI too. Dave's position is that a customer controls their own
2823
+ // take them away from Claude, Cursor and the CLI too. The owner's position is that a customer controls their own
2824
2824
  // billing wherever they use Hermoso; this is OpenAI's constraint on OpenAI's surface, nothing wider.
2825
2825
  //
2826
2826
  // `set_auto_reload` JOINED THEM 2026-08-24, and it is the strongest of the three, not the weakest — found by
@@ -3636,7 +3636,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3636
3636
  // billing_status (full picture + your role) → upgrade_plan / set_auto_reload (admin-only, pay-on-Stripe / in-app).
3637
3637
 
3638
3638
  // ── FEEDBACK: let the AGENT report a bug or ask for a capability we don't have ────────────────────────────────
3639
- // Dave 2026-07-26: someone driving Hermoso from OpenClaw/Claude/Cursor hits a bug or a missing capability mid-task.
3639
+ // Product decision (2026-07-26): someone driving Hermoso from OpenClaw/Claude/Cursor hits a bug or a missing capability mid-task.
3640
3640
  // Today that feedback dies in their terminal. These two tools turn the agent itself into the reporter — it already
3641
3641
  // has the exact context (what it tried, what came back), which is better than anything a human would retype later.
3642
3642
  // Both just email the team. Free, no credits.
@@ -3688,7 +3688,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
3688
3688
  }, wrap(async () => {
3689
3689
  const d = await apiGet('/api/billing/status');
3690
3690
  const ar = d.autoReload || {};
3691
- // A MEMBER of a shared workspace gets plan + balance and nothing about the owner's card (Dave, 2026-08-02:
3691
+ // A MEMBER of a shared workspace gets plan + balance and nothing about the owner's card (2026-08-02:
3692
3692
  // "members dont need to see the owners payment card"). `null` is deliberately distinguished from `false` on
3693
3693
  // both lines below — rendering "Card on file: no" at somebody whose owner definitely has a card is a
3694
3694
  // well-formed lie, which is the whole class this sweep exists to remove.
@@ -4795,7 +4795,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
4795
4795
  return ok(`Published ${d.carousel ? `a ${d.slides}-slide CAROUSEL ` : a.story ? 'a 24-hour STORY ' : ''}to ${d.account || d.page || d.target}${d.url ? ` — ${d.url}` : ''} (post ${d.postId}).${d.collaboratorNote ? ` ${d.collaboratorNote}` : ''}`, d);
4796
4796
  }));
4797
4797
  // ── SCHEDULING (2026-07-30). ONE mechanism for every channel — our durable queue, not a per-platform special case.
4798
- // Dave: "if only Facebook can do scheduling, then maybe we just do all the scheduling ourselves. There's probably
4798
+ // Product feedback: "if only Facebook can do scheduling, then maybe we just do all the scheduling ourselves. There's probably
4799
4799
  // no need for one edge case just for Facebook."
4800
4800
  server.registerTool('schedule_post', {
4801
4801
  title: 'Schedule a post for later',
@@ -5418,7 +5418,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
5418
5418
  }));
5419
5419
 
5420
5420
  // ── X DIRECT MESSAGES (2026-08-25) ──────────────────────────────────────────────────────────────────────────
5421
- // ON DEMAND, NEVER PUSHED. Dave's framing is the design: *"users check their messages by asking, not us sending
5421
+ // ON DEMAND, NEVER PUSHED. The owner's framing is the design: *"users check their messages by asking, not us sending
5422
5422
  // them notifications. Users already get notifications from all these DMs directly."* So there is no watcher and
5423
5423
  // no schedule here — a person asks their agent, the agent reads. Every rule is cited in lib/x-dm.mjs.
5424
5424
  //
@@ -7561,7 +7561,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
7561
7561
  // this year, when Meta renamed Standard -> LIMITED and Advanced -> FULL on 2026-05-05 — and a moving state
7562
7562
  // written into a tool description goes stale the day it changes, at which point an agent reads it and REFUSES a
7563
7563
  // capability we ship ([[prompt-rosters-go-stale]]). The tier belongs in the runtime refusal, which is computed;
7564
- // see lib/meta-access.mjs. Dave 2026-08-23: "advertise itself as having access to those meta scopes".
7564
+ // see lib/meta-access.mjs. Product feedback (2026-08-23): "advertise itself as having access to those meta scopes".
7565
7565
  server.registerTool('list_meta_pixels', {
7566
7566
  title: 'List Meta Pixels on an ad account',
7567
7567
  description: 'List the META PIXELS on one of the brand’s ad accounts — id, name, when it was created, and WHEN IT LAST FIRED. This is where the pixelId every conversion tool needs comes from: create_meta_ad takes it (with conversionEvent) to optimise an ad set for OFFSITE_CONVERSIONS instead of link clicks, and create_meta_audience needs it to build a website retargeting audience. Without this tool that id could only be read off a screen in Events Manager. READ lastFiredAt BEFORE YOU TRUST A PIXEL: one that has NEVER FIRED is not installed on the site, so an ad optimising against it will spend and never learn. Pass includeCode:true to get the <script> snippet for installation (it is long, so it is off by default). Read-only, free.',
@@ -8565,7 +8565,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
8565
8565
  const d = await apiPost('/api/google-ads/asset', a);
8566
8566
  return ok(`Uploaded ${d.kind} asset to Google Ads (${d.assetResourceName}).`, d);
8567
8567
  }));
8568
- // ---------- Google Ads breadth (Dave 2026-07-31): the four holes the connector audit found.
8568
+ // ---------- Google Ads breadth (2026-07-31): the four holes the connector audit found.
8569
8569
  // 1. CONVERSION ACTIONS. We offered TARGET_CPA / TARGET_ROAS / MAXIMIZE_CONVERSIONS with no way to
8570
8570
  // configure the tracking they depend on — offerable and undeliverable in the same product. Now
8571
8571
  // creatable + listable, and a conversion-bidding campaign on an account with none is REFUSED.
@@ -9238,7 +9238,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
9238
9238
  return ok(`Staged trigger "${d.trigger?.name}" [${d.trigger?.type}], id ${d.trigger?.triggerId}. ${d.note}`, d);
9239
9239
  }));
9240
9240
  // ---------- PUBLISHING (2026-08-20) — the one Tag Manager call that reaches the live site ----------
9241
- // Held back on 2026-08-19 and reversed by Dave on 2026-08-20; lib/tag-manager.mjs
9241
+ // Held back on 2026-08-19 and reversed by the owner on 2026-08-20; lib/tag-manager.mjs
9242
9242
  // GTM_SCOPES_REVERSED carries the decision with the prior refusal preserved verbatim. The shape
9243
9243
  // follows this repo's destructive-tool law: the gate is a PURE function so it can be RUN rather
9244
9244
  // than read, the unconfirmed call is a free READ that publishes nothing, and the ANSWER is the
@@ -14776,7 +14776,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
14776
14776
  // ══ AUTOMATED RULES (2026-08-19) ══════════════════════════════════════════════════════════════════════════════
14777
14777
  // Held since the 2026-08-19 approval and recorded as DELIBERATELY unbuilt — "a rule is standing permission to
14778
14778
  // move money with no human in the loop, and every spend switch in this product is confirm-gated for exactly
14779
- // that reason" — until Dave asked for it. The safety architecture was extended rather than weakened, because
14779
+ // that reason" — until the owner asked for it. The safety architecture was extended rather than weakened, because
14780
14780
  // the existing one genuinely does not transfer: set_tiktok_ads_status gates ONE act on objects the caller
14781
14781
  // NAMED at ONE moment, and a rule fires repeatedly, later, unattended, over a set TikTok re-resolves each run.
14782
14782
  //
@@ -17192,7 +17192,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17192
17192
  // (jobType 'stitch': the server packs the scenes into the fewest balanced ≤model-max acts via the shared
17193
17193
  // acts-packing.mjs) instead of the old silent clamp that time-compressed a 30s board into one 15s clip.
17194
17194
  if (a.dryRun) return ok(`DRY RUN — routing decision (no job submitted, nothing charged): jobType=${jobType || 'video'}, model=${input.model}, durationSeconds=${input.durationSeconds}${Array.isArray(input.scenes) ? `, acts=[${input.scenes.map(s => Math.round(s.seconds * 10) / 10).join(', ')}]s` : ' (single pass)'}${input.modelExplicit ? ', modelExplicit (ask-don’t-swap)' : ''}.${_clampNote}${_castLine}\n${notes || ''}`, { dryRun: true, jobType: jobType || 'video', ...(creator ? { creator } : {}), input });
17195
- // ASK BEFORE SPENDING (Dave 2026-07-28: "ask the user BEFORE the render is dispatched — never after money is
17195
+ // ASK BEFORE SPENDING (2026-07-28: "ask the user BEFORE the render is dispatched — never after money is
17196
17196
  // spent"). `notes` alone was not enough here: on the real path it only reaches the model AFTER renderJob has
17197
17197
  // polled to completion, i.e. after the credits are gone. So when the ad features a product this brand has no
17198
17198
  // photo of, STOP and say so — the same honesty contract as templateGapMessage: nothing was rendered, nothing was
@@ -17209,7 +17209,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17209
17209
 
17210
17210
  server.registerTool('make_template_ad', {
17211
17211
  title: 'Make template ad',
17212
- 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.",
17213
17213
  inputSchema: {
17214
17214
  config: z.object({}).passthrough().describe("the template config — MUST include config.template (one of the template ids above) plus that template's fields"),
17215
17215
  },
@@ -17221,7 +17221,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
17221
17221
  if (Array.isArray(r?.raw?.images) && r.raw.images.length) { // carousel: one PNG per slide → list every URL + inline the first slide
17222
17222
  const urls = r.raw.images.map((u) => abs(u));
17223
17223
  const first = await imageBlock(urls[0]).catch(() => null);
17224
- 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 ?? {} };
17225
17227
  }
17226
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 ?? {} }; }
17227
17229
  return okVideo(`Template ad ready: ${r.url}${r.model ? ` (${r.model})` : ''} [job ${r.jobId}]`, r);
@@ -17249,19 +17251,24 @@ function buildTools(rawServer, opts = {}, sink = null) {
17249
17251
 
17250
17252
  server.registerTool('post_edit', {
17251
17253
  title: 'Post-production edit',
17252
- 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.",
17253
17255
  inputSchema: {
17254
- 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'),
17255
17257
  ops: z.array(z.object({
17256
- op: z.enum(['trim', 'speed', 'mute', 'audio_gain', 'fade_out', 'append_card', 'watermark', 'grain']),
17257
- start: z.number().optional().describe('trim/mute window start (s)'),
17258
- 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'),
17259
17266
  factor: z.number().optional().describe('speed 0.5-2'),
17260
17267
  db: z.number().optional().describe('audio_gain -20..+6 dB'),
17261
- 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'),
17262
17269
  headline: z.string().optional().describe('append_card: big line (defaults to the brand name)'),
17263
17270
  tagline: z.string().optional().describe('append_card: smaller line under the headline'),
17264
- 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'),
17265
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"),
17266
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'),
17267
17274
  corner: z.enum(['tl', 'tr', 'bl', 'br']).optional().describe('watermark corner (default br)'),
@@ -17277,7 +17284,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17277
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
17278
17285
  const pal = (Array.isArray(b.palette) ? b.palette : []).filter(c => /^#[0-9a-f]{6}$/i.test(String(c || '')));
17279
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');
17280
- 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);
17281
17288
  }));
17282
17289
 
17283
17290
  server.registerTool('fix_beat', {
@@ -17333,7 +17340,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17333
17340
 
17334
17341
  // ADD SUBTITLES TO ANY VIDEO (2026-09-12). A plain comment, not a "── SECTION ──" header: build-docs groups tools by
17335
17342
  // those headers, and this tool belongs to the section clip_video is in.
17336
- // Dave: "Do we have functionality to add subtitles to our videos or others? … it should be possible to customize the
17343
+ // Product feedback: "Do we have functionality to add subtitles to our videos or others? … it should be possible to customize the
17337
17344
  // style of them as well". Burned subtitles existed only INSIDE clip_video and make_explainer; a finished render, an
17338
17345
  // upload or someone else's video had no way to get them. The look is the same textStyle vocabulary render_ad speaks.
17339
17346
  server.registerTool('add_subtitles', {
@@ -17559,7 +17566,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17559
17566
  outputSchema: { ...JOB_OUT },
17560
17567
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17561
17568
  }, wrap(async (a) => {
17562
- // HARD GUARD (Dave watched an agent stitch a 15s ad into 4 separate renders): a spot that fits ONE Seedance
17569
+ // HARD GUARD (the owner watched an agent stitch a 15s ad into 4 separate renders): a spot that fits ONE Seedance
17563
17570
  // clip renders single-pass through the Studio assembly instead — no seams, exact multi-beat arc, ~1/4 the cost.
17564
17571
  // The agent's scene list becomes the storyboard; its voiceover lines ride the same exactness rails.
17565
17572
  const total = +a.durationSeconds || (a.scenes || []).reduce((s, x) => s + (+x.seconds || 4), 0);
@@ -17906,7 +17913,7 @@ function memoryNoteVerdict(text) {
17906
17913
  }));
17907
17914
 
17908
17915
 
17909
- // ── SAVED CREATORS: outreach status + notes (2026-09-05, Dave: "parity with dedicated tools"). A creator saved by
17916
+ // ── SAVED CREATORS: outreach status + notes (2026-09-05: "parity with dedicated tools"). A creator saved by
17910
17917
  // find_creators → save_to_swipefile carries `creator`; this writes the OUTREACH state onto that row so the whole
17911
17918
  // team — web and agent — sees who has been contacted, who replied, who is booked. One row, one field, never a
17912
17919
  // second store: the swipefile is already synced, tombstoned and union-merged.
@@ -18681,7 +18688,7 @@ function memoryNoteVerdict(text) {
18681
18688
  const gs = d.groups || [];
18682
18689
  if (!gs.length) return ok(`No errors recorded${a?.kind || a?.surface ? ' matching that filter' : ''}. (This is a real empty result — a read that FAILED would have raised an error, not returned an empty list.)`, d);
18683
18690
  // A DORMANT GROUP SAYS SO, IN THE LINE ITSELF. Rows live 30 days, so a defect fixed weeks ago still appears
18684
- // here; without the marker it reads exactly like a live bug and gets re-diagnosed (Dave 2026-09-03, after an
18691
+ // here; without the marker it reads exactly like a live bug and gets re-diagnosed (2026-09-03, after an
18685
18692
  // hour went into a Meta 500 from 08-11 that had been fixed the same day). The server ranks these last; this
18686
18693
  // is the half a reader sees. "Dormant" and not "fixed": no hits for N days is strong evidence, not proof.
18687
18694
  const lines = gs.slice(0, 25).map(g => `[${g.kind === 'ours' ? 'OURS' : g.kind}] ${g.surface}·${g.op} ${g.status || '—'} ×${g.count} — ${g.errorClass}: ${String(g.message).slice(0, 110)}${g.stale ? ` ⏸ DORMANT ${g.daysQuiet}d — likely already fixed, check before working it` : ''} ·fp ${g.fp}`).join('\n');
@@ -18748,7 +18755,7 @@ function memoryNoteVerdict(text) {
18748
18755
  // this one is named and described as the fast single-brand path. The platform list is now decided HERE and the
18749
18756
  // spread cannot reach it.
18750
18757
  const d = await apiPost('/api/inspire/fanout', { country: 'US', limit: Math.min(12, a.limit || 8), sort: 'longest_running', ...a, platforms: ['facebook'] });
18751
- // SURFACE THE ACTUAL ADS (Dave 2026-07-21: ChatGPT got only "Pulled ads for X" — the structured data never
18758
+ // SURFACE THE ACTUAL ADS (2026-07-21: ChatGPT got only "Pulled ads for X" — the structured data never
18752
18759
  // reached the user). Flatten each platform's ads into compact rows + image blocks, like the search_* tools.
18753
18760
  const platforms = ['facebook', 'google', 'linkedin'];
18754
18761
  const rows = [], urls = [];
@@ -18764,12 +18771,12 @@ function memoryNoteVerdict(text) {
18764
18771
  // AND `media` MUST NEVER FALL BACK TO A LINK. It is the creative field, and adSpyCard uses it as the
18765
18772
  // tile's picture when no explicit thumb exists — so falling through to adUrl/link_url handed the card an
18766
18773
  // HTML PAGE as an <img> src, which paints the browser's broken-image glyph. That is what two tiles in an
18767
- // eight-ad grid were showing (Dave, 2026-08-24): not a missing creative, a page URL in an image slot.
18774
+ // eight-ad grid were showing (2026-08-24): not a missing creative, a page URL in an image slot.
18768
18775
  // The link still reaches the tile through `link` below, which is where a page URL belongs.
18769
18776
  const media = s.videos?.[0]?.video_sd_url || s.cards?.[0]?.video_sd_url || img || null;
18770
18777
  // A GOOGLE TEXT AD HAS NO PICTURE AND THAT IS NORMAL — its headline IS the creative, and it lives under
18771
18778
  // variations[]. Reading only the top-level fields left those rows with no copy and no image, so the grid
18772
- // filled with identical blank tiles reading "no creative / Liquid Death GOOGLE" (Dave, 2026-08-24).
18779
+ // filled with identical blank tiles reading "no creative / Liquid Death GOOGLE" (2026-08-24).
18773
18780
  // Measured on a real pull: every Google row came back format:'text', imageUrl null, adUrl null, and a real
18774
18781
  // headline in variations[0]. The ad was there all along; we were not reading it.
18775
18782
  const gv = Array.isArray(ad.variations) ? ad.variations[0] : null;
@@ -18777,7 +18784,7 @@ function memoryNoteVerdict(text) {
18777
18784
  || (gv && (gv.headline || gv.description)) || '';
18778
18785
  // THE TILE LINKS TO THE AD, NOT TO THE SHOP. Clicking a competitor's ad card used to open its DESTINATION
18779
18786
  // — walmart.com for a Liquid Death ad — which is the one place that tells you nothing about the ad. Someone
18780
- // clicking a video tile wants to WATCH THE AD (Dave, 2026-08-24). The library page plays the video, shows
18787
+ // clicking a video tile wants to WATCH THE AD (2026-08-24). The library page plays the video, shows
18781
18788
  // the full copy, every placement, the run dates AND where it points, so it strictly contains the
18782
18789
  // destination rather than replacing it. Meta hands us that page as `ad.url`; Google as `adUrl`. The
18783
18790
  // destination survives only as the last resort, for a row that carries no library page at all.
@@ -18948,7 +18955,7 @@ function memoryNoteVerdict(text) {
18948
18955
  const d = await apiSSE('/api/explore/chat', { messages: [{ role: 'user', content: query }], brand: brandObj });
18949
18956
  const res = d.results || [];
18950
18957
  // pull a still image URL out of each normalized card (ad OR tiktok/social shapes) so ChatGPT/Claude SHOW the
18951
- // creatives inline (Dave 2026-07-21: research_ads was returning text only, no images)
18958
+ // creatives inline (2026-07-21: research_ads was returning text only, no images)
18952
18959
  const imgUrl = (r) => { const a = r?.ad?.snapshot || {}; return r?.image || r?.thumb || r?.cover || r?.tiktok?.cover || r?.social?.image || a.images?.[0]?.resized_image_url || a.videos?.[0]?.video_preview_image_url || a.cards?.[0]?.resized_image_url || r?.ad?.imageUrl || null; };
18953
18960
  const urls = [...new Set(res.map(imgUrl).filter((u) => typeof u === 'string' && /^https?:\/\//.test(u)))].slice(0, 4);
18954
18961
  const widget = hostRendersWidgets();
@@ -18990,7 +18997,7 @@ function memoryNoteVerdict(text) {
18990
18997
  const trunc = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
18991
18998
  const nAds = (n) => Math.min(25, Math.max(1, Math.round(+n) || 8));
18992
18999
  // Compact JSON summary + REAL MCP image blocks of the top creatives (2026-07-21: ChatGPT does NOT render
18993
- // markdown-image links out of tool text — Dave got a text-only reply; attached image CONTENT BLOCKS display in
19000
+ // markdown-image links out of tool text — the owner got a text-only reply; attached image CONTENT BLOCKS display in
18994
19001
  // both ChatGPT and Claude). Plus an explicit creative-URL list so the model can hand the user clickable links
18995
19002
  // (videos especially), and a parent-brand nudge on zero results (SuperBelly is advertised by Blume — a name
18996
19003
  // miss must trigger resolution, not a shrug).
@@ -19154,7 +19161,7 @@ function memoryNoteVerdict(text) {
19154
19161
  const mk = d.marketplace && (d.marketplace.creators || []).length ? `\nInstagram creator marketplace:\n${d.marketplace.creators.map(c => `• @${c.handle}${c.followers != null ? `, ${fmt(c.followers)} followers` : ''}${c.country ? `, ${c.country}` : ''}${c.email ? `, ${c.email}` : ''}`).join('\n')}` : '';
19155
19162
  return ok(`${d.note}\n${lines.join('\n')}${mk}`, d);
19156
19163
  }));
19157
- // TOPIC SEARCH (2026-09-15, Dave: search a named brand, "but not their ads themselves, just posts about them … similarly
19164
+ // TOPIC SEARCH (2026-09-15: search a named brand, "but not their ads themselves, just posts about them … similarly
19158
19165
  // just broad things like coffee"): the posts ABOUT a subject from anyone, all three organic platforms in one call.
19159
19166
  // find_creators is this same search one step later (posts folded into people); the per-platform search_* tools are
19160
19167
  // it one platform at a time.
@@ -19379,7 +19386,7 @@ function memoryNoteVerdict(text) {
19379
19386
  }, wrap(async ({ save, ...a }) => {
19380
19387
  const d = await apiPost('/api/brand/draft', a);
19381
19388
  const p = d.profile || d;
19382
- // ALWAYS TRY THE WEBSITE (Dave 2026-07-28). /api/brand/draft returns the PROFILE only — it never fetched a single
19389
+ // ALWAYS TRY THE WEBSITE (2026-07-28). /api/brand/draft returns the PROFILE only — it never fetched a single
19383
19390
  // product photo, so an MCP-onboarded brand was structurally photo-less even with a perfectly good domain, and every
19384
19391
  // later plan_ad/render_ad on it invented the packaging. This tool's own outputSchema has advertised `logo`,
19385
19392
  // `products` and `productImages` since it shipped; nothing ever filled them. Pull them from the SAME endpoint the
@@ -19768,7 +19775,7 @@ function memoryNoteVerdict(text) {
19768
19775
  return ok(text, d);
19769
19776
  }));
19770
19777
 
19771
- // CLONE IS THE NAME, AND THE OLD ONE STAYS CALLABLE (2026-09-12, Dave: "Is remix and clone merged on other surfaces as
19778
+ // CLONE IS THE NAME, AND THE OLD ONE STAYS CALLABLE (2026-09-12: "Is remix and clone merged on other surfaces as
19772
19779
  // well? ... You can change it as long as you do it very carefully and dont lock us out"). The web app calls this Clone;
19773
19780
  // clone_static is the canonical name, and remix_static is kept, registered with the SAME handler, because an agent or a
19774
19781
  // host that cached the old roster calls tools by name and a missing name is a hard failure for that caller.
@@ -19975,24 +19982,25 @@ function memoryNoteVerdict(text) {
19975
19982
 
19976
19983
  server.registerTool('list_published_posts', {
19977
19984
  title: 'List what this brand has published',
19978
- 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.",
19979
19986
  inputSchema: {
19980
19987
  ...MANAGE_BRAND,
19981
19988
  channel: z.string().optional().describe('filter to one channel: facebook, instagram, threads, x, linkedin, youtube, tiktok, reddit, pinterest, google_business'),
19982
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'),
19983
19991
  },
19984
- 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() },
19985
19993
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
19986
19994
  }, wrap(async (a) => {
19987
- 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 } : {}) }));
19988
19996
  const posts = d.posts || [];
19989
- 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);
19990
19998
  const rows = posts.map(p => {
19991
19999
  const er = p.engagement || {};
19992
20000
  const eng = er.present ? `${(er.rate * 100).toFixed(2)}%` : `— (${er.reason || 'not measured'})`;
19993
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}` : ''}`;
19994
20002
  });
19995
- 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);
19996
20004
  }));
19997
20005
 
19998
20006
  server.registerTool('list_hooks', {
@@ -20037,7 +20045,7 @@ function memoryNoteVerdict(text) {
20037
20045
  ...MANAGE_BRAND,
20038
20046
  axis: z.enum(['hook', 'subject', 'recipe', 'channel', 'media', 'hour']).optional().describe('what to group by — default hook; recipe = the format of the creative'),
20039
20047
  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'),
20048
+ days: z.number().optional().describe('look back N days (1-730) over the whole history; omit for the recent posts only'),
20041
20049
  },
20042
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() },
20043
20051
  annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: false },
@@ -20054,13 +20062,13 @@ function memoryNoteVerdict(text) {
20054
20062
  const healthTxt = (d.health || []).filter(h => h.coverage == null || h.coverage < 0.8 || h.failed || h.neverRead).slice(0, 10).map(h => `• ${h.channel}: ${h.measured}/${h.posts} measured${h.coverage == null ? '' : ` (${Math.round(h.coverage * 100)}% of what could be)`}${h.neverRead ? ` · ${h.neverRead} never read` : ''}${h.empty ? ` · ${h.empty} read but empty${h.topEmpty ? ` (${h.topEmpty.message})` : ''}` : ''}${h.failed ? ` · ${h.failed} failed${h.topError ? ` — ${h.topError.message}` : ''}` : ''}${h.pending ? ` · ${h.pending} too new` : ''}`);
20055
20063
  // THE POSTS THEMSELVES, ranked inside each channel — answers "which of our posts did best" even when every post
20056
20064
  // was written to the same hook, which the hook comparison above cannot. A post is named by the CREATIVE it carried
20057
- // (what it shows, its format, its link) — the caption is only the fallback label (2026-09-11, Dave).
20065
+ // (what it shows, its format, its link) — the caption is only the fallback label (2026-09-11).
20058
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)})`;
20059
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`);
20060
20068
  // FOLLOWERS OVER TIME (2026-09-23): one count per account per day from the nightly snapshot; a count that could not be
20061
20069
  // read is printed as unknown WITH its reason — never as 0.
20062
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}` : ''}`) : [];
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}` : '');
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}` : '');
20064
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}`;
20065
20073
  if (!gs.length) return ok(`Nothing to compare on "${d.axis}" yet. ${d.finding?.why || ''}`.trim() + overTime, d);
20066
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}`}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.275",
3
+ "version": "0.1.280",
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",