hermoso 0.1.498 → 0.1.500

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/README.md CHANGED
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate image & video ad
5
5
  composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
6
6
  campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
7
7
 
8
- **1110 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **1182 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
9
9
  catalog with exact per-render credit costs plus the full capability map.
10
10
 
11
11
  **No markup on models** on Starter, Pro and Studio. Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
@@ -23,9 +23,9 @@ charges per API request (`hermoso_credits` prints the exact credits per X post).
23
23
 
24
24
  **What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
25
25
  Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus
26
- product feeds in Google Merchant Center. Publishing and scheduling — **eleven** channels: Facebook, Instagram,
27
- Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram and Google Business Profile. Messaging: WhatsApp (you message a
28
- person, so it is not a twelfth publishing channel) and DM automations on Instagram, Facebook Pages and
26
+ product feeds in Google Merchant Center. Publishing and scheduling — **fifteen** channels: Facebook, Instagram,
27
+ Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram, WordPress, Discord, Slack, Whop and Google Business Profile. Messaging: WhatsApp (you message a
28
+ person, so it is not a sixteenth publishing channel) and DM automations on Instagram, Facebook Pages and
29
29
  Messenger, and X (a keyword comment, a DM, a story reply or an ig.me / m.me link gets an automatic reply). Ad research: the Meta, Google and LinkedIn ad libraries plus organic TikTok,
30
30
  Instagram, YouTube, Threads and Reddit. Analytics: Google Analytics 4, Google Search Console and every
31
31
  connected platform's own post and campaign insights. Files: Google Drive, Sheets, Docs and OneDrive.
@@ -242,7 +242,7 @@ block entirely if you signed in above; it is there for CI, where the process can
242
242
 
243
243
  Then ask your agent: *“Generate an image ad with Hermoso.”*
244
244
 
245
- ### What the 1110 tools cover
245
+ ### What the 1182 tools cover
246
246
 
247
247
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
248
248
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
@@ -278,13 +278,16 @@ Real faces from your own photos need a paid plan; AI people work on every plan.
278
278
  per-render credit cost) with no ad framing: `generate_image` / `generate_video` with `useBrand:false`,
279
279
  `generate_voice`, `generate_text`. The image default is Nano Banana 2.1.
280
280
 
281
- **Publish to your own channels** — **eleven** of them: Facebook, Instagram and Threads (`post_to_meta`), TikTok
281
+ **Publish to your own channels** — **fifteen** of them: Facebook, Instagram and Threads (`post_to_meta`), TikTok
282
282
  (`post_to_tiktok`), YouTube (`post_to_youtube` + `update_youtube_video`, `youtube_video_insights`, comments
283
283
  read/reply), X (`post_to_x`, `x_post_metrics`, `x_post_insights`, `x_mentions`, `list_x_dms`, `send_x_dm`),
284
284
  LinkedIn profile **and** company Pages (`post_to_linkedin`, `post_to_linkedin_page`), Pinterest
285
285
  (`post_to_pinterest` + boards), Bluesky (`post_to_bluesky`, `delete_bluesky_post`, `bluesky_post_metrics`, plus
286
286
  `list_bluesky_convos` / `read_bluesky_dm` / `send_bluesky_dm`) and Telegram (`post_to_telegram`,
287
- `delete_telegram_message`, `list_telegram_chats`) and Google Business Profile (below). `schedule_post` / `list_scheduled` / `cancel_scheduled` give
287
+ `delete_telegram_message`, `list_telegram_chats`), WordPress (`post_to_wordpress`, `update_wordpress_post`,
288
+ `delete_wordpress_post`, `list_wordpress_posts`), Discord and Slack announcements (`post_to_discord`, `edit_discord_message`,
289
+ `delete_discord_message`, `post_to_slack`), Whop (`post_to_whop`, `edit_whop_post`, `delete_whop_post`, `get_whop_post`,
290
+ `list_whop_destinations`) and Google Business Profile (below). `schedule_post` / `list_scheduled` / `cancel_scheduled` give
288
291
  you one content calendar over exactly that set. `upload_file` brings in any external or local media, not just
289
292
  Hermoso renders.
290
293
  *X posting bills credits per API call (X charges per request); a post containing a link costs 13× one without.*
package/bin/hermoso.mjs CHANGED
@@ -153,7 +153,7 @@ async function main() {
153
153
  return out(`Balance: ${bal ?? '—'} credits`, d); }
