hermoso 0.1.279 → 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/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
@@ -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,7 +444,7 @@ 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.
@@ -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
@@ -17340,7 +17340,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17340
17340
 
17341
17341
  // ADD SUBTITLES TO ANY VIDEO (2026-09-12). A plain comment, not a "── SECTION ──" header: build-docs groups tools by
17342
17342
  // those headers, and this tool belongs to the section clip_video is in.
17343
- // 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
17344
17344
  // style of them as well". Burned subtitles existed only INSIDE clip_video and make_explainer; a finished render, an
17345
17345
  // upload or someone else's video had no way to get them. The look is the same textStyle vocabulary render_ad speaks.
17346
17346
  server.registerTool('add_subtitles', {
@@ -17566,7 +17566,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
17566
17566
  outputSchema: { ...JOB_OUT },
17567
17567
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
17568
17568
  }, wrap(async (a) => {
17569
- // 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
17570
17570
  // clip renders single-pass through the Studio assembly instead — no seams, exact multi-beat arc, ~1/4 the cost.
17571
17571
  // The agent's scene list becomes the storyboard; its voiceover lines ride the same exactness rails.
17572
17572
  const total = +a.durationSeconds || (a.scenes || []).reduce((s, x) => s + (+x.seconds || 4), 0);
@@ -17913,7 +17913,7 @@ function memoryNoteVerdict(text) {
17913
17913
  }));
17914
17914
 
17915
17915
 
17916
- // ── 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
17917
17917
  // find_creators → save_to_swipefile carries `creator`; this writes the OUTREACH state onto that row so the whole
17918
17918
  // team — web and agent — sees who has been contacted, who replied, who is booked. One row, one field, never a
17919
17919
  // second store: the swipefile is already synced, tombstoned and union-merged.
@@ -18688,7 +18688,7 @@ function memoryNoteVerdict(text) {
18688
18688
  const gs = d.groups || [];
18689
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);
18690
18690
  // A DORMANT GROUP SAYS SO, IN THE LINE ITSELF. Rows live 30 days, so a defect fixed weeks ago still appears
18691
- // 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
18692
18692
  // hour went into a Meta 500 from 08-11 that had been fixed the same day). The server ranks these last; this
18693
18693
  // is the half a reader sees. "Dormant" and not "fixed": no hits for N days is strong evidence, not proof.
18694
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');
@@ -18755,7 +18755,7 @@ function memoryNoteVerdict(text) {
18755
18755
  // this one is named and described as the fast single-brand path. The platform list is now decided HERE and the
18756
18756
  // spread cannot reach it.
18757
18757
  const d = await apiPost('/api/inspire/fanout', { country: 'US', limit: Math.min(12, a.limit || 8), sort: 'longest_running', ...a, platforms: ['facebook'] });
18758
- // 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
18759
18759
  // reached the user). Flatten each platform's ads into compact rows + image blocks, like the search_* tools.
18760
18760
  const platforms = ['facebook', 'google', 'linkedin'];
18761
18761
  const rows = [], urls = [];
@@ -18771,12 +18771,12 @@ function memoryNoteVerdict(text) {
18771
18771
  // AND `media` MUST NEVER FALL BACK TO A LINK. It is the creative field, and adSpyCard uses it as the
18772
18772
  // tile's picture when no explicit thumb exists — so falling through to adUrl/link_url handed the card an
18773
18773
  // HTML PAGE as an <img> src, which paints the browser's broken-image glyph. That is what two tiles in an
18774
- // 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.
18775
18775
  // The link still reaches the tile through `link` below, which is where a page URL belongs.
18776
18776
  const media = s.videos?.[0]?.video_sd_url || s.cards?.[0]?.video_sd_url || img || null;
18777
18777
  // A GOOGLE TEXT AD HAS NO PICTURE AND THAT IS NORMAL — its headline IS the creative, and it lives under
18778
18778
  // variations[]. Reading only the top-level fields left those rows with no copy and no image, so the grid
18779
- // 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).
18780
18780
  // Measured on a real pull: every Google row came back format:'text', imageUrl null, adUrl null, and a real
18781
18781
  // headline in variations[0]. The ad was there all along; we were not reading it.
18782
18782
  const gv = Array.isArray(ad.variations) ? ad.variations[0] : null;
@@ -18784,7 +18784,7 @@ function memoryNoteVerdict(text) {
18784
18784
  || (gv && (gv.headline || gv.description)) || '';
18785
18785
  // THE TILE LINKS TO THE AD, NOT TO THE SHOP. Clicking a competitor's ad card used to open its DESTINATION
18786
18786
  // — walmart.com for a Liquid Death ad — which is the one place that tells you nothing about the ad. Someone
18787
- // 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
18788
18788
  // the full copy, every placement, the run dates AND where it points, so it strictly contains the
18789
18789
  // destination rather than replacing it. Meta hands us that page as `ad.url`; Google as `adUrl`. The
18790
18790
  // destination survives only as the last resort, for a row that carries no library page at all.
@@ -18955,7 +18955,7 @@ function memoryNoteVerdict(text) {
18955
18955
  const d = await apiSSE('/api/explore/chat', { messages: [{ role: 'user', content: query }], brand: brandObj });
18956
18956
  const res = d.results || [];
18957
18957
  // pull a still image URL out of each normalized card (ad OR tiktok/social shapes) so ChatGPT/Claude SHOW the
18958
- // 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)
18959
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; };
18960
18960
  const urls = [...new Set(res.map(imgUrl).filter((u) => typeof u === 'string' && /^https?:\/\//.test(u)))].slice(0, 4);
18961
18961
  const widget = hostRendersWidgets();
@@ -18997,7 +18997,7 @@ function memoryNoteVerdict(text) {
18997
18997
  const trunc = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
18998
18998
  const nAds = (n) => Math.min(25, Math.max(1, Math.round(+n) || 8));
18999
18999
  // Compact JSON summary + REAL MCP image blocks of the top creatives (2026-07-21: ChatGPT does NOT render
19000
- // 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
19001
19001
  // both ChatGPT and Claude). Plus an explicit creative-URL list so the model can hand the user clickable links
19002
19002
  // (videos especially), and a parent-brand nudge on zero results (SuperBelly is advertised by Blume — a name
19003
19003
  // miss must trigger resolution, not a shrug).
@@ -19161,7 +19161,7 @@ function memoryNoteVerdict(text) {
19161
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')}` : '';
19162
19162
  return ok(`${d.note}\n${lines.join('\n')}${mk}`, d);
19163
19163
  }));
19164
- // 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
19165
19165
  // just broad things like coffee"): the posts ABOUT a subject from anyone, all three organic platforms in one call.
19166
19166
  // find_creators is this same search one step later (posts folded into people); the per-platform search_* tools are
19167
19167
  // it one platform at a time.
@@ -19386,7 +19386,7 @@ function memoryNoteVerdict(text) {
19386
19386
  }, wrap(async ({ save, ...a }) => {
19387
19387
  const d = await apiPost('/api/brand/draft', a);
19388
19388
  const p = d.profile || d;
19389
- // 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
19390
19390
  // product photo, so an MCP-onboarded brand was structurally photo-less even with a perfectly good domain, and every
19391
19391
  // later plan_ad/render_ad on it invented the packaging. This tool's own outputSchema has advertised `logo`,
19392
19392
  // `products` and `productImages` since it shipped; nothing ever filled them. Pull them from the SAME endpoint the
@@ -19775,7 +19775,7 @@ function memoryNoteVerdict(text) {
19775
19775
  return ok(text, d);
19776
19776
  }));
19777
19777
 
19778
- // 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
19779
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;
19780
19780
  // clone_static is the canonical name, and remix_static is kept, registered with the SAME handler, because an agent or a
19781
19781
  // host that cached the old roster calls tools by name and a missing name is a hard failure for that caller.
@@ -20062,7 +20062,7 @@ function memoryNoteVerdict(text) {
20062
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` : ''}`);
20063
20063
  // THE POSTS THEMSELVES, ranked inside each channel — answers "which of our posts did best" even when every post
20064
20064
  // was written to the same hook, which the hook comparison above cannot. A post is named by the CREATIVE it carried
20065
- // (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).
20066
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)})`;
20067
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`);
20068
20068
  // FOLLOWERS OVER TIME (2026-09-23): one count per account per day from the nightly snapshot; a count that could not be
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.279",
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",