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 +10 -7
- package/bin/hermoso.mjs +1 -1
- package/mcp/chatgpt-roster.mjs +51 -0
- package/mcp/client.mjs +5 -4
- package/mcp/http.mjs +25 -24
- package/mcp/openai-extensions.mjs +3 -3
- package/mcp/queued-image-note.mjs +14 -0
- package/mcp/roster-scope.mjs +27 -9
- package/mcp/static-batch.mjs +5 -2
- package/mcp/tool-hints.mjs +13 -0
- package/mcp/tools.mjs +1544 -238
- package/mcp/well-formed.mjs +1 -1
- package/package.json +2 -2
- package/skills/hermoso-ad-from-brand/SKILL.md +3 -0
- package/skills/hermoso-generate/SKILL.md +8 -0
- package/skills/hermoso-marketing/SKILL.md +1 -0
- package/skills/hermoso-product-photoshoot/SKILL.md +27 -12
- package/skills/hermoso-research/SKILL.md +1 -0
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
|
-
**
|
|
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 — **
|
|
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
|
|
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
|
|
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** — **
|
|
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`)
|
|
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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
129
|
-
// `initialize` handshakes
|
|
130
|
-
//
|
|
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
|
|
133
|
-
//
|
|
134
|
-
// because the cost is CHURN, not retention — one client
|
|
135
|
-
// FEEDING the churn:
|
|
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
|
-
//
|
|
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
|
-
//
|
|
154
|
-
//
|
|
155
|
-
//
|
|
156
|
-
//
|
|
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
|
|
225
|
-
// hosted
|
|
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
|
|
230
|
-
//
|
|
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
|
-
|
|
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
|
|
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).
|
|
538
|
-
// 10-15 publish
|
|
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
|
|
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'],
|
|
1283
|
-
google_business: ['Google Business Profile', 'channels'], whatsapp: ['WhatsApp', '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
|
+
}
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -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
|
|
130
|
-
//
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
//
|
|
134
|
-
//
|
|
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', '
|
|
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
|
-
|
|
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
|
package/mcp/static-batch.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
package/mcp/tool-hints.mjs
CHANGED
|
@@ -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
|