154
154
  case 'brand': {
155
155
  // BARE `hermoso brand` SHOWS THE SAVED BRAND (2026-09-25). The docs have always listed it as "the saved brand
156
- // profile" and it died with the `brand draft` usage line instead — the first command a new user copies off
156
+ // profile" and it printed the `brand draft` usage line instead — the first command a new user copies off
157
157
  // the CLI page. It is get_brand, run through the same handler the MCP twins use.
158
158
  if (!sub) { const reg = await import('../mcp/registry.mjs'); return await runTool(reg, 'get_brand', flags, []); }
159
159
  if (sub !== 'draft') return die('usage: hermoso brand the saved profile (a brand, creator or personal workspace)\n hermoso brand draft (--domain <d> | --description <t> | --social <h> --platform <p>)');
@@ -0,0 +1,51 @@
1
+ // GENERATED by tools/chatgpt-roster-rank.mjs. Do not edit by hand: re-run the tool (it ranks by the committed usage in
2
+ // tools/lib/chatgpt-roster-usage.json). The provider families ChatGPT lists, best first; a family is all or nothing.
3
+ // The core (every tool that needs no connection) is always listed and is not in these lists. Cap: CHATGPT_TOOL_CAP
4
+ // (950) in mcp/tools.mjs, under ChatGPT's own 1,000-tool limit.
5
+ export const CHATGPT_ROSTER_FAMILIES = Object.freeze([
6
+ 'google_drive', // long tail, pinned (plugin skills)
7
+ 'intercom', // long tail, pinned (plugin skills)
8
+ 'meta', // publishing
9
+ 'youtube', // publishing
10
+ 'tiktok', // publishing
11
+ 'x', // publishing
12
+ 'pinterest', // publishing
13
+ 'google_business', // publishing
14
+ 'linkedin', // publishing
15
+ 'google_ads', // big-platform ads
16
+ 'google_analytics', // analytics
17
+ 'threads', // publishing
18
+ 'tiktok_ads', // big-platform ads
19
+ 'google_search_console', // analytics
20
+ 'telegram', // publishing
21
+ 'google_tag_manager', // long tail
22
+ 'snapchat_ads', // long tail
23
+ 'microsoft_onedrive', // long tail
24
+ 'bluesky', // publishing
25
+ 'openai_ads', // big-platform ads
26
+ 'reddit_ads', // long tail
27
+ 'bing_webmaster', // analytics
28
+ ]);
29
+ // Left out to stay under the cap, in rank order (the first one is the next to get in when room appears).
30
+ export const CHATGPT_ROSTER_LEFT_OUT = Object.freeze([
31
+ 'x_ads', // long tail
32
+ 'mixpanel', // analytics
33
+ 'apple_ads', // long tail
34
+ 'pinterest_ads', // long tail
35
+ 'discord', // publishing
36
+ 'slack', // publishing
37
+ 'wordpress', // publishing
38
+ 'microsoft_ads', // big-platform ads
39
+ 'amplitude', // analytics
40
+ 'hubspot', // analytics
41
+ 'klaviyo', // analytics
42
+ 'mailchimp', // analytics
43
+ 'posthog', // analytics
44
+ 'stripe', // analytics
45
+ 'applovin_ads', // long tail
46
+ 'canva', // long tail
47
+ 'notion', // long tail
48
+ 'vercel', // long tail
49
+ 'whop', // long tail
50
+ ]);
51
+ export const CHATGPT_ROSTER_META = Object.freeze({ cap: 950, usageCollectedAt: "2026-11-03T20:25:36.816Z" });
package/mcp/client.mjs CHANGED
@@ -253,10 +253,9 @@ const isTransport = (e) => !!e && e.name === 'TypeError' && TRANSPORT_RE.test(St
253
253
 
254
254
  // ── THE PLATFORM'S OWN 429 NEVER REACHED US, SO IT IS RETRIED HERE (2026-10-04) ───────────────────────────────
255
255
  // Polling a job every 10 seconds from the CLI answered HTTP 429, and it was not a limit of ours: the app has no limiter
256
- // on GET /api/jobs/:id, and /v1 reads allow 300 a minute per key. Every one of those 429s in the Cloud Run request log
257
- // (36 between 18:35 and 18:39Z, on /api/credits, GET / and webhooks as much as on job polls) is the platform's "The
256
+ // on GET /api/jobs/:id, and /v1 reads allow 300 a minute per key. A JSON-less 429 is the platform's "The
258
257
  // request was aborted because there was no available instance": a plain-text body, latency 0s, refused before the
259
- // container saw it, while the one instance's CPU sat pegged by local renders at 2-7 concurrent requests. The browser's
258
+ // container sees it, and it can land on any route, not only on job polls. The browser's
260
259
  // api() has retried that answer since 2026-09-06; the CLI and the stdio MCP read it as a failed call, so one busy
261
260
  // second ended a render wait. A refusal that never reached the app ran nothing, so it is retried for ANY method; the
262
261
  // app's own refusals always answer JSON and pass through untouched (their rules are theirs). A read also rides out a
@@ -439,7 +438,9 @@ export async function connectedProviders() {
439
438
  // shopifyStore (2026-10-27): true/false when the server could tell whether this account has a Shopify store;
440
439
  // anything else is null = unknown, which holds nothing back (roster-scope.mjs toolHeldForNoShopifyStore).
441
440
  const shopifyStore = r?.shopifyStore === true ? true : r?.shopifyStore === false ? false : null;
442
- return { connected: new Set(list.filter((p) => typeof p === 'string' && p)), readOk: true, offered, gated, connectLink, withheld, shopifyStore };
441
+ // flagsOn (2026-11-03): tools that ship dark and are switched ON on this server (roster-scope.mjs FLAGGED_OFF_TOOLS).
442
+ const flagsOn = new Set((Array.isArray(r?.flagsOn) ? r.flagsOn : []).filter((n) => typeof n === 'string' && n));
443
+ return { connected: new Set(list.filter((p) => typeof p === 'string' && p)), readOk: true, offered, gated, connectLink, withheld, flagsOn, shopifyStore };
443
444
  } catch { return { connected: new Set(), readOk: false }; }
444
445
  }
445
446
  // Upload raw file BYTES to /api/upload (150MB, persists → returns {url,kind,bytes}). Overrides the JSON content-type so
package/mcp/http.mjs CHANGED
@@ -16,7 +16,7 @@ import { randomUUID, createHash } from 'node:crypto';
16
16
  import { createRequire } from 'node:module';
17
17
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
18
18
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
19
- import { registerTools, MCP_INSTRUCTIONS, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS } from './tools.mjs';
19
+ import { registerTools, MCP_INSTRUCTIONS, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS, anonDiscoveryGroups } from './tools.mjs';
20
20
  import { chatgptExtAsked } from './openai-extensions.mjs';
21
21
  import { mcpCtx, connectedProviders } from './client.mjs';
22
22
 
@@ -120,24 +120,24 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
120
120
 
121
121
  // Per-session Streamable-HTTP transports. Each authenticated session gets its own McpServer with the same tools.
122
122
  //
123
- // ── A SESSION WAS EXPENSIVE, AND THIS MAP IS WHY PROD OOM'd (2026-08-01, again 2026-08-24) ───────────────────
123
+ // ── A SESSION WAS EXPENSIVE, AND THIS MAP IS WHY IT IS BOUNDED (2026-08-01, again 2026-08-24) ─────────────────
124
124
  // registerTools() used to rebuild all 681 tool definitions with their zod schemas on EVERY `initialize`:
125
125
  // **114.36 MB of RETAINED heap per McpServer** (measured, node --expose-gc, 25 instances; 114.48 MB measured
126
126
  // again end-to-end through this very transport). The older note here said ~36 MB — true at the 248-tool roster
127
127
  // it was written against, and 3.2x stale by August. This map used to be unbounded and never expired — the only
128
- // removal was transport.onclose, which never fires for a client that simply goes away. Prod took 238
129
- // `initialize` handshakes in 49 minutes on a 4 GiB, maxScale=1 instance and died of
130
- // `FATAL ERROR: Reached heap limit` six times in that hour, every crash a full outage.
128
+ // removal was transport.onclose, which never fires for a client that simply goes away, so a burst of
129
+ // `initialize` handshakes from clients that never close can grow the heap until the process reaches its
130
+ // memory limit.
131
131
  //
132
- // THE CAP AND THE TTL WERE NEVER THE FIX, AND 2026-08-24 PROVED IT: they worked exactly as designed (peak
133
- // concurrent sessions measured at exactly SESSION_MAX) and prod still crashed four times in twelve hours,
134
- // because the cost is CHURN, not retention — one client opened ~175 sessions in 65 minutes. Worse, the cap was
135
- // FEEDING the churn: 157 of 258 session closures in a 3-day window were 'over cap', each one answering that
132
+ // THE CAP AND THE TTL ARE NOT THE FIX ON THEIR OWN: they work exactly as designed (peak concurrent sessions
133
+ // measured at exactly SESSION_MAX) and memory still runs short under load,
134
+ // because the cost is CHURN, not retention — one client re-initialized over and over. Worse, the cap was
135
+ // FEEDING the churn: most session closures in a 3-day window were 'over cap', each one answering that
136
136
  // client's next call with a 404, which obliges it to re-initialize, which allocates another full roster and
137
137
  // evicts somebody else. A self-sustaining spiral. The durable fix is in mcp/tools.mjs: the tool definitions are
138
138
  // static per process, so they are built ONCE and replayed. A session now costs **3.06-3.19 MB** — 36x less —
139
139
  // and it is the per-`initialize` figure, not the per-live-session one, that decides whether this survives a
140
- // retry storm. See tools/mcp-tool-canon-check.mjs.
140
+ // burst of retries. See tools/mcp-tool-canon-check.mjs.
141
141
  //
142
142
  // The 400-on-a-session-miss below was the ACCELERANT: a client that cannot re-initialize opens a NEW session
143
143
  // instead of reusing its own, so the leak fed itself. Both are fixed here — bound + expire the map, and answer
@@ -150,11 +150,10 @@ export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStar
150
150
  const SESSION_IDLE_MS = Math.max(1000, Number(process.env.MCP_SESSION_IDLE_MS || 30 * 60e3)); // 1s floor so the expiry is TESTABLE; a short TTL is merely wasteful now that eviction is recoverable
151
151
  const sessions = new Map(); // mcp-session-id -> { transport, server, user, lastSeen } (insertion-ordered = LRU)
152
152
  // ── A STANDALONE SSE STREAM HOLDS A CLOUD RUN REQUEST SLOT FOR AS LONG AS IT LIVES (2026-09-15) ──────────────
153
- // OUTAGE, 13:00–13:22 UTC: every request to the app answered 429 "no available instance" for 22 minutes with the
154
- // instance at 1% CPU. Nothing was down and nobody was busy — the instance's 200 concurrent-request slots were all
155
- // held by `GET /mcp` notification streams (99 of them from ChatGPT's connector that morning, each held until the
156
- // 30-minute session idle), and Cloud Run refuses a request it has no slot for BEFORE the container sees it, so
157
- // the app could not even log it. maxScale=1 turns "one host holds too many streams" into a total outage.
153
+ // An instance's concurrent-request slots can all be held by `GET /mcp` notification streams (ChatGPT's connector
154
+ // opens one per session and holds it until the 30-minute session idle) while its CPU sits idle, and Cloud Run
155
+ // refuses a request it has no slot for BEFORE the container sees it, so the app cannot even log the refusal.
156
+ // Unbounded, "one host holds too many streams" saturates the instance for every other caller.
158
157
  // The GET stream is optional in the spec (a server may answer 405 or close it at any time; clients reconnect),
159
158
  // and this server sends nothing on it but a one-shot tools/list_changed nudge. So: a stream lives at most
160
159
  // MCP_GET_STREAM_MS (5 min, env-tunable) and the instance holds at most MCP_GET_STREAM_MAX of them at once —
@@ -221,13 +220,13 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
221
220
 
222
221
  // MCP spec 2025-06-18, Transports § Session Management: a server that no longer has a session MUST answer 404,
223
222
  // and on 404 the client MUST start a new session with a fresh InitializeRequest. 400 carries no such obligation,
224
- // so a 400 here is permanent death for that connection — which is exactly what every deploy used to do to every
225
- // hosted customer. -32001 "Session not found" is the SDK's own code for this case; we mint it ourselves because
223
+ // so a 400 here is permanent death for that connection, which is what a restart would otherwise do to every
224
+ // hosted connection. -32001 "Session not found" is the SDK's own code for this case; we mint it ourselves because
226
225
  // the SDK's branch is unreachable when there is no initialized transport left to compare the id against.
227
226
  const sessionGone = (res) => res.status(404).json({ jsonrpc: '2.0', error: { code: -32001, message: 'Session not found' }, id: null });
228
227
  // The SDK's own answer for a non-initialize request that names no session at all. We reply BEFORE constructing
229
- // anything: building a 36 MB McpServer purely to have it reject the request is what turned a retry storm into
230
- // an OOM. Status and message are byte-identical to the SDK's, so no client sees a behaviour change.
228
+ // anything: building a 36 MB McpServer purely to have it reject the request turns a burst of retries into
229
+ // memory pressure. Status and message are byte-identical to the SDK's, so no client sees a behaviour change.
231
230
  const needSession = (res) => res.status(400).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Bad Request: Mcp-Session-Id header is required' }, id: null });
232
231
  // The one place the host name is turned into a decision, so the anon and session paths cannot diverge.
233
232
  //
@@ -407,7 +406,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
407
406
  // every one of those the roster IS the product description. Serving them the core set would publish Hermoso as
408
407
  // a 22-tool server on ~430 directory pages. So an UNSTATED scope here resolves to the full pre-core-first
409
408
  // default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
410
- registerTools(server, { only: scope?.groups || [...DEFAULT_TOOL_GROUPS], directory: scope?.directory || false, widgetHost, hosted: true, ext: extAsked(req) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
409
+ // A widget host in full mode gets every group here too, so the anonymous roster and the signed-in one agree (see
410
+ // chatgptRosterMode in tools.mjs: ChatGPT cannot reach an unlisted tool).
411
+ registerTools(server, { only: scope?.groups || anonDiscoveryGroups(widgetHost), directory: scope?.directory || false, widgetHost, hosted: true, ext: extAsked(req) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
411
412
  // WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
412
413
  // not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
413
414
  // tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
@@ -456,7 +457,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
456
457
  return challenge(res);
457
458
  }
458
459
 
459
- // The stream cap, BEFORE any session work: a reconnect storm must be refused at the door, not after allocating.
460
+ // The stream cap, BEFORE any session work: a burst of reconnects must be refused at the door, not after allocating.
460
461
  if (req.method === 'GET') {
461
462
  if (openGetStreams >= GET_STREAM_MAX) {
462
463
  res.set('Retry-After', '30');
@@ -534,8 +535,8 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
534
535
  // tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
535
536
  // A CHATGPT ACCOUNT THAT HAS MADE NOTHING YET (2026-10-19). Read once per session, ChatGPT hosts only, bounded so a slow
536
537
  // read can never hold the connect: null (no note) is the answer to every failure. The first successful tool reply of
537
- // the session then offers one concrete next ask (firstRunNote in tools.mjs). 9 of 13 ChatGPT signups since the
538
- // 10-15 publish spent nothing, and a live "make me a video ad" chat on a connected account never called us.
538
+ // the session then offers one concrete next ask (firstRunNote in tools.mjs). Most ChatGPT signups since the
539
+ // 10-15 publish had made nothing, and a live "make me a video ad" chat on a connected account never called us.
539
540
  let firstRun = null;
540
541
  if (widgetHost && typeof firstRunFor === 'function' && user.accountId) {
541
542
  try { firstRun = await Promise.race([Promise.resolve(firstRunFor({ accountId: user.accountId })).catch(() => null), new Promise((r) => { const t = setTimeout(() => r(null), 1500); if (t && typeof t.unref === 'function') t.unref(); })]); } catch { firstRun = null; }
@@ -578,7 +579,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
578
579
  else if (m === 'tools/call') {
579
580
  entry.calls = (entry.calls || 0) + 1;
580
581
  // THE FIRST CALL IS THE MILESTONE, NOT THE LISTING (2026-09-07). The connect_mcp reward was granted on "an API
581
- // key exists", and 37 sessions last week listed the roster and never called a tool while 803 grants went out.
582
+ // key exists", and many sessions listed the roster and never called a tool while the grant still went out.
582
583
  // A session's first tools/call is what proves the connection is USED; the hook stamps the account's touch row.
583
584
  if (entry.calls === 1 && typeof onFirstCall === 'function' && entry.user?.accountId) { try { onFirstCall({ accountId: entry.user.accountId, userId: entry.user.userId || null, client: entry.client || '', ua: entry.ua || '', src: entry.src || null }); } catch {} }
584
585
  }
@@ -1268,7 +1268,7 @@ export const neutralNote = (t) => {
1268
1268
  // sweeps and drift watches queued for later (measured on a fresh local account: one `agentidle` "nudge", queued for
1269
1269
  // tomorrow, would have shown as "1 render in progress"). Mirrors server.js JOB_TYPES_HIDDEN_FROM_V1, and the check
1270
1270
  // fails if that list gains a type this one lacks. A job queued for LATER is not in progress either.
1271
- export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'emailfeedback']);
1271
+ export const PANEL_HIDDEN_JOB_TYPES = Object.freeze(['fallate', 'storysweep', 'ttpublicurl', 'whatsappcoexsync', 'purge', 'agentidle', 'welcomewait', 'agentsetupmail', 'uploadretention', 'miniappretention', 'connectordrift', 'falwatch', 'faceassetsweep', 'searchvis', 'mcpcanary', 'welcomeads', 'lifecycleedu', 'connhealth', 'chargednothing', 'badoutcome', 'emailfeedback', 'weeklyposts']);
1272
1272
  const ageText = (h) => (h == null || !Number.isFinite(+h) ? '' : +h < 1 ? 'just now' : +h < 48 ? `${Math.round(+h)}h ago` : `${Math.round(+h / 24)}d ago`);
1273
1273
  const VIDEO_RE = /\.(mp4|webm|mov|m4v)([?#]|$)/i;
1274
1274
  const clip = (s, n) => { const t = String(s ?? '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
@@ -1279,8 +1279,8 @@ const EDITABLE_POST = ['queued', 'scheduled'];
1279
1279
  const PROVIDER_INFO = {
1280
1280
  meta: ['Meta (Facebook and Instagram)', 'channels'], instagram: ['Instagram', 'channels'], threads: ['Threads', 'channels'],
1281
1281
  tiktok: ['TikTok', 'channels'], youtube: ['YouTube', 'channels'], linkedin: ['LinkedIn', 'channels'], x: ['X', 'channels'],
1282
- pinterest: ['Pinterest', 'channels'], bluesky: ['Bluesky', 'channels'], telegram: ['Telegram', 'channels'], reddit: ['Reddit', 'channels'],
1283
- google_business: ['Google Business Profile', 'channels'], whatsapp: ['WhatsApp', 'channels'], snapchat: ['Snapchat', 'channels'],
1282
+ pinterest: ['Pinterest', 'channels'], bluesky: ['Bluesky', 'channels'], telegram: ['Telegram', 'channels'],
1283
+ google_business: ['Google Business Profile', 'channels'], whatsapp: ['WhatsApp', 'channels'],
1284
1284
  google_ads: ['Google Ads', 'ads'], tiktok_ads: ['TikTok Ads', 'ads'], linkedin_ads: ['LinkedIn Ads', 'ads'], microsoft_ads: ['Microsoft Advertising', 'ads'],
1285
1285
  reddit_ads: ['Reddit Ads', 'ads'], pinterest_ads: ['Pinterest Ads', 'ads'], snapchat_ads: ['Snapchat Ads', 'ads'], x_ads: ['X Ads', 'ads'],
1286
1286
  openai_ads: ['ChatGPT Ads', 'ads'], apple_ads: ['Apple Ads', 'ads'], applovin_ads: ['AppLovin Ads', 'ads'],
@@ -0,0 +1,14 @@
1
+ // THE LOGO IS SAID WHERE IT LANDED, ONCE — ON THE QUEUED MCP PATH TOO (journey QA 2026-11-03). The stdio/CLI transport
2
+ // (and /v1 with ?wait=) queues generate_image and then joins two notes: the route's, written BEFORE the render ("The
3
+ // brand's real logo file is laid over the finished picture (bottom right).") and the worker's, written after it
4
+ // ("Your logo was laid over the picture in the top left corner…"). The synchronous route already drops the first when
5
+ // the worker moved the logo (lib/logo-note.mjs dropMovedLogoClaim, 2026-10-19); this path joined both, so one reply put
6
+ // the logo in two corners. Same rule, here, because the CLI copy of this tree cannot import ../lib.
7
+ // tools/journey-qa-1019-check.mjs runs it against the server's own function on the same cases. Pure.
8
+ const ROUTE_CLAIM = /\s*The brand's real logo file is laid over the finished picture \([^)]*\)\./;
9
+ export function joinQueuedImageNote(routeNote, raw) {
10
+ const lo = raw && raw.logoOverlay;
11
+ let route = String(routeNote ?? '');
12
+ if (lo && lo.applied === true && lo.chosen && lo.chosen !== 'asked') route = route.replace(ROUTE_CLAIM, '');
13
+ return [route.trim(), String(raw?.productNote ?? '').trim()].filter(Boolean).join(' ');
14
+ }
@@ -52,6 +52,7 @@
52
52
  export const NEVER_GATE = new Set([
53
53
  'search_meta_ads', 'search_google_ads', 'search_linkedin_ads', 'search_tiktok',
54
54
  'search_instagram', 'search_youtube', 'search_reddit', 'search_threads', 'fetch_social_data',
55
+ 'tiktok_ideas', // public TikTok post lookups on our research key + one writing call, never a TikTok connection (2026-11-03)
55
56
  'search_meta_places', // place search runs on our data provider and a web search, never on a Meta connection (2026-10-27)
56
57
  ]);
57
58
 
@@ -104,6 +105,10 @@ export const TOOL_PROVIDER_RULES = [
104
105
  [/notion/, 'notion'],
105
106
  // Canva (2026-10-27): the user's OWN Canva, offered only to a workspace that connected it.
106
107
  [/canva/, 'canva'],
108
+ // Klaviyo (2026-11-03): the brand's OWN email marketing, offered only to a workspace that connected it.
109
+ [/klaviyo/, 'klaviyo'],
110
+ // Mailchimp (2026-11-03): the brand's OWN email marketing, offered only to a workspace that connected it.
111
+ [/mailchimp/, 'mailchimp'],
107
112
  // ── posting-only channels ──
108
113
  [/youtube/, 'youtube'],
109
114
  [/google_business|business_location|^list_business_(categories|attributes)$|^business_google_updated$/, 'google_business'],
@@ -111,6 +116,13 @@ export const TOOL_PROVIDER_RULES = [
111
116
  [/onedrive/, 'microsoft_onedrive'],
112
117
  [/bluesky/, 'bluesky'],
113
118
  [/telegram/, 'telegram'],
119
+ // WordPress, Discord and Slack publishing (2026-11-03): the brand's OWN site and webhooks. Discord/Slack posting rides the
120
+ // same connector row as their alerts (a second, announcement webhook), so the provider is the bare name.
121
+ [/wordpress/, 'wordpress'],
122
+ [/discord/, 'discord'],
123
+ [/slack/, 'slack'],
124
+ // Whop (2026-11-03): the brand's OWN whop, offered only to a workspace whose whop installed Hermoso.
125
+ [/whop/, 'whop'],
114
126
  // TIKTOK IS THREE CREDENTIALS, AND ONLY FIVE TOOLS RIDE THE POSTING ONE (2026-10-29). Until this date the bare rule sent
115
127
  // every remaining tiktok name to 'tiktok', so 41 Business, analytics and creator-marketplace tools were offered by the
116
128
  // POSTING login while their routes spend the TikTok Ads advertiser token or its account-holder grant: a TikTok Ads-only
@@ -126,13 +138,12 @@ export const TOOL_PROVIDER_RULES = [
126
138
  ];
127
139
 
128
140
  // The provider a tool needs, or null when this module cannot attribute it. null ⇒ NEVER dropped (property 2).
129
- // ── PROVIDERS HERMOSO DOES NOT OFFER, AND AS OF NOW WILL NOT (2026-09-03) ────────────────────────────────
130
- // "make sure posting to reddit or snapchat are never offered, never described, never wasting context in a tools
131
- // list". Reddit organic posting needs Reddit's API approval we do not have; Snapchat posting is not even built. Both
132
- // have ADS connectors that are live and are NOT in this set (`reddit_ads`, `snapchat_ads`). A tool whose provider
133
- // is here is not registered on any roster, not listed by find_tools, not offered by the Studio agent, and not
134
- // counted. Gating at call time was not enough: a disabled tool still costs its schema in every canon and its name
135
- // in every listing. If Reddit ever approves the app, remove it from this set and the whole surface returns.
141
+ // ── PROVIDERS HERMOSO DOES NOT OFFER: REDDIT AND SNAPCHAT ORGANIC POSTING, PERMANENTLY ────────────────────────
142
+ // Permanently not offered (owner 2026-11-03: "reddit posting and snapchat posting are never happening … they should
143
+ // never be in the static list or anywhere"). Their OAuth descriptors, gates, tiles, routes and tools were DELETED that
144
+ // day; this set is the BELT that stays, so a tool named for either provider that ever reappears is not registered on
145
+ // any roster, not listed by find_tools, not offered by the Studio agent and not counted. The ADS connectors are live
146
+ // and are NOT in this set (`reddit_ads`, `snapchat_ads`). Never remove an entry from this set.
136
147
  export const UNOFFERED_PROVIDERS = new Set(['reddit', 'snapchat']);
137
148
  export function toolUnoffered(name) { const p = toolProvider(name); return !!p && UNOFFERED_PROVIDERS.has(p); }
138
149
 
@@ -143,7 +154,7 @@ export function toolUnoffered(name) { const p = toolProvider(name); return !!p &
143
154
  // the Studio agent's schema and executor) asks THIS function, so the answer cannot differ between them.
144
155
  // The set mirrors lib/multi-account.mjs MULTI_ACCOUNT_PROVIDERS, which the npm package cannot import;
145
156
  // tools/multi-account-arg-check.mjs fails when the two differ, and DERIVES the tool split below from server.js.
146
- export const MULTI_ACCOUNT_CHANNELS = Object.freeze(new Set(['tiktok', 'x', 'youtube', 'threads', 'bluesky', 'telegram', 'reddit', 'pinterest', 'instagram']));
157
+ export const MULTI_ACCOUNT_CHANNELS = Object.freeze(new Set(['tiktok', 'x', 'youtube', 'threads', 'bluesky', 'telegram', 'pinterest', 'instagram']));
147
158
  // Filed under a multi-account channel, but routed through a connection that holds ONE grant per brand, so `account`
148
159
  // would do nothing there: these two Pinterest reads ride the ad-account (pinterest_ads) row. (The TikTok Business and
149
160
  // Ads tools are no longer filed under 'tiktok' at all — only the five posting tools are — so they need no entry.)
@@ -227,7 +238,14 @@ export const SHOPIFY_STORE_TOOL_RE = /shopify/;
227
238
  export const toolNeedsShopifyStore = (name) => SHOPIFY_STORE_TOOL_RE.test(String(name || ''));
228
239
  export function toolHeldForNoShopifyStore(name, conn) { return !!(conn && conn.shopifyStore === false && toolNeedsShopifyStore(name)); }
229
240
  export const SHOPIFY_STORE_NOTE = 'works only for a Shopify store that installed Hermoso from the Shopify App Store, and this account has no linked store. An existing Hermoso account cannot be linked to a store from here; a merchant who did install it opens Hermoso once from their Shopify admin (Apps \u2192 Hermoso).';
230
- export function toolWithheldByServer(name, conn) { return !!(conn && ((conn.withheld instanceof Set && conn.withheld.has(String(name || ''))) || toolHeldForNoShopifyStore(name, conn))); }
241
+ // ── A TOOL THAT SHIPS DARK (2026-11-03, owner: "reangle_video can't stay offered while it fails its one promise") ──
242
+ // The opposite polarity of `withheld`: a tool here is OFF unless the server's providers read names it in `flagsOn`
243
+ // (server.js reangleOffered(), env REANGLE_VIDEO=on). FAIL CLOSED: no read, a failed read or an older server all keep
244
+ // it hidden, so the docs build, a CLI with no session and an unreadable store never offer a capability that is not
245
+ // ready. The route and its job types refuse it for free as well, so a stale roster cannot spend.
246
+ export const FLAGGED_OFF_TOOLS = new Set(['reangle_video']);
247
+ export function toolFlaggedOff(name, conn) { const n = String(name || ''); return FLAGGED_OFF_TOOLS.has(n) && !(conn && conn.flagsOn instanceof Set && conn.flagsOn.has(n)); }
248
+ export function toolWithheldByServer(name, conn) { return toolFlaggedOff(name, conn) || !!(conn && ((conn.withheld instanceof Set && conn.withheld.has(String(name || ''))) || toolHeldForNoShopifyStore(name, conn))); }
231
249
  export function toolHeldBackByConnectors(name, conn) {
232
250
  if (toolWithheldByServer(name, conn)) return true; // the server says this capability is not offered now
233
251
  if (!conn || !conn.readOk) return false; // property 1 — fail OPEN on an unreadable store
@@ -5,7 +5,7 @@
5
5
  // different selling angle with its headline and CTA on the image, in 1:1, 4:5 or 9:16. We had every part of that
6
6
  // (draft_brand, the product library, the batch planner the web Studio fans into up to 30 ranked statics) but an agent
7
7
  // had to stitch it together itself: plan_variations (capped at 8, plans only), then one generate_image per variant —
8
- // and a CLI generate_image held past 60s died client-side while the render still billed. This is the one-call shape.
8
+ // and a CLI generate_image held past 60s timed out client-side while the render still billed. This is the one-call shape.
9
9
  //
10
10
  // Everything here is PURE so tools/static-ads-batch-check.mjs runs the real functions. Byte-twin: cli/mcp/static-batch.mjs.
11
11
 
@@ -144,7 +144,10 @@ export function staticAdPrompt(v, { logo = 'server', productPhoto = false, layou
144
144
  x.supporting ? `supporting line "${String(x.supporting).trim()}"` : '',
145
145
  x.cta ? `call to action "${String(x.cta).trim()}"` : '',
146
146
  ].filter(Boolean);
147
- if (words.length) lines.push(`On-image text, spelled exactly as given and nothing else: ${words.join(', ')}.${L ? '' : ' Bold ad typography with a clear hierarchy, crisp and correctly spelled.'} A finished, ready-to-run static ad.`);
147
+ // TEXT SLOTS (2026-11-03, catalog audit §3): every slot carries its literal
148
+ // quoted words, and type under ~2% of the picture's height turns to gibberish, so no line is set that small and a line
149
+ // set small is 4-6 words (a longer one breaks onto two lines rather than shrinking).
150
+ if (words.length) lines.push(`On-image text, spelled exactly as given and nothing else: ${words.join(', ')}.${L ? '' : ' Bold ad typography with a clear hierarchy, crisp and correctly spelled.'} No line of text is smaller than about 2% of the picture's height (smaller type turns to gibberish); a line set in small type holds at most 4-6 words, so a longer one breaks onto two lines instead of shrinking. A finished, ready-to-run static ad.`);
148
151
  if (L) lines.push('Show the product once (one unit as it is sold) unless the layout is a flat lay or a set of its range; any person has at most two hands and every hand in frame belongs to a visible arm.');
149
152
  if (productPhoto) lines.push('The first reference image is the REAL product: keep its shape, colours and label exactly.');
150
153
  // THE LOGO IS LAID ON, NEVER PAINTED (2026-10-06): 'overlay' (and the old 'attached' / 'server') add nothing here. The
@@ -128,6 +128,19 @@ export function finishedRenderKind(tool, r) {
128
128
  return '';
129
129
  }
130
130
 
131
+ // ── A FINISHED RENDER ON A WIDGET HOST NEEDS NO OTHER PICTURE (2026-11-03) ───────────────────────────────────────────
132
+ // Measured on ChatGPT: after generate_image returned a finished Fernfizz ad, the reply showed ChatGPT's OWN image-search
133
+ // result beside it (a 3D-model page from an unrelated site, rendered as a "search image" chip), because the model wanted
134
+ // a picture in its answer and our card was the only one it had been told not to repeat. The card already shows the
135
+ // result, so the reply says so and asks for no other picture. Agent-facing, not user-facing. Pure, no imports.
136
+ export const WIDGET_RESULT_NOTE = (kind) => `Note for the assistant, not for the user: the Hermoso card in this reply already shows the finished ${kind === 'video' ? 'video' : 'image'}. Show no other picture with it, no image search and no web images, because none of them is this result.`;
137
+ export function withWidgetResultNote(tool, r, widgetHost) {
138
+ if (!widgetHost) return r;
139
+ const kind = finishedRenderKind(tool, r);
140
+ if (!kind) return r;
141
+ return { ...r, content: [...r.content, { type: 'text', text: WIDGET_RESULT_NOTE(kind) }] };
142
+ }
143
+
131
144
  // ── OUT OF CREDITS ON A HOST THAT FORBIDS SELLING CREDITS (2026-09-30, OpenAI's plugin guidelines) ─────────────────
132
145
  // "Plugins may: explain that a certain feature is not available with the user's current plan or entitlement; link to
133
146
  // an informational page … Plugins may not: link directly to a checkout … or promote upgrades." ChatGPT is not offered