hermoso 0.1.279 → 0.1.281
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 +1 -1
- package/mcp/roster-scope.mjs +2 -2
- package/mcp/tool-hints.mjs +1 -1
- package/mcp/tools.mjs +59 -48
- package/package.json +1 -1
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 (
|
|
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.
|
package/mcp/roster-scope.mjs
CHANGED
|
@@ -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 (
|
|
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
|
|
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'
|
package/mcp/tool-hints.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
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 (
|
|
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) —
|
|
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
|
|
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 (
|
|
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,
|
|
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 —
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
|
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).
|
|
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 (
|
|
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.
|
|
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,
|
|
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.
|
|
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
|
-
//
|
|
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 (
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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 (
|
|
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
|
|
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
|
|
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 (
|
|
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
|
-
//
|
|
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 (
|
|
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
|
|
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.
|
|
@@ -18613,22 +18613,33 @@ function memoryNoteVerdict(text) {
|
|
|
18613
18613
|
}));
|
|
18614
18614
|
server.registerTool('remove_member', {
|
|
18615
18615
|
title: 'Remove a teammate',
|
|
18616
|
-
description: 'Remove a member from this brand workspace by email — they lose access (you can re-invite them later).
|
|
18616
|
+
description: 'Remove a member from this brand workspace by email — they lose access (you can re-invite them later), AND every connection THEY made on this brand is disconnected with them: their own X, LinkedIn, TikTok, YouTube, Pinterest, Threads… connections (on a channel holding several accounts, only the accounts they added), and any extra login or authorization they added. Connections the owner or anyone else made are never touched, and one with no record of who connected it is treated as the owner\'s. Queued posts that would publish through their accounts, or that they scheduled themselves, will not go out. The unconfirmed call reports exactly which connections and how many scheduled posts — relay that to the user, then call with confirm:true.',
|
|
18617
18617
|
inputSchema: {
|
|
18618
18618
|
email: z.string().describe('the member’s email'),
|
|
18619
18619
|
confirm: z.boolean().optional().describe('REQUIRED true'),
|
|
18620
18620
|
},
|
|
18621
|
-
outputSchema: { ok: z.boolean().optional(), removed: z.boolean().optional(), email: z.string().optional(), members: z.array(z.any()).optional() },
|
|
18621
|
+
outputSchema: { ok: z.boolean().optional(), removed: z.boolean().optional(), email: z.string().optional(), members: z.array(z.any()).optional(), connectionsDisconnected: z.array(z.any()).optional(), scheduledPostsNotGoingOut: z.number().optional() },
|
|
18622
18622
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
18623
18623
|
}, wrap(async (a) => {
|
|
18624
|
-
|
|
18624
|
+
// THE CONFIRM NAMES WHAT GOES WITH THEM (2026-09-24): removing a teammate disconnects the connections they made,
|
|
18625
|
+
// so the sentence the user approves must say which, from the same plan the removal runs. A 404 (not a member)
|
|
18626
|
+
// surfaces as the error it is; a preview that could not be read is said, never rendered as "nothing".
|
|
18627
|
+
if (a.confirm !== true) {
|
|
18628
|
+
let pv = null;
|
|
18629
|
+
try { pv = await apiGet('/api/team/remove-preview', { email: a.email || '' }); }
|
|
18630
|
+
catch (e) { if (e?.status === 404 || e?.status === 403) throw e; }
|
|
18631
|
+
return ok(pv?.message
|
|
18632
|
+
? `${pv.message} Confirm with the user, then call again with confirm:true.`
|
|
18633
|
+
: `This will remove ${a.email || '(no email)'} from the workspace and disconnect any connections they made on this brand. I could not read which ones just now — check Settings ▸ Connectors before confirming. Confirm with the user, then call again with confirm:true.`,
|
|
18634
|
+
{ ok: false, ...(pv ? { connectionsDisconnected: pv.connectionsDisconnected || [], scheduledPostsNotGoingOut: pv.scheduledPostsNotGoingOut || 0 } : {}) });
|
|
18635
|
+
}
|
|
18625
18636
|
// The route 404s BY NAME when the address is not a member (a typo, a stale address, the wrong brand) — that error
|
|
18626
18637
|
// surfaces through wrap(). What reaches here is a real change or an honest no-op, and the sentence comes from the
|
|
18627
18638
|
// server's READ-BACK of the members list, never from what we asked for.
|
|
18628
18639
|
const d = await apiPost('/api/team/remove', { email: a.email });
|
|
18629
18640
|
if (d.alreadyRemoved) return ok(d.message || `${a.email} already had no access to this workspace — nothing changed.`, { ok: false, removed: false, alreadyRemoved: true, email: a.email });
|
|
18630
18641
|
const left = (d.members || []).map(m => ` \u2022 ${m.email} \u2014 ${m.role || 'member'}`);
|
|
18631
|
-
return ok(`${d.message || `${a.email} no longer has access to this workspace.`}${left.length ? `\nStill on this workspace:\n${left.join('\n')}` : ''}`, { ok: true, removed: !!d.removed, email: d.email || a.email, members: d.members });
|
|
18642
|
+
return ok(`${d.message || `${a.email} no longer has access to this workspace.`}${left.length ? `\nStill on this workspace:\n${left.join('\n')}` : ''}`, { ok: true, removed: !!d.removed, email: d.email || a.email, members: d.members, connectionsDisconnected: d.connectionsDisconnected || [], scheduledPostsNotGoingOut: d.scheduledPostsNotGoingOut || 0 });
|
|
18632
18643
|
}));
|
|
18633
18644
|
server.registerTool('set_role', {
|
|
18634
18645
|
title: 'Change a teammate’s role',
|
|
@@ -18688,7 +18699,7 @@ function memoryNoteVerdict(text) {
|
|
|
18688
18699
|
const gs = d.groups || [];
|
|
18689
18700
|
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
18701
|
// 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 (
|
|
18702
|
+
// here; without the marker it reads exactly like a live bug and gets re-diagnosed (2026-09-03, after an
|
|
18692
18703
|
// hour went into a Meta 500 from 08-11 that had been fixed the same day). The server ranks these last; this
|
|
18693
18704
|
// is the half a reader sees. "Dormant" and not "fixed": no hits for N days is strong evidence, not proof.
|
|
18694
18705
|
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 +18766,7 @@ function memoryNoteVerdict(text) {
|
|
|
18755
18766
|
// this one is named and described as the fast single-brand path. The platform list is now decided HERE and the
|
|
18756
18767
|
// spread cannot reach it.
|
|
18757
18768
|
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 (
|
|
18769
|
+
// SURFACE THE ACTUAL ADS (2026-07-21: ChatGPT got only "Pulled ads for X" — the structured data never
|
|
18759
18770
|
// reached the user). Flatten each platform's ads into compact rows + image blocks, like the search_* tools.
|
|
18760
18771
|
const platforms = ['facebook', 'google', 'linkedin'];
|
|
18761
18772
|
const rows = [], urls = [];
|
|
@@ -18771,12 +18782,12 @@ function memoryNoteVerdict(text) {
|
|
|
18771
18782
|
// AND `media` MUST NEVER FALL BACK TO A LINK. It is the creative field, and adSpyCard uses it as the
|
|
18772
18783
|
// tile's picture when no explicit thumb exists — so falling through to adUrl/link_url handed the card an
|
|
18773
18784
|
// 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 (
|
|
18785
|
+
// eight-ad grid were showing (2026-08-24): not a missing creative, a page URL in an image slot.
|
|
18775
18786
|
// The link still reaches the tile through `link` below, which is where a page URL belongs.
|
|
18776
18787
|
const media = s.videos?.[0]?.video_sd_url || s.cards?.[0]?.video_sd_url || img || null;
|
|
18777
18788
|
// A GOOGLE TEXT AD HAS NO PICTURE AND THAT IS NORMAL — its headline IS the creative, and it lives under
|
|
18778
18789
|
// 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" (
|
|
18790
|
+
// filled with identical blank tiles reading "no creative / Liquid Death GOOGLE" (2026-08-24).
|
|
18780
18791
|
// Measured on a real pull: every Google row came back format:'text', imageUrl null, adUrl null, and a real
|
|
18781
18792
|
// headline in variations[0]. The ad was there all along; we were not reading it.
|
|
18782
18793
|
const gv = Array.isArray(ad.variations) ? ad.variations[0] : null;
|
|
@@ -18784,7 +18795,7 @@ function memoryNoteVerdict(text) {
|
|
|
18784
18795
|
|| (gv && (gv.headline || gv.description)) || '';
|
|
18785
18796
|
// THE TILE LINKS TO THE AD, NOT TO THE SHOP. Clicking a competitor's ad card used to open its DESTINATION
|
|
18786
18797
|
// — 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 (
|
|
18798
|
+
// clicking a video tile wants to WATCH THE AD (2026-08-24). The library page plays the video, shows
|
|
18788
18799
|
// the full copy, every placement, the run dates AND where it points, so it strictly contains the
|
|
18789
18800
|
// destination rather than replacing it. Meta hands us that page as `ad.url`; Google as `adUrl`. The
|
|
18790
18801
|
// destination survives only as the last resort, for a row that carries no library page at all.
|
|
@@ -18955,7 +18966,7 @@ function memoryNoteVerdict(text) {
|
|
|
18955
18966
|
const d = await apiSSE('/api/explore/chat', { messages: [{ role: 'user', content: query }], brand: brandObj });
|
|
18956
18967
|
const res = d.results || [];
|
|
18957
18968
|
// pull a still image URL out of each normalized card (ad OR tiktok/social shapes) so ChatGPT/Claude SHOW the
|
|
18958
|
-
// creatives inline (
|
|
18969
|
+
// creatives inline (2026-07-21: research_ads was returning text only, no images)
|
|
18959
18970
|
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
18971
|
const urls = [...new Set(res.map(imgUrl).filter((u) => typeof u === 'string' && /^https?:\/\//.test(u)))].slice(0, 4);
|
|
18961
18972
|
const widget = hostRendersWidgets();
|
|
@@ -18997,7 +19008,7 @@ function memoryNoteVerdict(text) {
|
|
|
18997
19008
|
const trunc = (s, n = 200) => { const t = String(s || '').replace(/\s+/g, ' ').trim(); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
18998
19009
|
const nAds = (n) => Math.min(25, Math.max(1, Math.round(+n) || 8));
|
|
18999
19010
|
// 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 —
|
|
19011
|
+
// markdown-image links out of tool text — the owner got a text-only reply; attached image CONTENT BLOCKS display in
|
|
19001
19012
|
// both ChatGPT and Claude). Plus an explicit creative-URL list so the model can hand the user clickable links
|
|
19002
19013
|
// (videos especially), and a parent-brand nudge on zero results (SuperBelly is advertised by Blume — a name
|
|
19003
19014
|
// miss must trigger resolution, not a shrug).
|
|
@@ -19161,7 +19172,7 @@ function memoryNoteVerdict(text) {
|
|
|
19161
19172
|
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
19173
|
return ok(`${d.note}\n${lines.join('\n')}${mk}`, d);
|
|
19163
19174
|
}));
|
|
19164
|
-
// TOPIC SEARCH (2026-09-15
|
|
19175
|
+
// TOPIC SEARCH (2026-09-15: search a named brand, "but not their ads themselves, just posts about them … similarly
|
|
19165
19176
|
// just broad things like coffee"): the posts ABOUT a subject from anyone, all three organic platforms in one call.
|
|
19166
19177
|
// find_creators is this same search one step later (posts folded into people); the per-platform search_* tools are
|
|
19167
19178
|
// it one platform at a time.
|
|
@@ -19386,7 +19397,7 @@ function memoryNoteVerdict(text) {
|
|
|
19386
19397
|
}, wrap(async ({ save, ...a }) => {
|
|
19387
19398
|
const d = await apiPost('/api/brand/draft', a);
|
|
19388
19399
|
const p = d.profile || d;
|
|
19389
|
-
// ALWAYS TRY THE WEBSITE (
|
|
19400
|
+
// ALWAYS TRY THE WEBSITE (2026-07-28). /api/brand/draft returns the PROFILE only — it never fetched a single
|
|
19390
19401
|
// product photo, so an MCP-onboarded brand was structurally photo-less even with a perfectly good domain, and every
|
|
19391
19402
|
// later plan_ad/render_ad on it invented the packaging. This tool's own outputSchema has advertised `logo`,
|
|
19392
19403
|
// `products` and `productImages` since it shipped; nothing ever filled them. Pull them from the SAME endpoint the
|
|
@@ -19775,7 +19786,7 @@ function memoryNoteVerdict(text) {
|
|
|
19775
19786
|
return ok(text, d);
|
|
19776
19787
|
}));
|
|
19777
19788
|
|
|
19778
|
-
// CLONE IS THE NAME, AND THE OLD ONE STAYS CALLABLE (2026-09-12
|
|
19789
|
+
// CLONE IS THE NAME, AND THE OLD ONE STAYS CALLABLE (2026-09-12: "Is remix and clone merged on other surfaces as
|
|
19779
19790
|
// 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
19791
|
// clone_static is the canonical name, and remix_static is kept, registered with the SAME handler, because an agent or a
|
|
19781
19792
|
// host that cached the old roster calls tools by name and a missing name is a hard failure for that caller.
|
|
@@ -20062,7 +20073,7 @@ function memoryNoteVerdict(text) {
|
|
|
20062
20073
|
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
20074
|
// THE POSTS THEMSELVES, ranked inside each channel — answers "which of our posts did best" even when every post
|
|
20064
20075
|
// 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
|
|
20076
|
+
// (what it shows, its format, its link) — the caption is only the fallback label (2026-09-11).
|
|
20066
20077
|
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
20078
|
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
20079
|
// 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.
|
|
3
|
+
"version": "0.1.281",
|
|
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",
|