hermoso 0.1.531 → 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/arg-guard.mjs +8 -2
- package/mcp/chatgpt-roster.mjs +3 -2
- package/mcp/client.mjs +35 -3
- package/mcp/http.mjs +43 -18
- package/mcp/openai-extensions.mjs +10 -0
- package/mcp/spend-guard.mjs +127 -0
- package/mcp/tool-cost.mjs +3 -0
- package/mcp/tool-hints.mjs +111 -6
- package/mcp/tools.mjs +1210 -168
- 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/arg-guard.mjs
CHANGED
|
@@ -71,11 +71,17 @@ export function declaredArgKeys(schema) {
|
|
|
71
71
|
return null;
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
// META ARGUMENTS (2026-11-11, spend-guard.mjs): accepted on EVERY tool without being in its schema, never "unknown".
|
|
75
|
+
// `context` is the user's goal in one sentence, for analytics only; declared once (on call_tool) instead of on 1,200
|
|
76
|
+
// schemas, so it costs the roster nothing. The guard lifts it onto the parsed arguments under CONTEXT_SYM, because the
|
|
77
|
+
// SDK's own parse strips any key the schema does not declare.
|
|
78
|
+
export const META_ARGS = Object.freeze(['context']);
|
|
79
|
+
export const CONTEXT_SYM = Symbol.for('hermoso.agentContext');
|
|
80
|
+
/** The keys of `args` the schema does not declare (host `_` metadata and META_ARGS excluded). [] when there are none or none can be judged. */
|
|
75
81
|
export function unknownArgs(declared, args) {
|
|
76
82
|
if (!Array.isArray(declared) || !args || typeof args !== 'object' || Array.isArray(args)) return [];
|
|
77
83
|
const known = new Set(declared);
|
|
78
|
-
return Object.keys(args).filter((k) => !known.has(k) && !k.startsWith('_'));
|
|
84
|
+
return Object.keys(args).filter((k) => !known.has(k) && !k.startsWith('_') && !META_ARGS.includes(k));
|
|
79
85
|
}
|
|
80
86
|
|
|
81
87
|
function lev(a, b) {
|
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,14 @@ 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';
|
|
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
|
|
132
|
+
// handler with every request marked as a quote (the server stops each spending point and prices it); `context` rides
|
|
133
|
+
// as one redacted, percent-encoded sentence the server puts on the ledger row of whatever the call bills.
|
|
134
|
+
{ const st = toolCtx.getStore(); if (st?.quoteOnly) h['x-hermoso-quote'] = '1'; if (st?.contextHeader) h['x-hermoso-context'] = st.contextHeader; }
|
|
127
135
|
if (process.env.EDGE_SECRET) h['x-edge-auth'] = process.env.EDGE_SECRET; // belt: in-process self-calls satisfy the edge shield even if the loopback exemption ever changes
|
|
128
136
|
// THE SAME RULE FOR THE BEARER (2026-09-23). This read `ctx?.token || TOKEN`, so a hosted request whose ctx carried
|
|
129
137
|
// no token would have been sent with the SERVER process's own HERMOSO_TOKEN — an operator credential standing in for
|
|
@@ -149,17 +157,29 @@ export function signInMessage(status, body, msg, isSignedOut = signedOut()) {
|
|
|
149
157
|
return /^sign in to continue\.?$/i.test(String(msg || '').trim()) ? SIGN_IN_HINT : `${msg} ${SIGN_IN_HINT}`;
|
|
150
158
|
}
|
|
151
159
|
|
|
160
|
+
// A PRICE QUOTE'S STOP (2026-11-11, spend-guard.mjs): the server answers a request made in quote mode, at the point it would
|
|
161
|
+
// have spent, with 409 {code:'quote_only', quote:{legs}}. The legs join the running call's tally and the call stops here;
|
|
162
|
+
// wrap() turns the tally into the price. `_quoteStop` is never reported as an error.
|
|
163
|
+
export const QUOTE_STOP_MESSAGE = 'Price quote only: nothing ran and nothing was charged.';
|
|
164
|
+
export const quoteStopError = () => Object.assign(new Error(QUOTE_STOP_MESSAGE), { _quoteStop: true, status: 409, _viaApi: true });
|
|
165
|
+
function quoteStopFrom(status, body) {
|
|
166
|
+
if (Number(status) !== 409 || body?.code !== 'quote_only') return;
|
|
167
|
+
const st = toolCtx.getStore();
|
|
168
|
+
if (st?.quoteOnly) for (const l of (Array.isArray(body?.quote?.legs) ? body.quote.legs : [])) (st.quoteLegs ||= []).push(l);
|
|
169
|
+
throw quoteStopError();
|
|
170
|
+
}
|
|
152
171
|
// unwrap the {data}|{error} envelope; throw a clean Error (with .status) on failure
|
|
153
172
|
async function unwrap(res, { read = false } = {}) {
|
|
154
173
|
let body = null;
|
|
155
174
|
try { body = await res.json(); } catch (e) { if (read && isDeadline(e)) throw readDeadlineError(); } // a body cut off by the read deadline is not an empty answer
|
|
156
175
|
if (!res.ok) {
|
|
176
|
+
quoteStopFrom(res.status, body); // a price quote that stopped at a spending point (throws; never data)
|
|
157
177
|
const msg = signInMessage(res.status, body, (body && (body.error || body.message)) || `HTTP ${res.status}`);
|
|
158
178
|
// `_viaApi` MARKS AN ERROR THAT ALREADY REACHED THE SERVER, so route() has already recorded it in the error
|
|
159
179
|
// ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
|
|
160
180
|
// throw, a schema rejection, a socket reset — which is what stops the twins double-counting every 4xx.
|
|
161
181
|
// `connectUrl` rides a not-connected 401 with the brand already in it, so a hint never rebuilds the link.
|
|
162
|
-
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
|
|
163
183
|
}
|
|
164
184
|
// WHAT THIS CALL WAS CHARGED (2026-10-19). A paid route answers {data, creditsUsed}, and the unwrap below drops the
|
|
165
185
|
// sibling, so a synchronous paid tool's reply could never say what it cost. It is added to the running tool call's
|
|
@@ -522,7 +542,7 @@ export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
|
|
|
522
542
|
// /api/explore/chat streams Server-Sent-Events; collect to the terminal `done` payload {reply, results, actions}.
|
|
523
543
|
export async function apiSSE(p, body = {}) {
|
|
524
544
|
const res = await fetchWrite(`${API_BASE}${withBrandScope(p)}`, { method: 'POST', headers: headers({ Accept: 'text/event-stream' }), body: JSON.stringify(body) });
|
|
525
|
-
if (!res.ok) { let e; try {
|
|
545
|
+
if (!res.ok) { let e, b = null; try { b = await res.json(); e = b?.error; } catch {} quoteStopFrom(res.status, b); throw Object.assign(new Error(e || `HTTP ${res.status}`), { status: res.status }); }
|
|
526
546
|
// A server that answered with plain JSON (an older revision) is read as JSON, never as an empty stream.
|
|
527
547
|
if (!/text\/event-stream/i.test(res.headers.get('content-type') || '')) { const j = await res.json(); return j && Object.prototype.hasOwnProperty.call(j, 'data') ? j.data : j; }
|
|
528
548
|
const reader = res.body.getReader(); const dec = new TextDecoder();
|
|
@@ -549,7 +569,19 @@ export async function apiSSE(p, body = {}) {
|
|
|
549
569
|
// Submit a render to the job queue and (optionally) poll it to completion. Returns the UNWRAPPED worker result
|
|
550
570
|
// (the {image|video, model} object) on success — the job's `result` is itself a {data} envelope, so we peel it.
|
|
551
571
|
export async function submitJob(type, input, { label = '' } = {}) {
|
|
552
|
-
|
|
572
|
+
const st = toolCtx.getStore();
|
|
573
|
+
// A PRICE QUOTE (get_cost / expected_credits): the same submit as a dry run, priced by the job's own hold, then the call
|
|
574
|
+
// stops: nothing is queued. The server forces the dry run in quote mode too; this keeps the tally exact.
|
|
575
|
+
if (st?.quoteOnly) {
|
|
576
|
+
const d = await apiPost('/api/jobs', { type, input, label, dryRun: true });
|
|
577
|
+
const q = d?.quote || null;
|
|
578
|
+
(st.quoteLegs ||= []).push({ credits: q && Number(q.credits) > 0 ? Number(q.credits) : null, ...(q && Number(q.priceCredits) > 0 ? { priceCredits: Number(q.priceCredits) } : {}), basis: q && Number(q.credits) > 0 ? 'hold' : 'actual', label: label || q?.label || type, ...(q && q.refused ? { refused: String(q.refused) } : {}) });
|
|
579
|
+
throw quoteStopError();
|
|
580
|
+
}
|
|
581
|
+
// A TOOL CALL WITH AN idempotency_key: each job it queues carries a key derived from it (one per submit in the call), so a
|
|
582
|
+
// retry of a lost answer collapses onto the same job, and the claim learns the job id (pendingText names it).
|
|
583
|
+
const idem = st?.idemKey ? { idempotencyKey: `${st.idemJobKey}.${(st.idemJobs = (st.idemJobs || 0) + 1)}`, toolIdemKey: st.idemKey } : {};
|
|
584
|
+
return apiPost('/api/jobs', { type, input, label, ...idem }); // → publicJob {id, status, ...}
|
|
553
585
|
}
|
|
554
586
|
export async function getJob(id) { return apiGet(`/api/jobs/${encodeURIComponent(id)}`); } // → publicJob
|
|
555
587
|
// ONE READ THAT A ROLLOVER CANNOT TURN INTO "IT DOES NOT EXIST" (2026-10-19). get_job is a single read, and its 404
|
package/mcp/http.mjs
CHANGED
|
@@ -16,8 +16,8 @@ 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';
|
|
20
|
-
import {
|
|
19
|
+
import { registerTools, MCP_INSTRUCTIONS, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS, anonDiscoveryGroups, widgetHostScope, withFirstRunLine, firstRunOffers } from './tools.mjs';
|
|
20
|
+
import { chatgptExtRequested } from './openai-extensions.mjs';
|
|
21
21
|
import { mcpCtx, connectedProviders } from './client.mjs';
|
|
22
22
|
|
|
23
23
|
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
@@ -398,7 +398,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
398
398
|
// `?ext=1` asks for the ChatGPT extensions app (the Library panel + sidebar). Only a REQUEST: registerTools turns it
|
|
399
399
|
// on only for a widget host on a process with HERMOSO_CHATGPT_EXT=1 (chatgptExtOn in openai-extensions.mjs), so on
|
|
400
400
|
// the published URL, or with the flag unset, the roster is exactly what it was. Presentation only, like `?tools=`.
|
|
401
|
-
|
|
401
|
+
// HERMOSO_CHATGPT_EXT_PUBLISHED=1 stands in for `?ext=1` on an unscoped connection (the published all-in-one plugin's
|
|
402
|
+
// URL cannot carry it; see chatgptExtRequested). Still only a request: the widget-host and HERMOSO_CHATGPT_EXT keys hold.
|
|
403
|
+
const extAsked = (req, scope) => chatgptExtRequested({ asked: req.query?.ext, app: scope?.app || null });
|
|
402
404
|
const srcOf = (req) => { const v = String(req.query?.src || '').trim().toLowerCase(); return /^[a-z0-9][a-z0-9_.-]{0,39}$/.test(v) ? v : null; };
|
|
403
405
|
const methodsOf = (body) => (Array.isArray(body) ? body : [body]).map((m) => m && m.method).filter(Boolean);
|
|
404
406
|
|
|
@@ -406,6 +408,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
406
408
|
// A widget host (ChatGPT, whose plugin scanner reads this anonymous roster) gets its own instructions — see
|
|
407
409
|
// MCP_INSTRUCTIONS_WIDGET. The SAME host decision as the roster below, so the two can never disagree.
|
|
408
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)
|
|
409
412
|
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
|
|
410
413
|
// `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
|
|
411
414
|
// as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
|
|
@@ -423,7 +426,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
423
426
|
// default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
|
|
424
427
|
// A widget host in full mode gets every group here too, so the anonymous roster and the signed-in one agree (see
|
|
425
428
|
// chatgptRosterMode in tools.mjs: ChatGPT cannot reach an unlisted tool).
|
|
426
|
-
registerTools(server, { only: scope?.groups || anonDiscoveryGroups(widgetHost), directory: scope?.directory || false, app: scope?.app || null, widgetHost, hosted: true, ext: extAsked(req) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
429
|
+
registerTools(server, { only: scope?.groups || anonDiscoveryGroups(widgetHost), directory: scope?.directory || false, app: scope?.app || null, widgetHost, hosted: true, ext: extAsked(req, scope) }); // metadata only — tools/list never invokes a handler, and tools/call can't reach here
|
|
427
430
|
// WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
|
|
428
431
|
// not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
|
|
429
432
|
// tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
|
|
@@ -455,7 +458,18 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
455
458
|
// doing anything"). Only a tools/call is somebody doing something, so only a tools/call stamps. Auth itself is
|
|
456
459
|
// unchanged in both branches: this decides bookkeeping, never access.
|
|
457
460
|
const didWork = methodsOf(req.body).includes('tools/call');
|
|
458
|
-
|
|
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
|
+
}
|
|
459
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}` });
|
|
460
474
|
if (!user) {
|
|
461
475
|
// No valid bearer: allow ONLY the read-only discovery handshake (POST), fail CLOSED for everything else.
|
|
@@ -518,8 +532,12 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
518
532
|
// Only an `initialize` may mint a session. Anything else naming no session at all is the SDK's own 400 —
|
|
519
533
|
// answered here so we never build a server whose only job would be to reject the request.
|
|
520
534
|
if (!init) return needSession(res);
|
|
521
|
-
const
|
|
522
|
-
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));
|
|
523
541
|
sweepSessions(); // make room before allocating, so the cap is a ceiling and not a suggestion
|
|
524
542
|
// ── WHAT THIS CALLER CAN ACTUALLY USE (2026-08-26) ────────────────────────────────────────────────────────
|
|
525
543
|
// One free store read, on the ONE request per session that mints the roster, so the tools we advertise are
|
|
@@ -537,8 +555,21 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
537
555
|
const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
|
|
538
556
|
// ONE host decision for the instructions and the roster (see MCP_INSTRUCTIONS_WIDGET), so the two cannot disagree.
|
|
539
557
|
const widgetHost = isWidgetHost(entry?.client || clientInfoOf(req.body), req);
|
|
540
|
-
|
|
541
|
-
|
|
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) });
|
|
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
|
|
542
573
|
const transport = new StreamableHTTPServerTransport({
|
|
543
574
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
544
575
|
sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
|
|
@@ -548,15 +579,9 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
548
579
|
// WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
|
|
549
580
|
// request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
|
|
550
581
|
// tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
|
|
551
|
-
//
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
// 10-15 publish had made nothing, and a live "make me a video ad" chat on a connected account never called us.
|
|
555
|
-
let firstRun = null;
|
|
556
|
-
if (widgetHost && typeof firstRunFor === 'function' && user.accountId) {
|
|
557
|
-
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; }
|
|
558
|
-
}
|
|
559
|
-
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 };
|
|
560
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 {} }
|
|
561
586
|
// LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
|
|
562
587
|
// ever read is a guess. One line per session (not per call) so a new host identifies itself once and
|
|
@@ -166,6 +166,16 @@ export const chatgptExtAsked = (v) => /^(1|true|on|yes)$/i.test(String(Array.isA
|
|
|
166
166
|
// All three keys, in one predicate so the transport and the check cannot disagree about what "on" means.
|
|
167
167
|
export const chatgptExtOn = ({ env = process.env, asked, widgetHost } = {}) =>
|
|
168
168
|
!!widgetHost && String(env?.[CHATGPT_EXT_ENV] ?? '').trim() === '1' && chatgptExtAsked(asked);
|
|
169
|
+
// THE PUBLISHED APP CANNOT ASK (2026-11-11). The all-in-one plugin's mcp.json URL is plain /mcp, and OpenAI does not
|
|
170
|
+
// let an update change a published plugin's MCP URL ("To change an existing MCP server's URL, contact support",
|
|
171
|
+
// developers.openai.com/plugins/deploy/submission.md, read 2026-11-11), so `?ext=1` can never reach it. This second
|
|
172
|
+
// env stands in for the ask on an UNSCOPED connection only (no `?tools=` app scope, so the three focused apps are
|
|
173
|
+
// untouched). It is still only a REQUEST: chatgptExtOn above keeps its other two keys (a widget host and
|
|
174
|
+
// HERMOSO_CHATGPT_EXT=1), so with this env unset the roster is byte-identical. Turning it on adds NEW tools, which
|
|
175
|
+
// OpenAI's continuous review checks before any user sees them; no reviewed tool changes.
|
|
176
|
+
export const CHATGPT_EXT_PUBLISHED_ENV = 'HERMOSO_CHATGPT_EXT_PUBLISHED';
|
|
177
|
+
export const chatgptExtRequested = ({ env = process.env, asked, app } = {}) =>
|
|
178
|
+
chatgptExtAsked(asked) || (!app && String(env?.[CHATGPT_EXT_PUBLISHED_ENV] ?? '').trim() === '1');
|
|
169
179
|
|
|
170
180
|
// THE SIDEBAR ICON. The spec asks for a 20x20 monochrome SVG in currentColor. This is the bloom (four petals and a
|
|
171
181
|
// centre, public/brand/bloom-mono-black.svg) redrawn in currentColor at the spec's stroke weight. A data: URI, so it
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// ── A PAID TOOL CALL: PRICE IT FIRST, BUY IT ONCE (2026-11-11) ─────────────────────────────────────────────────────
|
|
2
|
+
//
|
|
3
|
+
// Three controls on every tool that spends (owner-approved 2026-11-11, after reading how the best agent surfaces do it):
|
|
4
|
+
// get_cost: true the exact price, with nothing run and nothing billed;
|
|
5
|
+
// expected_credits: N "run it at the price I was quoted": the server re-prices and REFUSES (unbilled) if it moved;
|
|
6
|
+
// idempotency_key: "…" chosen once and repeated unchanged on a retry: the same key never buys the call twice.
|
|
7
|
+
// Plus `context` on every tool: the user's goal in one short sentence, for analytics only.
|
|
8
|
+
//
|
|
9
|
+
// WHICH TOOLS: the ones that spend credits, derived from the same classification find_tools prints (tool-cost.mjs:
|
|
10
|
+
// the `create` and `research` groups run a model or Ad Spy research), minus SPEND_EXEMPT, the few tools filed in those
|
|
11
|
+
// groups that spend nothing. X's per-call tools are left out on purpose: post_to_x already has its own 24h
|
|
12
|
+
// idempotencyKey and a free dryRun that prices the exact post. tools/spend-guard-check.mjs derives the set from the
|
|
13
|
+
// registry and the handlers' own code, and fails on a paying tool that lacks the controls.
|
|
14
|
+
//
|
|
15
|
+
// HOW A PRICE IS MADE: the tool's OWN handler runs with quote mode on (client.mjs sends x-hermoso-quote: 1 on every
|
|
16
|
+
// request; server.js answers each spending point with what it would cost and stops it there). So the price is the run's
|
|
17
|
+
// own pricing, never a second table that could drift. Nothing is queued, held or charged.
|
|
18
|
+
//
|
|
19
|
+
// No `get_cost`, no `expected_credits` and no `idempotency_key` ⇒ the tool runs exactly as it always has.
|
|
20
|
+
//
|
|
21
|
+
// BYTE-TWINNED with cli/mcp/spend-guard.mjs (tools/mcp-parity.mjs sweeps every shared file). PURE apart from the
|
|
22
|
+
// injected `post`, and no lib/ import, because the CLI ships rooted at cli/.
|
|
23
|
+
|
|
24
|
+
import { createHash } from 'node:crypto';
|
|
25
|
+
import { toolCostClass } from './tool-cost.mjs';
|
|
26
|
+
|
|
27
|
+
// Filed in `create` / `research` but spend nothing: lookups, saved presets, Library and Gallery bookkeeping.
|
|
28
|
+
export const SPEND_EXEMPT = Object.freeze([
|
|
29
|
+
'list_video_presets', 'save_video_preset', 'update_video_preset', 'delete_video_preset',
|
|
30
|
+
'delete_library_item', 'submit_to_gallery', 'list_gallery_submissions', 'withdraw_from_gallery',
|
|
31
|
+
'list_winning_ads', 'set_product_image', 'collect_post_metrics',
|
|
32
|
+
]);
|
|
33
|
+
export const SPEND_CONTROL_ARGS = Object.freeze(['idempotency_key', 'get_cost', 'expected_credits']);
|
|
34
|
+
|
|
35
|
+
/** Does this tool spend credits (and so take get_cost / expected_credits / idempotency_key)? */
|
|
36
|
+
export function spendTool(name, group) {
|
|
37
|
+
const n = String(name || '');
|
|
38
|
+
if (SPEND_EXEMPT.includes(n)) return false;
|
|
39
|
+
const c = toolCostClass(n, group, null);
|
|
40
|
+
return c === 'model' || c === 'research';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// The parameter texts. Short on purpose: they ride every paying tool's schema.
|
|
44
|
+
export const SPEND_ARG_TEXT = Object.freeze({
|
|
45
|
+
get_cost: 'true: return the price only; nothing runs or bills',
|
|
46
|
+
expected_credits: 'the get_cost price; refused unbilled if the live price differs',
|
|
47
|
+
idempotency_key: 'chosen once, resent unchanged on retries: the same key never buys twice',
|
|
48
|
+
});
|
|
49
|
+
export const CONTEXT_ARG_TEXT = "optional: the user's underlying goal in one short sentence, for analytics only (no names, contact details or URLs)";
|
|
50
|
+
|
|
51
|
+
export const TOOL_IDEM_KEY_MAX = 128;
|
|
52
|
+
export function validKey(k) {
|
|
53
|
+
if (typeof k !== 'string') return null;
|
|
54
|
+
const s = k.trim();
|
|
55
|
+
return s && s.length <= TOOL_IDEM_KEY_MAX && !/[\u0000-\u001f\u007f]/.test(s) ? s : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function stableJson(v) {
|
|
59
|
+
if (Array.isArray(v)) return '[' + v.map(stableJson).join(',') + ']';
|
|
60
|
+
if (v && typeof v === 'object') return '{' + Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => JSON.stringify(k) + ':' + stableJson(v[k])).join(',') + '}';
|
|
61
|
+
return JSON.stringify(v === undefined ? null : v);
|
|
62
|
+
}
|
|
63
|
+
/** The arguments a key is bound to: everything except the call controls and `context`, order-free. */
|
|
64
|
+
export function argsHash(tool, args) {
|
|
65
|
+
const a = {};
|
|
66
|
+
for (const [k, v] of Object.entries(args && typeof args === 'object' ? args : {})) if (!SPEND_CONTROL_ARGS.includes(k) && k !== 'context') a[k] = v;
|
|
67
|
+
return createHash('sha256').update(String(tool || '') + '\n' + stableJson(a)).digest('hex').slice(0, 40);
|
|
68
|
+
}
|
|
69
|
+
/** The root of the job idempotency keys a keyed call's submits carry (server shape /^[A-Za-z0-9._:-]{8,120}$/; client.mjs appends .1, .2…). */
|
|
70
|
+
export function jobKeyRoot(toolKey, tool = '') {
|
|
71
|
+
return `ti.${createHash('sha256').update(String(tool) + '\n' + String(toolKey)).digest('hex').slice(0, 24)}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Pull the call controls off the arguments. `clean` is what the handler sees, so none of them ever reaches a body. */
|
|
75
|
+
export function takeSpendMeta(args) {
|
|
76
|
+
const a = args && typeof args === 'object' ? { ...args } : {};
|
|
77
|
+
const getCost = a.get_cost === true;
|
|
78
|
+
const expected = a.expected_credits == null || a.expected_credits === '' ? null : Number(a.expected_credits);
|
|
79
|
+
const rawKey = a.idempotency_key;
|
|
80
|
+
for (const k of SPEND_CONTROL_ARGS) delete a[k];
|
|
81
|
+
return { clean: a, getCost, expected: Number.isFinite(expected) ? expected : (expected === null ? null : NaN), key: rawKey == null || rawKey === '' ? null : validKey(rawKey), badKey: rawKey != null && rawKey !== '' && !validKey(rawKey) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ── the agent's `context`, redacted on THIS side before it leaves (lib/agent-context.mjs is the server's copy) ──
|
|
85
|
+
export const AGENT_CONTEXT_MAX = 200;
|
|
86
|
+
export function redactContext(s) {
|
|
87
|
+
if (typeof s !== 'string') return '';
|
|
88
|
+
let t = s.replace(/[\u0000-\u001f\u007f]+/g, ' ');
|
|
89
|
+
t = t.replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]');
|
|
90
|
+
t = t.replace(/\b(?:https?:\/\/|www\.)\S+/gi, '[url]');
|
|
91
|
+
t = t.replace(/\b[a-z0-9-]+(?:\.[a-z0-9-]+)*\.(?:com|net|org|io|ai|co|app|dev|shop|store|us|uk|ca|de|fr|es|it|nl|au|me|xyz)\b\S*/gi, '[url]');
|
|
92
|
+
t = t.replace(/(^|\s)@[A-Za-z0-9_.]{2,}/g, '$1[handle]');
|
|
93
|
+
t = t.replace(/\+?\d[\d\s().-]{6,}\d/g, '[number]');
|
|
94
|
+
t = t.replace(/\s+/g, ' ').trim();
|
|
95
|
+
return t.length > AGENT_CONTEXT_MAX ? t.slice(0, AGENT_CONTEXT_MAX - 1).trimEnd() + '…' : t;
|
|
96
|
+
}
|
|
97
|
+
// A header value is Latin-1 at the transport: anything else is percent-encoded (the server reads the decoded text).
|
|
98
|
+
export const contextHeaderValue = (t) => { try { return encodeURIComponent(t).slice(0, 900); } catch { return ''; } };
|
|
99
|
+
|
|
100
|
+
// ── the price ──
|
|
101
|
+
const round1 = (n) => Math.round(n * 10) / 10;
|
|
102
|
+
/** One price out of the legs a quote run collected. credits is null when any leg is billed at its actual cost. */
|
|
103
|
+
export function quoteFromLegs(legs) {
|
|
104
|
+
const L = (Array.isArray(legs) ? legs : []).filter(Boolean);
|
|
105
|
+
const refused = L.find((l) => l.refused);
|
|
106
|
+
if (refused && !L.some((l) => l.credits > 0)) return { credits: null, legs: L, refused: String(refused.refused) };
|
|
107
|
+
const unpriced = L.filter((l) => !(Number(l.credits) > 0));
|
|
108
|
+
const sum = L.reduce((t, l) => t + (Number(l.priceCredits) > 0 ? Number(l.priceCredits) : Number(l.credits) > 0 ? Number(l.credits) : 0), 0);
|
|
109
|
+
const hold = L.reduce((t, l) => t + (Number(l.credits) > 0 ? Number(l.credits) : 0), 0);
|
|
110
|
+
if (!L.length) return { credits: 0, legs: L, free: true };
|
|
111
|
+
return { credits: unpriced.length ? null : round1(sum), ...(hold > sum + 1e-9 && !unpriced.length ? { holdCredits: round1(hold) } : {}), ...(L.some((l) => l.basis === 'estimate') ? { estimate: true } : {}), legs: L, ...(unpriced.length ? { unpriced: unpriced.map((l) => l.label || 'a step') } : {}) };
|
|
112
|
+
}
|
|
113
|
+
const legLine = (l) => `${l.label || 'a step'}: ${Number(l.priceCredits) > 0 ? `${l.priceCredits}` : Number(l.credits) > 0 ? `${l.credits}` : 'its actual cost'}`;
|
|
114
|
+
export function quoteText(tool, q) {
|
|
115
|
+
if (q.refused) return `NO PRICE (nothing ran, nothing was charged): ${tool} would not start: ${q.refused}`;
|
|
116
|
+
if (q.free) return `PRICE (nothing ran, nothing was charged): ${tool} with these arguments spends no credits.`;
|
|
117
|
+
if (q.credits == null) {
|
|
118
|
+
const known = q.legs.filter((l) => Number(l.credits) > 0);
|
|
119
|
+
return `NO FIXED PRICE (nothing ran, nothing was charged): ${tool} is billed at the actual cost of ${q.unpriced.join(' and ')}, which is not known before it runs.${known.length ? ` The priced part is ${known.map(legLine).join(', ')} credits.` : ''} expected_credits cannot bind this call; run it without it.`;
|
|
120
|
+
}
|
|
121
|
+
return `PRICE (nothing ran, nothing was charged): ${tool} costs ${q.estimate ? 'about ' : ''}${q.credits} credits${q.holdCredits ? ` (it sets aside up to ${q.holdCredits} while it runs; the final charge is its actual cost)` : q.estimate ? ' (its own entry estimate; the final charge is the actual cost of the run)' : ''}.${q.legs.length > 1 ? ` Steps: ${q.legs.map(legLine).join('; ')}.` : ''} To run it at this price, call again without get_cost, with expected_credits: ${q.credits} and an idempotency_key.`;
|
|
122
|
+
}
|
|
123
|
+
export function expectedMismatchText(tool, expected, q) {
|
|
124
|
+
if (q.credits == null) return `Refused, nothing ran and nothing was charged: expected_credits was ${expected}, but ${tool} has no fixed price here (${q.refused || `billed at the actual cost of ${(q.unpriced || []).join(' and ') || 'its model steps'}`}). Run it without expected_credits, or ask the user first.`;
|
|
125
|
+
return `Refused, nothing ran and nothing was charged: the live price of ${tool} is ${q.credits} credits, not the ${expected} you expected. Tell the user the new price; if they agree, call again with expected_credits: ${q.credits}.`;
|
|
126
|
+
}
|
|
127
|
+
export const priceMatches = (expected, q) => q && q.credits != null && Math.abs(Number(expected) - Number(q.credits)) < 0.05;
|
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' }];
|