hermoso 0.1.532 → 0.1.533
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 +12 -5
- package/mcp/chatgpt-roster.mjs +3 -2
- package/mcp/client.mjs +5 -1
- package/mcp/http.mjs +37 -14
- package/mcp/tool-cost.mjs +3 -0
- package/mcp/tool-hints.mjs +111 -6
- package/mcp/tools.mjs +958 -136
- package/package.json +2 -2
- package/skills/hermoso-edit-timeline/SKILL.md +3 -3
package/README.md
CHANGED
|
@@ -24,8 +24,12 @@ campaigns behind them, all over [MCP](https://modelcontextprotocol.io) tools, a
|
|
|
24
24
|
|
|
25
25
|
## Set up in Claude, ChatGPT or Cursor
|
|
26
26
|
|
|
27
|
-
**Claude (claude.ai, Claude Desktop):**
|
|
28
|
-
|
|
27
|
+
**Claude (claude.ai, Claude Desktop):** one click. **[Add to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Hermoso&connectorUrl=https%3A%2F%2Fapp.hermoso.ai%2Fmcp%3Fsrc%3Dreadme)**
|
|
28
|
+
opens Claude's *Add custom connector* dialog with Hermoso already filled in. Press Continue, choose **Sign in now**
|
|
29
|
+
if Claude asks about Authentication, then sign in with your Hermoso account.
|
|
30
|
+
|
|
31
|
+
By hand: Customize → Connectors → Add → *Add custom connector*, paste the URL below, choose **Sign in now** under
|
|
32
|
+
Authentication, then Add and Connect. On a Team or Enterprise plan an Owner adds it for the organization first.
|
|
29
33
|
|
|
30
34
|
```text
|
|
31
35
|
https://app.hermoso.ai/mcp
|
|
@@ -67,7 +71,7 @@ Ask in your own words. The agent picks the tools:
|
|
|
67
71
|
- "Compare last week's Google Ads and Meta Ads results and tell me what to pause."
|
|
68
72
|
- "Schedule this video to TikTok, Instagram Reels and YouTube Shorts for Tuesday at 9am."
|
|
69
73
|
|
|
70
|
-
**
|
|
74
|
+
**1231 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
71
75
|
catalog with exact per-render credit costs plus the full capability map.
|
|
72
76
|
|
|
73
77
|
**No markup on models** on Starter, Pro and Studio. Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
|
|
@@ -242,7 +246,10 @@ the routes.
|
|
|
242
246
|
|
|
243
247
|
## Instant: the hosted Claude.ai connector
|
|
244
248
|
|
|
245
|
-
|
|
249
|
+
**[Add to Claude](https://claude.ai/customize/connectors?modal=add-custom-connector&connectorName=Hermoso&connectorUrl=https%3A%2F%2Fapp.hermoso.ai%2Fmcp%3Fsrc%3Dreadme)** opens Claude with the connector already filled in: press Continue, sign in with your
|
|
250
|
+
Hermoso account, and you are done.
|
|
251
|
+
|
|
252
|
+
By hand: paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude → Customize → Connectors → Add → *Add custom connector*,
|
|
246
253
|
press Continue, choose **Sign in now** under Authentication (Claude's detector pre-selects "No sign-in" because our
|
|
247
254
|
discovery handshake is open, and with that your first request comes back "Authentication required"), press Add and
|
|
248
255
|
Connect, approve with your Hermoso account, and you are done: the full toolset with your saved brand context, billed to your plan.
|
|
@@ -304,7 +311,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
304
311
|
|
|
305
312
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
306
313
|
|
|
307
|
-
### What the
|
|
314
|
+
### What the 1231 tools cover
|
|
308
315
|
|
|
309
316
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
310
317
|
Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
|
package/mcp/chatgpt-roster.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
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
2
|
// tools/lib/chatgpt-roster-usage.json). The provider families ChatGPT lists, best first; a family is all or nothing.
|
|
3
3
|
// The core (every tool that needs no connection) is always listed and is not in these lists. Cap: CHATGPT_TOOL_CAP
|
|
4
|
-
// (
|
|
4
|
+
// (1000) in mcp/tools.mjs, under ChatGPT's own 1,000-tool limit; the families get that minus the room
|
|
5
|
+
// the ChatGPT extension tools take (CHATGPT_EXT_TOOL_ROOM), because those tools are on the same list.
|
|
5
6
|
export const CHATGPT_ROSTER_FAMILIES = Object.freeze([
|
|
6
7
|
'google_drive', // long tail, pinned (plugin skills)
|
|
7
8
|
'intercom', // long tail, pinned (plugin skills)
|
|
@@ -49,4 +50,4 @@ export const CHATGPT_ROSTER_LEFT_OUT = Object.freeze([
|
|
|
49
50
|
'vercel', // long tail
|
|
50
51
|
'whop', // long tail
|
|
51
52
|
]);
|
|
52
|
-
export const CHATGPT_ROSTER_META = Object.freeze({ cap:
|
|
53
|
+
export const CHATGPT_ROSTER_META = Object.freeze({ cap: 1000, usageCollectedAt: "2026-11-03T20:25:36.816Z" });
|
package/mcp/client.mjs
CHANGED
|
@@ -124,6 +124,10 @@ function headers(extra = {}) {
|
|
|
124
124
|
// reliable discriminator here. Forgeable, and harmlessly so: the worst a caller achieves is under-reporting
|
|
125
125
|
// their own activity, which is why this decides bookkeeping and nothing else.
|
|
126
126
|
if (ctx) h['x-hermoso-inproc'] = '1';
|
|
127
|
+
// WHICH HOST THE PERSON IS IN, FOR A REFUSAL ONLY (2026-11-12, lib/buy-page.mjs). A hosted session remembers its client
|
|
128
|
+
// (mcp/http.mjs rememberedClient); a ChatGPT one is named here so the server can mail the out-of-credits note in ~10
|
|
129
|
+
// minutes, since nothing can be bought inside ChatGPT. Forgeable and harmless: the worst a caller does is hurry its own email.
|
|
130
|
+
if (ctx && /openai|chatgpt/i.test(String(ctx.client || ''))) h['x-hermoso-host'] = 'chatgpt';
|
|
127
131
|
// A PRICE QUOTE AND THE AGENT'S STATED GOAL (2026-11-11, spend-guard.mjs): get_cost / expected_credits run the tool's own
|
|
128
132
|
// handler with every request marked as a quote (the server stops each spending point and prices it); `context` rides
|
|
129
133
|
// as one redacted, percent-encoded sentence the server puts on the ledger row of whatever the call bills.
|
|
@@ -175,7 +179,7 @@ async function unwrap(res, { read = false } = {}) {
|
|
|
175
179
|
// ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
|
|
176
180
|
// throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
|
|
177
181
|
// `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
|
|
178
|
-
throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(res.status === 429 ? { retryAfter: Number(body?.retryAfter) || Number(res.headers?.get?.('retry-after')) || 60 } : {}), ...(typeof body?.code === 'string' ? { apiCode: body.code } : {}), ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}) }); // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
|
|
182
|
+
throw Object.assign(new Error(msg), { status: res.status, _viaApi: true, ...(res.status === 429 ? { retryAfter: Number(body?.retryAfter) || Number(res.headers?.get?.('retry-after')) || 60 } : {}), ...(typeof body?.code === 'string' ? { apiCode: body.code } : {}), ...(body?.connector ? { connector: body.connector } : {}), ...(typeof body?.connectUrl === 'string' ? { connectUrl: body.connectUrl } : {}), ...(body?.meta?.videoChoice && typeof body.meta.videoChoice === 'object' ? { videoChoice: body.meta.videoChoice } : {}), ...(body?.metaAuthHold === true ? { metaAuthHold: true } : {}), ...(body?.meta?.welcomeClaim && typeof body.meta.welcomeClaim === 'object' && body.meta.welcomeClaim.url ? { welcomeClaim: { credits: Number(body.meta.welcomeClaim.credits) || 0, url: String(body.meta.welcomeClaim.url) } } : {}), ...(body?.remedy && typeof body.remedy === 'object' && typeof body.remedy.code === 'string' ? { remedy: body.remedy } : {}), ...(body?.buy && typeof body.buy === 'object' && body.buy.pack ? { buy: body.buy } : {}) }); // `buy` (2026-11-12): the two priced doors a balance refusal carries (server buyDoorsFor): a pack and a plan, each with its price and a checkout link // `remedy` (2026-10-25): the server's structured next step (a publish outcome, Meta's security hold), handed on whole to wrap()'s _meta and /v1 // `videoChoice` rides a 402 for a video the caller expects and cannot afford (server videoChoiceFor) — wrap() spells its options out instead of a bare top-up line
|
|
179
183
|
}
|
|
180
184
|
// WHAT THIS CALL WAS CHARGED (2026-10-19). A paid route answers {data, creditsUsed}, and the unwrap below drops the
|
|
181
185
|
// sibling, so a synchronous paid tool's reply could never say what it cost. It is added to the running tool call's
|
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, anonDiscoveryGroups } from './tools.mjs';
|
|
19
|
+
import { registerTools, MCP_INSTRUCTIONS, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS, anonDiscoveryGroups, widgetHostScope, withFirstRunLine, firstRunOffers } from './tools.mjs';
|
|
20
20
|
import { chatgptExtRequested } from './openai-extensions.mjs';
|
|
21
21
|
import { mcpCtx, connectedProviders } from './client.mjs';
|
|
22
22
|
|
|
@@ -408,6 +408,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
408
408
|
// A widget host (ChatGPT, whose plugin scanner reads this anonymous roster) gets its own instructions — see
|
|
409
409
|
// MCP_INSTRUCTIONS_WIDGET. The SAME host decision as the roster below, so the two can never disagree.
|
|
410
410
|
const widgetHost = isWidgetHost(clientInfoOf(req.body), req);
|
|
411
|
+
scope = widgetHostScope(scope, widgetHost); // the URL our app handed ChatGPT users is the plain URL there (LEGACY_WIDGET_CONNECT_GROUPS)
|
|
411
412
|
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
|
|
412
413
|
// `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
|
|
413
414
|
// as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
|
|
@@ -457,7 +458,18 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
457
458
|
// doing anything"). Only a tools/call is somebody doing something, so only a tools/call stamps. Auth itself is
|
|
458
459
|
// unchanged in both branches: this decides bookkeeping, never access.
|
|
459
460
|
const didWork = methodsOf(req.body).includes('tools/call');
|
|
460
|
-
|
|
461
|
+
// A KEY STORE THAT COULD NOT BE READ IS NOT A REJECTED KEY (2026-11-11). During the database restart at 22:41 a
|
|
462
|
+
// Codex session's valid hmk_ key was answered with the 401 challenge, logged as "bearer token rejected"; a 401 tells
|
|
463
|
+
// the client to drop its credential and sign in again, for an outage of a few seconds. verifyBearer answers null for
|
|
464
|
+
// an unknown or revoked key and THROWS when the lookup itself fails, so a throw is a 503 the client retries. Same rule
|
|
465
|
+
// as the refresh path in mcp/oauth.mjs ("a failed read is not a revocation").
|
|
466
|
+
let storeDown = false;
|
|
467
|
+
const user = token ? await verifyBearer(token, { stamp: didWork }).catch(() => { storeDown = true; return null; }) : null;
|
|
468
|
+
if (storeDown) {
|
|
469
|
+
connectEvent(req, { step: 'mcp-auth', ok: false, reason: 'key store read failed', detail: `${token.slice(0, 4)}… (${token.length} chars) on ${req.method}` });
|
|
470
|
+
res.setHeader('Retry-After', '5');
|
|
471
|
+
return res.status(503).json({ jsonrpc: '2.0', error: { code: -32000, message: 'Hermoso is briefly unavailable. Please retry in a few seconds; your connection is fine.' }, id: null });
|
|
472
|
+
}
|
|
461
473
|
if (!user && token) connectEvent(req, { step: 'mcp-auth', ok: false, reason: 'bearer token rejected', detail: `${token.slice(0, 4)}… (${token.length} chars) on ${req.method}` });
|
|
462
474
|
if (!user) {
|
|
463
475
|
// No valid bearer: allow ONLY the read-only discovery handshake (POST), fail CLOSED for everything else.
|
|
@@ -520,8 +532,12 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
520
532
|
// Only an `initialize` may mint a session. Anything else naming no session at all is the SDK's own 400 —
|
|
521
533
|
// answered here so we never build a server whose only job would be to reject the request.
|
|
522
534
|
if (!init) return needSession(res);
|
|
523
|
-
const
|
|
524
|
-
if (
|
|
535
|
+
const scope0 = scopeFor(req, res);
|
|
536
|
+
if (scope0 === false) return; // unknown group — already answered 400, and nothing was allocated
|
|
537
|
+
// On ChatGPT the URL our own app handed out (seven named groups, no ads or analytics) is read as the plain URL:
|
|
538
|
+
// a widget host has no executor, so a group it does not list is a feature it does not have. See
|
|
539
|
+
// LEGACY_WIDGET_CONNECT_GROUPS in tools.mjs. The same host decision as `widgetHost` below.
|
|
540
|
+
const scope = widgetHostScope(scope0, isWidgetHost(entry?.client || clientInfoOf(req.body), req));
|
|
525
541
|
sweepSessions(); // make room before allocating, so the cap is a ceiling and not a suggestion
|
|
526
542
|
// ── WHAT THIS CALLER CAN ACTUALLY USE (2026-08-26) ────────────────────────────────────────────────────────
|
|
527
543
|
// One free store read, on the ONE request per session that mints the roster, so the tools we advertise are
|
|
@@ -539,7 +555,20 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
539
555
|
const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
|
|
540
556
|
// ONE host decision for the instructions and the roster (see MCP_INSTRUCTIONS_WIDGET), so the two cannot disagree.
|
|
541
557
|
const widgetHost = isWidgetHost(entry?.client || clientInfoOf(req.body), req);
|
|
542
|
-
|
|
558
|
+
// AN ACCOUNT THAT HAS MADE NOTHING YET, ON EVERY HOST (2026-10-19 for ChatGPT; every host since 2026-11-12). Read once
|
|
559
|
+
// per session, bounded so a slow read can never hold the connect: null (no guidance) is the answer to every failure.
|
|
560
|
+
// Read BEFORE the server is built, because it changes what initialize says: a fresh account's `instructions` open with
|
|
561
|
+
// one line saying how to start (withFirstRunLine in tools.mjs), which a host reads before the user types anything. The
|
|
562
|
+
// first successful tool reply of the session then carries the same next ask (firstRunNote), for hosts that ignore
|
|
563
|
+
// instructions. `offer` is what this session's own instructions can name, so a focused app or the directory cage is
|
|
564
|
+
// never told to offer a tool it does not list.
|
|
565
|
+
const baseInstructions = instructionsFor(scope, { widgetHost });
|
|
566
|
+
let firstRun = null;
|
|
567
|
+
if (typeof firstRunFor === 'function' && user.accountId) {
|
|
568
|
+
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; }
|
|
569
|
+
}
|
|
570
|
+
firstRun = firstRun && typeof firstRun === 'object' && firstRun.fresh === true ? { ...firstRun, offer: firstRunOffers(baseInstructions) } : null;
|
|
571
|
+
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: withFirstRunLine(baseInstructions, firstRun) });
|
|
543
572
|
registerTools(server, { only: scope.groups, directory: scope.directory || false, app: scope.app || null, connectors, widgetHost, hosted: true, ext: extAsked(req, scope), client: entry?.client || rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
544
573
|
const transport = new StreamableHTTPServerTransport({
|
|
545
574
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
@@ -550,15 +579,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
550
579
|
// WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
|
|
551
580
|
// request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
|
|
552
581
|
// tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
|
|
553
|
-
//
|
|
554
|
-
//
|
|
555
|
-
|
|
556
|
-
// 10-15 publish had made nothing, and a live "make me a video ad" chat on a connected account never called us.
|
|
557
|
-
let firstRun = null;
|
|
558
|
-
if (widgetHost && typeof firstRunFor === 'function' && user.accountId) {
|
|
559
|
-
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; }
|
|
560
|
-
}
|
|
561
|
-
entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0, connectSeen: true, firstRun: firstRun && typeof firstRun === 'object' ? firstRun : null };
|
|
582
|
+
// The fresh-account read (firstRun) happened above, before the server was built; the session keeps it so the first
|
|
583
|
+
// successful tool reply can carry the same next ask.
|
|
584
|
+
entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0, connectSeen: true, firstRun };
|
|
562
585
|
if (typeof onSessionStart === 'function') { try { onSessionStart({ accountId: user.accountId || null, userId: user.userId || null, client: entry.client, ua: entry.ua, src: entry.src }); } catch {} }
|
|
563
586
|
// LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
|
|
564
587
|
// ever read is a guess. One line per session (not per call) so a new host identifies itself once and
|
package/mcp/tool-cost.mjs
CHANGED
|
@@ -46,6 +46,9 @@ export const CREATE_FREE_READS = Object.freeze([
|
|
|
46
46
|
// The product catalog (2026-10-06): a store read or write, and a product page read with curl and parsed locally (no
|
|
47
47
|
// model; POST /api/products answers creditsUsed: 0). Each says "Free" / "0 credits" in its own description.
|
|
48
48
|
'list_products', 'add_product', 'refresh_products', 'update_product', 'remove_product',
|
|
49
|
+
// Story films (2026-11-11): the catalog, one card's recipe and starting a run (the outline) are store reads and writes;
|
|
50
|
+
// each says "Free" in its own description. advance_story_film runs the paid stages and stays a model tool.
|
|
51
|
+
'list_story_cards', 'get_story_card', 'start_story_film',
|
|
49
52
|
]);
|
|
50
53
|
// Reads inside `research`. `find_competitors` says "0 credits" in its own description (it is the discovery model,
|
|
51
54
|
// billed to us, not a paid ad-data call); the watch tools only write and read a stored preference — the weekly
|
package/mcp/tool-hints.mjs
CHANGED
|
@@ -81,10 +81,29 @@ export function videoChoiceDraftCall(tool, d) {
|
|
|
81
81
|
// then try to upsell the plans/credits?"). The agent says plainly that the account does not have enough credits and offers
|
|
82
82
|
// credits and plans; the image and the light draft stay listed LAST, for a user who asks for something cheaper themselves,
|
|
83
83
|
// never as the agent's own pick. A video-plus-static ask is priced whole (`needCredits`, `alsoImage`) and neither half is made.
|
|
84
|
+
// ── THE TWO PRICED DOORS, AS AN AGENT RELAYS THEM (2026-11-12) ──────────────────────────────────────────────────────
|
|
85
|
+
// A balance refusal carries `buy` (the server's buyDoorsFor): a monthly plan and a credit pack sized to the ask, each with
|
|
86
|
+
// its price, its credits and a checkout link that needs no sign-in, plus the same offer as ready-made `lines`. The old
|
|
87
|
+
// answer explained how buy_credits works and left the agent to make a second call before the user saw a price. Now the
|
|
88
|
+
// prices and the links are in the refusal itself. Never on a widget host (ChatGPT may not link to a checkout): the
|
|
89
|
+
// callers pass `buy` only when the host is not one. Pure, no imports: the twin rule.
|
|
90
|
+
export function buyDoorsOf(e, widgetHost) {
|
|
91
|
+
const b = e && e.buy;
|
|
92
|
+
return !widgetHost && b && typeof b === 'object' && b.pack && Array.isArray(b.lines) && b.lines.length ? b : null;
|
|
93
|
+
}
|
|
94
|
+
export function buyDoorsText(buy, tool) {
|
|
95
|
+
if (!buy) return '';
|
|
96
|
+
const lines = buy.lines.map((x) => String(x)).filter(Boolean);
|
|
97
|
+
const doors = lines.filter((l) => l.startsWith('•'));
|
|
98
|
+
return [buy.plan ? `Recommend the ${buy.plan.name} plan to the user, with its link; a one-time credit pack is the other option:` : 'Give the user this way to continue, with its link:', ...doors,
|
|
99
|
+
`Each link opens a secure checkout page for this account, no sign-in needed. Once they have paid, call ${tool || 'the same tool'} again with the same arguments. A card already on file: buy_credits({pack:"${buy.pack.id}"}) quotes it and charges on confirm.`].join('\n');
|
|
100
|
+
}
|
|
101
|
+
export const buyDoorsHints = (buy, tool) => (buy ? [{ do: `${buy.plan ? `recommend the ${buy.plan.name} plan from this message with its price and link, and mention the one-time pack as the other option` : 'show the user the pack from this message with its price and link'}; after they pay, call ${tool || 'the same tool'} again with the same arguments`, why: 'the account cannot cover this call; each link is a checkout for this account that needs no sign-in, and nothing was charged' }] : []);
|
|
84
102
|
export function videoChoiceHints(tool, choice, opts = {}) {
|
|
85
103
|
const c = choice && typeof choice === 'object' ? choice : {}, o = c.options || {};
|
|
86
104
|
const out = opts.widgetHost
|
|
87
105
|
? [{ do: `tell the user they do not have enough credits: it needs ${c.short ?? '?'} more than the account has; they add credits or pick a plan in their Hermoso account at ${ACCOUNT_INFO_URL}`, why: 'this host does not offer purchases, so nothing is bought from here' }]
|
|
106
|
+
: opts.buy ? buyDoorsHints(opts.buy, tool)
|
|
88
107
|
: [
|
|
89
108
|
{ do: 'buy_credits({})', why: `${c.short ?? '?'} credits short; a pack covers it, then the same call makes it as asked` },
|
|
90
109
|
{ do: 'upgrade_plan({})', why: 'a monthly plan (Starter, Pro, Studio) includes monthly credits and no markup on models' },
|
|
@@ -108,6 +127,7 @@ export function videoChoiceText(tool, choice, opts = {}) {
|
|
|
108
127
|
// feature needs more credits and link an informational page, never a checkout. `o.topup.url` is a checkout link.
|
|
109
128
|
...(opts.widgetHost
|
|
110
129
|
? [` 1. The user can add credits or pick a plan in their Hermoso account (${ACCOUNT_INFO_URL}); then the same call makes it as asked. Nothing can be bought from here.`]
|
|
130
|
+
: opts.buy ? [buyDoorsText(opts.buy, tool)]
|
|
111
131
|
: [` 1. Add credits: buy_credits({}) quotes a pack on a saved card or returns a checkout link${o.topup?.url ? ` (or ${o.topup.url})` : ''}; then repeat the same call and it goes ahead as asked.`,
|
|
112
132
|
` 2. Or a monthly plan: upgrade_plan({}) lists them; Starter, Pro and Studio include monthly credits and no markup on models, so credits go further.`]),
|
|
113
133
|
];
|
|
@@ -156,8 +176,28 @@ export function withWidgetResultNote(tool, r, widgetHost) {
|
|
|
156
176
|
// buy_credits / upgrade_plan (WITHHELD_FROM_WIDGET_HOSTS), so its out-of-credits reply must not name them either, and
|
|
157
177
|
// the server's own sentence ("Upgrade or top up to keep going.") is rewritten to say where the account is managed.
|
|
158
178
|
// The link is the pricing page, which describes the options and starts nothing. Pure, no imports — the twin rule.
|
|
159
|
-
|
|
160
|
-
|
|
179
|
+
// TAGGED src=chatgpt-credits (2026-11-12, lib/buy-page.mjs): the pricing page shows a visitor with this tag one button, "Buy credits
|
|
180
|
+
// or a plan", to the app's buy page. Still the informational page ChatGPT's rules allow, and the only link this host is ever
|
|
181
|
+
// given; the buy page itself is never linked from here. The tag rides only the refusal text, never a tool definition.
|
|
182
|
+
export const ACCOUNT_INFO_URL = 'https://hermoso.ai/pricing?src=chatgpt-credits';
|
|
183
|
+
// …AND THE PLAN TIER THE ACCOUNT IS ON, AND NOTHING ELSE (2026-11-12, owner: "shouldnt we only show plans higher than the plan
|
|
184
|
+
// they are on?"). The pricing page is static and cannot see the account, so the link says which tier it is on (&from=starter)
|
|
185
|
+
// and the page hides the plans at or below it. A tier is not personal data; no id, email or balance ever rides the link. An
|
|
186
|
+
// unknown or free tier is the plain link (every plan is above Free). Pure; tools.mjs reads the tier once per refusal.
|
|
187
|
+
export const PLAN_TIERS = Object.freeze(['free', 'basic', 'starter', 'pro', 'studio']);
|
|
188
|
+
export function accountInfoUrl(tier) {
|
|
189
|
+
const t = String(tier || '').trim().toLowerCase();
|
|
190
|
+
return PLAN_TIERS.includes(t) && t !== 'free' ? `${ACCOUNT_INFO_URL}&from=${t}` : ACCOUNT_INFO_URL;
|
|
191
|
+
}
|
|
192
|
+
/** A refusal written for a widget host, with the tier put on its info link (text and hints). The text is otherwise untouched. */
|
|
193
|
+
export function withInfoTier(text, hints, tier) {
|
|
194
|
+
const u = accountInfoUrl(tier);
|
|
195
|
+
if (u === ACCOUNT_INFO_URL) return { text, hints };
|
|
196
|
+
const sw = (x) => String(x).split(ACCOUNT_INFO_URL).join(u);
|
|
197
|
+
return { text: sw(text), hints: (hints || []).map((h) => (h && typeof h === 'object' ? { ...h, ...(typeof h.do === 'string' ? { do: sw(h.do) } : {}), ...(typeof h.why === 'string' ? { why: sw(h.why) } : {}) } : h)) };
|
|
198
|
+
}
|
|
199
|
+
export const ACCOUNT_CREDITS_LINE = `The user can add credits or pick a plan in their Hermoso account at ${ACCOUNT_INFO_URL}, then ask again.`; // the neutral fact every widget-host refusal carries (owner, 2026-11-12): what to do and where, never a pitch
|
|
200
|
+
export const CREDITS_LOW_NOTE = `The Hermoso account does not have enough credits for this. Tell the user plainly. ${ACCOUNT_CREDITS_LINE} Nothing can be bought from here.`;
|
|
161
201
|
const UPSELL_SENTENCE_RE = /\s*(?:please\s+)?(?:upgrade(?:\s+your\s+plan)?\s+or\s+top\s+up|top\s+up|add\s+credits|buy\s+(?:more\s+)?credits|monthly\s+plans\s+have\s+no\s+markup\s+on\s+models)\b[^.!\n]*[.!]?/gi; // the monthly-plan upsell (2026-10-20) is a promotion too: a widget host (ChatGPT) never hears it
|
|
162
202
|
// ── FREE CREDITS NOT CLAIMED YET (2026-10-25, ChatGPT funnel) ─────────────────────────────────────────────────────
|
|
163
203
|
// An account whose welcome credits were never added (the one-time check that a person is behind it did not pass
|
|
@@ -165,8 +205,73 @@ const UPSELL_SENTENCE_RE = /\s*(?:please\s+)?(?:upgrade(?:\s+your\s+plan)?\s+or\
|
|
|
165
205
|
// said on every host, ChatGPT included. The server marks the refusal (meta.welcomeClaim = {credits, url}).
|
|
166
206
|
export const welcomeClaimSentence = (w) => `This account has not received its ${Number(w?.credits) || 0} free credits yet. The user opens ${w?.url} once in a browser (one click, no sign-in and no card) and they arrive in a few seconds after a quick check that a person is there.`;
|
|
167
207
|
export const welcomeClaimHint = (w) => ({ do: `ask the user to open ${w?.url} once in a browser (one click, no sign-in) to receive the ${Number(w?.credits) || 0} free credits; then run the same call again`, why: 'the account never received its free credits: they wait behind a one-time check that a person is behind the account, which runs in the browser' });
|
|
168
|
-
export function neutralCreditsText(msg) {
|
|
169
|
-
const base = String(msg ?? '').replace(UPSELL_SENTENCE_RE, '').replace(/[ \t]+$/gm, '').replace(/\s*[—–,:-]\s*$/gm, '.').trim();
|
|
170
|
-
|
|
208
|
+
export function neutralCreditsText(msg, nums = {}) {
|
|
209
|
+
const base = stripPlanPromo(String(msg ?? '').replace(UPSELL_SENTENCE_RE, '')).replace(/[ \t]+$/gm, '').replace(/\s*[—–,:-]\s*$/gm, '.').trim();
|
|
210
|
+
// The numbers, when the server's sentence did not carry them ("You’re out of credits." does not): read from the refusal's
|
|
211
|
+
// structured block (e.buy.need / .balance), never parsed from prose. A count, not a price.
|
|
212
|
+
const need = Math.round(Number(nums?.need) || 0), bal = Number(nums?.balance);
|
|
213
|
+
const numLine = need > 0 && !/\bneeds?\b[^.\n]*\d/i.test(base) ? `This needs about ${need} credits${Number.isFinite(bal) && nums?.balance != null ? ` and the account has ${Math.max(0, Math.floor(bal))}` : ''}.` : '';
|
|
214
|
+
return [base, numLine, CREDITS_LOW_NOTE].filter(Boolean).join('\n');
|
|
215
|
+
}
|
|
216
|
+
// ── NO PLAN PITCH ON A WIDGET HOST, WHEREVER IT CAME FROM (2026-11-12) ────────────────────────────────────────────
|
|
217
|
+
// Measured on production: a ChatGPT out-of-credits refusal carried no price and no checkout link and STILL ended
|
|
218
|
+
// "Starter, Pro and Studio have no markup on models, so your credits go twice as far." — the server's withPlanUpsell
|
|
219
|
+
// sentence (server.js PLAN_UPSELL_LINE, or its "on one of those plans this needs about N" form), which UPSELL_SENTENCE_RE
|
|
220
|
+
// above did not know. OpenAI's app guidelines: apps "may not … promote upgrades". So the rule is a SENTENCE filter, not a
|
|
221
|
+
// list of known sentences: on a widget host any sentence (or list item) that pitches a plan, names a purchase tool, a
|
|
222
|
+
// price, a checkout or a top-up is dropped, and the neutral line (ACCOUNT_CREDITS_LINE) says what to do and where.
|
|
223
|
+
// Two strengths. PLAN_PROMO_RE is for text that is ABOUT credits or the account (a credits refusal, the capability map),
|
|
224
|
+
// where "$" or "upgrade" can only be selling. PLAN_PITCH_RE is narrow enough for any reply on a widget host (a budget
|
|
225
|
+
// error says "$5", ad copy says "go further"): only our own pitch phrases and the purchase tools' names.
|
|
226
|
+
export const PLAN_PROMO_RE = /\b(?:buy_credits|upgrade_plan|set_auto_reload|billing_status)\b|\bupgrad(?:e|es|ed|ing)\b|no markup|twice as far|\bgo(?:es)? further\b|checkout|\btop[- ]?ups?\b|\$\s?\d|WHY HERMOSO|\bcredit packs?\b|\bper month\b|\/mo\b/i;
|
|
227
|
+
export const PLAN_PITCH_RE = /no markup on (?:image, video and voice )?models|credits? go(?:es)? (?:twice as far|further)|\bStarter, Pro and Studio\b|\b(?:buy_credits|upgrade_plan|set_auto_reload|billing_status)\b/i;
|
|
228
|
+
// Split `s` at every `sep` that sits outside parentheses/brackets, so "(a, b; c)" is never cut in half.
|
|
229
|
+
function splitTop(s, sep) {
|
|
230
|
+
const out = []; let depth = 0, from = 0;
|
|
231
|
+
for (let i = 0; i < s.length; i++) {
|
|
232
|
+
const ch = s[i];
|
|
233
|
+
if (ch === '(' || ch === '[' || ch === '{') depth++;
|
|
234
|
+
else if ((ch === ')' || ch === ']' || ch === '}') && depth > 0) depth--;
|
|
235
|
+
else if (depth === 0) {
|
|
236
|
+
if (sep === 'sentence') { if (/[.!?]/.test(ch) && /\s/.test(s[i + 1] || '')) { out.push(s.slice(from, i + 1)); from = i + 1; } }
|
|
237
|
+
else if (s.startsWith(sep, i)) { out.push(s.slice(from, i)); from = i + sep.length; i += sep.length - 1; }
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
out.push(s.slice(from));
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
const PROMO_LEVELS = [['sentence', ' '], ['; ', '; '], [' · ', ' · ']];
|
|
244
|
+
export function stripPlanPromo(text, re = PLAN_PROMO_RE) {
|
|
245
|
+
const prune = (s, levels) => {
|
|
246
|
+
if (!re.test(s)) return s;
|
|
247
|
+
for (let k = 0; k < levels.length; k++) {
|
|
248
|
+
const [sep, join] = levels[k];
|
|
249
|
+
const parts = splitTop(s, sep);
|
|
250
|
+
if (parts.length < 2) continue;
|
|
251
|
+
return parts.map((p) => prune(sep === 'sentence' ? p.trim() : p, levels.slice(k + 1))).filter((p) => p.trim()).join(join);
|
|
252
|
+
}
|
|
253
|
+
return '';
|
|
254
|
+
};
|
|
255
|
+
const src = String(text ?? '');
|
|
256
|
+
if (!re.test(src)) return src;
|
|
257
|
+
return src.split('\n').map((line) => (re.test(line) ? { line: prune(line, PROMO_LEVELS), cut: true } : { line, cut: false }))
|
|
258
|
+
.filter((x) => !(x.cut && !x.line.trim())).map((x) => x.line).join('\n');
|
|
259
|
+
}
|
|
260
|
+
// A reply on a widget host, with every pitch sentence out of its text blocks (a JSON block is data and left whole) and
|
|
261
|
+
// out of its hints. The result object is copied, never edited in place.
|
|
262
|
+
export function withoutPlanPitch(r, re = PLAN_PITCH_RE) {
|
|
263
|
+
if (!r || typeof r !== 'object') return r;
|
|
264
|
+
let out = r;
|
|
265
|
+
if (Array.isArray(r.content) && r.content.some((b) => b && b.type === 'text' && typeof b.text === 'string' && re.test(b.text) && !/^\s*[[{]/.test(b.text))) {
|
|
266
|
+
out = { ...out, content: r.content.map((b) => (b && b.type === 'text' && typeof b.text === 'string' && !/^\s*[[{]/.test(b.text) ? { ...b, text: stripPlanPromo(b.text, re) } : b)) };
|
|
267
|
+
}
|
|
268
|
+
const h = out._meta && Array.isArray(out._meta[HINTS_KEY]) ? out._meta[HINTS_KEY] : null;
|
|
269
|
+
if (h && h.some((x) => PLAN_PROMO_RE.test(`${x.do} ${x.why}`))) {
|
|
270
|
+
const kept = h.filter((x) => !PLAN_PROMO_RE.test(`${x.do} ${x.why}`));
|
|
271
|
+
const meta = { ...out._meta };
|
|
272
|
+
if (kept.length) meta[HINTS_KEY] = kept; else delete meta[HINTS_KEY];
|
|
273
|
+
out = { ...out, _meta: meta };
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
171
276
|
}
|
|
172
|
-
export const creditsLowHints = () => [{ do: `tell the user the
|
|
277
|
+
export const creditsLowHints = () => [{ do: `tell the user the account does not have enough credits for this; they can add credits or pick a plan in their Hermoso account at ${ACCOUNT_INFO_URL}, then ask again`, why: 'this host does not offer purchases, so nothing is bought from here' }];
|