hermoso 0.1.530 → 0.1.532
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 +2 -2
- package/mcp/arg-guard.mjs +8 -2
- package/mcp/client.mjs +30 -2
- package/mcp/http.mjs +6 -4
- package/mcp/openai-extensions.mjs +10 -0
- package/mcp/spend-guard.mjs +127 -0
- package/mcp/tools.mjs +293 -22
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -67,7 +67,7 @@ Ask in your own words. The agent picks the tools:
|
|
|
67
67
|
- "Compare last week's Google Ads and Meta Ads results and tell me what to pause."
|
|
68
68
|
- "Schedule this video to TikTok, Instagram Reels and YouTube Shorts for Tuesday at 9am."
|
|
69
69
|
|
|
70
|
-
**
|
|
70
|
+
**1199 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
|
|
71
71
|
catalog with exact per-render credit costs plus the full capability map.
|
|
72
72
|
|
|
73
73
|
**No markup on models** on Starter, Pro and Studio. Stop overpaying markups on AI models. Plans: [hermoso.ai/pricing](https://hermoso.ai/pricing).
|
|
@@ -304,7 +304,7 @@ block entirely if you signed in above; it is there for CI, where the process can
|
|
|
304
304
|
|
|
305
305
|
Then ask your agent: *“Generate an image ad with Hermoso.”*
|
|
306
306
|
|
|
307
|
-
### What the
|
|
307
|
+
### What the 1199 tools cover
|
|
308
308
|
|
|
309
309
|
**Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
|
|
310
310
|
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/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
|
+
// 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
|
+
// handler with every request marked as a quote (the server stops each spending point and prices it); `context` rides
|
|
129
|
+
// as one redacted, percent-encoded sentence the server puts on the ledger row of whatever the call bills.
|
|
130
|
+
{ const st = toolCtx.getStore(); if (st?.quoteOnly) h['x-hermoso-quote'] = '1'; if (st?.contextHeader) h['x-hermoso-context'] = st.contextHeader; }
|
|
127
131
|
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
132
|
// THE SAME RULE FOR THE BEARER (2026-09-23). This read `ctx?.token || TOKEN`, so a hosted request whose ctx carried
|
|
129
133
|
// no token would have been sent with the SERVER process's own HERMOSO_TOKEN — an operator credential standing in for
|
|
@@ -149,11 +153,23 @@ export function signInMessage(status, body, msg, isSignedOut = signedOut()) {
|
|
|
149
153
|
return /^sign in to continue\.?$/i.test(String(msg || '').trim()) ? SIGN_IN_HINT : `${msg} ${SIGN_IN_HINT}`;
|
|
150
154
|
}
|
|
151
155
|
|
|
156
|
+
// 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
|
|
157
|
+
// have spent, with 409 {code:'quote_only', quote:{legs}}. The legs join the running call's tally and the call stops here;
|
|
158
|
+
// wrap() turns the tally into the price. `_quoteStop` is never reported as an error.
|
|
159
|
+
export const QUOTE_STOP_MESSAGE = 'Price quote only: nothing ran and nothing was charged.';
|
|
160
|
+
export const quoteStopError = () => Object.assign(new Error(QUOTE_STOP_MESSAGE), { _quoteStop: true, status: 409, _viaApi: true });
|
|
161
|
+
function quoteStopFrom(status, body) {
|
|
162
|
+
if (Number(status) !== 409 || body?.code !== 'quote_only') return;
|
|
163
|
+
const st = toolCtx.getStore();
|
|
164
|
+
if (st?.quoteOnly) for (const l of (Array.isArray(body?.quote?.legs) ? body.quote.legs : [])) (st.quoteLegs ||= []).push(l);
|
|
165
|
+
throw quoteStopError();
|
|
166
|
+
}
|
|
152
167
|
// unwrap the {data}|{error} envelope; throw a clean Error (with .status) on failure
|
|
153
168
|
async function unwrap(res, { read = false } = {}) {
|
|
154
169
|
let body = null;
|
|
155
170
|
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
171
|
if (!res.ok) {
|
|
172
|
+
quoteStopFrom(res.status, body); // a price quote that stopped at a spending point (throws; never data)
|
|
157
173
|
const msg = signInMessage(res.status, body, (body && (body.error || body.message)) || `HTTP ${res.status}`);
|
|
158
174
|
// `_viaApi` MARKS AN ERROR THAT ALREADY REACHED THE SERVER, so route() has already recorded it in the error
|
|
159
175
|
// ledger with the tool name off x-hermoso-tool. wrap() reports ONLY the errors that lack this marker — a local
|
|
@@ -522,7 +538,7 @@ export async function apiUploadUrl(p, url, { fileName = '' } = {}) {
|
|
|
522
538
|
// /api/explore/chat streams Server-Sent-Events; collect to the terminal `done` payload {reply, results, actions}.
|
|
523
539
|
export async function apiSSE(p, body = {}) {
|
|
524
540
|
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 {
|
|
541
|
+
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
542
|
// A server that answered with plain JSON (an older revision) is read as JSON, never as an empty stream.
|
|
527
543
|
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
544
|
const reader = res.body.getReader(); const dec = new TextDecoder();
|
|
@@ -549,7 +565,19 @@ export async function apiSSE(p, body = {}) {
|
|
|
549
565
|
// Submit a render to the job queue and (optionally) poll it to completion. Returns the UNWRAPPED worker result
|
|
550
566
|
// (the {image|video, model} object) on success — the job's `result` is itself a {data} envelope, so we peel it.
|
|
551
567
|
export async function submitJob(type, input, { label = '' } = {}) {
|
|
552
|
-
|
|
568
|
+
const st = toolCtx.getStore();
|
|
569
|
+
// A PRICE QUOTE (get_cost / expected_credits): the same submit as a dry run, priced by the job's own hold, then the call
|
|
570
|
+
// stops: nothing is queued. The server forces the dry run in quote mode too; this keeps the tally exact.
|
|
571
|
+
if (st?.quoteOnly) {
|
|
572
|
+
const d = await apiPost('/api/jobs', { type, input, label, dryRun: true });
|
|
573
|
+
const q = d?.quote || null;
|
|
574
|
+
(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) } : {}) });
|
|
575
|
+
throw quoteStopError();
|
|
576
|
+
}
|
|
577
|
+
// 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
|
|
578
|
+
// retry of a lost answer collapses onto the same job, and the claim learns the job id (pendingText names it).
|
|
579
|
+
const idem = st?.idemKey ? { idempotencyKey: `${st.idemJobKey}.${(st.idemJobs = (st.idemJobs || 0) + 1)}`, toolIdemKey: st.idemKey } : {};
|
|
580
|
+
return apiPost('/api/jobs', { type, input, label, ...idem }); // → publicJob {id, status, ...}
|
|
553
581
|
}
|
|
554
582
|
export async function getJob(id) { return apiGet(`/api/jobs/${encodeURIComponent(id)}`); } // → publicJob
|
|
555
583
|
// 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
|
@@ -17,7 +17,7 @@ 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
19
|
import { registerTools, MCP_INSTRUCTIONS, instructionsFor, parseToolScope, DEFAULT_TOOL_GROUPS, anonDiscoveryGroups } from './tools.mjs';
|
|
20
|
-
import {
|
|
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
|
|
|
@@ -423,7 +425,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
423
425
|
// default rather than to the session default; an explicit `?tools=` still wins, exactly as it does below.
|
|
424
426
|
// A widget host in full mode gets every group here too, so the anonymous roster and the signed-in one agree (see
|
|
425
427
|
// 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
|
|
428
|
+
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
429
|
// WHO PROBES US WITHOUT A TOKEN, BY NAME (2026-09-25). A host's add-connector dialog decides "sign-in needed or
|
|
428
430
|
// not" from THIS answer: ChatGPT probes with an empty body and gets the 401; claude.ai's dialog sends a real
|
|
429
431
|
// tokenless initialize (UA python-httpx) and our 200 made it pre-select "No sign-in". The UA alone cannot tell
|
|
@@ -538,7 +540,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
538
540
|
// ONE host decision for the instructions and the roster (see MCP_INSTRUCTIONS_WIDGET), so the two cannot disagree.
|
|
539
541
|
const widgetHost = isWidgetHost(entry?.client || clientInfoOf(req.body), req);
|
|
540
542
|
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: instructionsFor(scope, { widgetHost }) });
|
|
541
|
-
registerTools(server, { only: scope.groups, directory: scope.directory || false, app: scope.app || null, connectors, widgetHost, hosted: true, ext: extAsked(req), 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
|
|
543
|
+
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
544
|
const transport = new StreamableHTTPServerTransport({
|
|
543
545
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
544
546
|
sessionIdGenerator: () => 'sess_' + randomUUID().replace(/-/g, ''),
|
|
@@ -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/tools.mjs
CHANGED
|
@@ -13,7 +13,7 @@ import { ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
|
13
13
|
// The UTF-16 well-formedness law — see wellFormedServer() below for why it is applied here, and
|
|
14
14
|
// ./well-formed.mjs for why that specifier is the only one that can work in a byte-identical twin.
|
|
15
15
|
import { wellFormedValue, wellFormedString } from './well-formed.mjs';
|
|
16
|
-
import { UNKNOWN_ARGS_SYM, UNKNOWN_ARGS_DETAIL_SYM, UNKNOWN_ARGS_REFUSED_SYM, argGuardMode, argGuardRefuses, declaredArgKeys, didYouMean, unknownArgs, unknownArgsNote, unknownArgsDetail } from './arg-guard.mjs'; // an undeclared argument is named back, never silently stripped (2026-10-26)
|
|
16
|
+
import { CONTEXT_SYM, UNKNOWN_ARGS_SYM, UNKNOWN_ARGS_DETAIL_SYM, UNKNOWN_ARGS_REFUSED_SYM, argGuardMode, argGuardRefuses, declaredArgKeys, didYouMean, unknownArgs, unknownArgsNote, unknownArgsDetail } from './arg-guard.mjs'; // an undeclared argument is named back, never silently stripped (2026-10-26)
|
|
17
17
|
// WHICH CONNECTOR A TOOL NEEDS — the same table and the same decision the Studio chat applies (lib/studio-roster.mjs
|
|
18
18
|
// re-exports every symbol from here). `./roster-scope.mjs` is the only specifier that resolves in a byte-identical
|
|
19
19
|
// twin, for the same reason ./well-formed.mjs is. See applyToolGates() for the seam and roster-scope.mjs for the law.
|
|
@@ -31,6 +31,8 @@ function metaAlternativeNote(name) { return metaAlternativeNoteOf(canonicalToolN
|
|
|
31
31
|
// WHAT A TOOL COSTS AND WHETHER IT IS WORKING — the two facts find_tools puts on every row beside the parameters.
|
|
32
32
|
// Same twin-safe specifier rule as the two imports above: no lib/, no repo, no relative escape.
|
|
33
33
|
import { toolCostClass, costLabel, costKindOf, creditRangeFrom } from './tool-cost.mjs';
|
|
34
|
+
// PRICE FIRST, BUY ONCE (2026-11-11): get_cost / expected_credits / idempotency_key on every paying tool, `context` on all.
|
|
35
|
+
import { spendTool, SPEND_ARG_TEXT, CONTEXT_ARG_TEXT, takeSpendMeta, argsHash, jobKeyRoot, redactContext, contextHeaderValue, quoteFromLegs, quoteText as spendQuoteText, expectedMismatchText, priceMatches } from './spend-guard.mjs';
|
|
34
36
|
import { recordToolOutcome, toolHealth, healthLabel, healthPenalty } from './tool-health.mjs';
|
|
35
37
|
// THE NEXT STEP, NAMED. The prose we already write stays; this is the same advice as an addressable field.
|
|
36
38
|
import { withHints, hintsOf, videoChoiceText, videoChoiceHints, neutralCreditsText, creditsLowHints, welcomeClaimHint, finishedRenderKind, withWidgetResultNote } from './tool-hints.mjs';
|
|
@@ -668,6 +670,104 @@ export async function withWeekPlanNote(tool, r, post = apiPost, brand = '') {
|
|
|
668
670
|
return withHints(out, [...hintsOf(r), { do: 'ask the user the question in this reply', why: d.kind === 'finished' ? 'their planned week has gone out' : 'they just connected their first channel' }]);
|
|
669
671
|
} catch { return r; }
|
|
670
672
|
}
|
|
673
|
+
// ── PRICE FIRST, BUY ONCE (2026-11-11, spend-guard.mjs) ─────────────────────────────────────────────────────────
|
|
674
|
+
// Which registered tools take the three call controls: filled at the ONE registration seam (buildTools' proxy) from
|
|
675
|
+
// the group marker each tool was written under, so the next tool inherits it with nothing to remember.
|
|
676
|
+
const SPEND_TOOL_NAMES = new Set();
|
|
677
|
+
const SPEND_ARGS = Object.freeze({
|
|
678
|
+
get_cost: z.boolean().optional().describe(SPEND_ARG_TEXT.get_cost),
|
|
679
|
+
expected_credits: z.number().optional().describe(SPEND_ARG_TEXT.expected_credits),
|
|
680
|
+
idempotency_key: z.string().optional().describe(SPEND_ARG_TEXT.idempotency_key), // ≤128 chars, checked in takeSpendMeta (a refusal, not a schema bound: fewer roster bytes)
|
|
681
|
+
});
|
|
682
|
+
export const withSpendArgs = (name, def, group) => {
|
|
683
|
+
if (!spendTool(name, group)) return def;
|
|
684
|
+
SPEND_TOOL_NAMES.add(name);
|
|
685
|
+
const s = def && def.inputSchema;
|
|
686
|
+
if (!s || typeof s !== 'object' || s instanceof z.ZodType) return def;
|
|
687
|
+
const add = Object.fromEntries(Object.entries(SPEND_ARGS).filter(([k]) => !Object.prototype.hasOwnProperty.call(s, k)));
|
|
688
|
+
return Object.keys(add).length ? { ...def, inputSchema: { ...s, ...add } } : def;
|
|
689
|
+
};
|
|
690
|
+
export const isSpendToolName = (n) => SPEND_TOOL_NAMES.has(String(n || ''));
|
|
691
|
+
// get_instructions({topic:'paid-calls'}): the one place the controls are explained at length, so the schemas stay short.
|
|
692
|
+
export const PAID_CALLS_GUIDE = [
|
|
693
|
+
'PAID CALLS: every tool that spends credits takes three optional controls. With none of them the call runs exactly as before.',
|
|
694
|
+
'• get_cost: true: the call runs its own pricing and stops where it would spend. Nothing runs, nothing is queued and nothing is charged; the answer is the price in credits (and, for a render, what it sets aside while it runs). A step billed at its actual cost (an AI writing step, an ad-data lookup) has no fixed price, and the answer says so.',
|
|
695
|
+
'• expected_credits: N: run the call only at that price. The server prices it again first; if the live price differs (or has no fixed figure) the call is refused, unbilled, with the new price. Ask the user before resending at a new price.',
|
|
696
|
+
'• idempotency_key: a string up to 128 characters you choose ONCE per purchase and resend UNCHANGED on every retry of it (a timeout, a dropped connection, an error you are unsure of). For 24 hours the same key returns the ORIGINAL result instead of running and billing again; while the first call still runs it names the job to poll; the same key with different arguments is refused. Never invent a new key for a retry: a new key buys it a second time. A first call that failed having charged nothing frees its key, so the corrected retry runs.',
|
|
697
|
+
'The flow: get_cost → tell the user the price → on their yes, call with expected_credits (the quoted number) and an idempotency_key.',
|
|
698
|
+
'call_tool also takes `revision` (from find_tools): a tool that changed since is refused unrun with its current parameters, so rebuild the arguments and call again.',
|
|
699
|
+
'`context` (any tool, or on call_tool): the user\'s underlying goal in one short sentence, for analytics only. Never names, contact details or URLs; it is redacted anyway and never used to generate anything.',
|
|
700
|
+
].join('\n');
|
|
701
|
+
// `context` from wherever it arrived (a declared arg, or the META carrier the arg guard / call_tool / v1 put on the
|
|
702
|
+
// parsed arguments), removed from what the handler sees, redacted.
|
|
703
|
+
export function takeAgentContext(args) {
|
|
704
|
+
if (!args || typeof args !== 'object') return { args, text: '' };
|
|
705
|
+
const raw = typeof args.context === 'string' ? args.context : (typeof args[CONTEXT_SYM] === 'string' ? args[CONTEXT_SYM] : '');
|
|
706
|
+
if (!raw) return { args, text: '' };
|
|
707
|
+
return { args: withoutArgKeys(args, ['context']), text: redactContext(raw) };
|
|
708
|
+
}
|
|
709
|
+
// A copy of the arguments without `keys`, keeping the non-enumerable markers (the arg guard's notes) the layers read.
|
|
710
|
+
export function withoutArgKeys(args, keys) {
|
|
711
|
+
if (!args || typeof args !== 'object' || !keys.some((k) => Object.prototype.hasOwnProperty.call(args, k))) return args;
|
|
712
|
+
const c = { ...args };
|
|
713
|
+
for (const k of keys) delete c[k];
|
|
714
|
+
for (const sym of Object.getOwnPropertySymbols(args)) { try { Object.defineProperty(c, sym, { value: args[sym], enumerable: false, configurable: true }); } catch {} }
|
|
715
|
+
return c;
|
|
716
|
+
}
|
|
717
|
+
const spendRefusal = (text, data = {}) => ({ content: [{ type: 'text', text }], structuredContent: data, isError: true });
|
|
718
|
+
// THE PRICE: the tool's own handler, every request it makes marked as a quote (client.mjs), each spending point stopped
|
|
719
|
+
// and priced by the server. Whatever the handler returns after a stop is discarded; only the tally is the answer.
|
|
720
|
+
async function quoteToolRun(tool, args, extra, st, fn) {
|
|
721
|
+
const qs = { tool, quoteOnly: true, quoteLegs: [], ...(st.account ? { account: st.account } : {}), ...(st.contextHeader ? { contextHeader: st.contextHeader } : {}) };
|
|
722
|
+
let r = null, err = null;
|
|
723
|
+
try { r = await toolCtx.run(qs, () => fn(args, extra)); } catch (e) { err = e; }
|
|
724
|
+
if (!qs.quoteLegs.length) {
|
|
725
|
+
if (err && !err._quoteStop) throw err; // refused before any spend (bad arguments, not connected…): the real call's own answer
|
|
726
|
+
if (r && r.isError) return { refusedResult: r };
|
|
727
|
+
}
|
|
728
|
+
return { quote: quoteFromLegs(qs.quoteLegs) };
|
|
729
|
+
}
|
|
730
|
+
export async function spendPreflight(tool, sm, args, extra, st, fn) {
|
|
731
|
+
if (sm.badKey) return spendRefusal('idempotency_key must be 1 to 128 printable characters. Nothing ran and nothing was charged.', { error: 'invalid_idempotency_key' });
|
|
732
|
+
if (Number.isNaN(sm.expected)) return spendRefusal('expected_credits must be a number (the credits get_cost quoted). Nothing ran and nothing was charged.', { error: 'invalid_expected_credits' });
|
|
733
|
+
if (!sm.getCost && sm.expected == null) return null;
|
|
734
|
+
const { quote: q, refusedResult } = await quoteToolRun(tool, args, extra, st, fn);
|
|
735
|
+
if (refusedResult) return refusedResult;
|
|
736
|
+
if (sm.getCost) return ok(spendQuoteText(tool, q), { getCost: true, credits: q.credits, ...(q.holdCredits ? { holdCredits: q.holdCredits } : {}), quote: q });
|
|
737
|
+
if (!priceMatches(sm.expected, q)) return spendRefusal(expectedMismatchText(tool, sm.expected, q), { error: 'price_changed', expected: sm.expected, credits: q.credits, quote: q });
|
|
738
|
+
return null; // the live price is the expected one: run it
|
|
739
|
+
}
|
|
740
|
+
// THE KEY: claimed on the server (scoped to the caller's account) before the handler runs; a replay, a still-running
|
|
741
|
+
// first call or a reused key with other arguments answers here and nothing runs.
|
|
742
|
+
export async function claimToolKey(tool, key, args, st) {
|
|
743
|
+
const hash = argsHash(tool, args);
|
|
744
|
+
let d;
|
|
745
|
+
try { d = await toolCtx.run({ tool }, () => apiPost('/api/tool-idempotency/claim', { tool, key, hash })); }
|
|
746
|
+
catch (e) { return { answer: spendRefusal(`The idempotency_key could not be checked (${String(e?.message || e).slice(0, 160)}), so nothing ran and nothing was charged. Call again with the same key.`, { error: 'idempotency_unavailable' }) }; }
|
|
747
|
+
if (d?.state === 'replay') {
|
|
748
|
+
const r = d.result && typeof d.result === 'object' ? d.result : { content: [] };
|
|
749
|
+
const content = [{ type: 'text', text: d.text }, ...(Array.isArray(r.content) ? r.content : [])];
|
|
750
|
+
return { answer: { ...r, content, structuredContent: { ...(r.structuredContent || {}), idempotentReplay: true, idempotencyKey: key } } };
|
|
751
|
+
}
|
|
752
|
+
if (d?.state === 'pending') return { answer: { content: [{ type: 'text', text: d.text }], structuredContent: { idempotencyPending: true, idempotencyKey: key, ...(Array.isArray(d.jobIds) && d.jobIds.length ? { jobId: d.jobIds[d.jobIds.length - 1], jobIds: d.jobIds } : {}) } } };
|
|
753
|
+
if (d?.state === 'conflict') return { answer: spendRefusal(d.text, { error: 'idempotency_key_reused', idempotencyKey: key }) };
|
|
754
|
+
if (d?.state !== 'claimed') return { answer: spendRefusal('The idempotency_key could not be claimed, so nothing ran and nothing was charged. Call again with the same key.', { error: 'idempotency_unavailable' }) };
|
|
755
|
+
st.idemKey = key; st.idemJobKey = jobKeyRoot(key, tool);
|
|
756
|
+
return { tool, key, hash };
|
|
757
|
+
}
|
|
758
|
+
// A finished call is SETTLED under its key (a retry gets this answer); a call that failed having charged nothing is
|
|
759
|
+
// offered for RELEASE, and the server releases it only when no job it queued is still running or was charged (so the
|
|
760
|
+
// corrected retry really runs). One that charged and then failed keeps its answer: the key bought it.
|
|
761
|
+
export async function finishToolKey(claim, st, r, err) {
|
|
762
|
+
const charged = Math.round(((st.apiCharged || 0) + (st.jobCharged || 0)) * 10) / 10;
|
|
763
|
+
const failed = !!err || !!r?.isError;
|
|
764
|
+
try {
|
|
765
|
+
if (!failed || charged > 0) {
|
|
766
|
+
const result = r || { content: [{ type: 'text', text: `The first call with this key failed after it started: ${String(err?.message || err).slice(0, 400)}` }], isError: true };
|
|
767
|
+
await toolCtx.run({ tool: claim.tool }, () => apiPost('/api/tool-idempotency/settle', { tool: claim.tool, key: claim.key, hash: claim.hash, result, charged }));
|
|
768
|
+
} else await toolCtx.run({ tool: claim.tool }, () => apiPost('/api/tool-idempotency/release', { tool: claim.tool, key: claim.key, hash: claim.hash }));
|
|
769
|
+
} catch { /* bookkeeping: the answer below is the call's own, and an unsettled claim ages out as stale */ }
|
|
770
|
+
}
|
|
671
771
|
const wrap = (fn) => {
|
|
672
772
|
// A NAMED closure, so registerTools' proxy can stamp the tool name onto the very function it registers and this
|
|
673
773
|
// body can read it back. Reading a name off `fn` instead would be wrong: `fn` is the inner handler and the thing
|
|
@@ -690,7 +790,21 @@ const wrap = (fn) => {
|
|
|
690
790
|
if (isRemote() && _tool !== 'upload_file') { const _lm = findClientLocalMedia(args, { served: null }); if (_lm) throw clientLocalMediaError(_lm); }
|
|
691
791
|
const _st = { tool: _tool };
|
|
692
792
|
if (typeof args?.account === 'string' && args.account.trim() && accountScopedTool(_tool)) _st.account = args.account.trim(); // see MULTI_ACCOUNT_CHANNELS
|
|
693
|
-
|
|
793
|
+
// PRICE FIRST, BUY ONCE (spend-guard.mjs): the call controls never reach the handler; with none of them sent this
|
|
794
|
+
// is exactly the call it always was. `context` is lifted off every tool's arguments and rides as a header.
|
|
795
|
+
{ const _cx = takeAgentContext(args); args = _cx.args; if (_cx.text) _st.contextHeader = contextHeaderValue(_cx.text); }
|
|
796
|
+
const _sm = isSpendToolName(_tool) ? takeSpendMeta(args) : null;
|
|
797
|
+
if (_sm) {
|
|
798
|
+
args = withoutArgKeys(args, ['get_cost', 'expected_credits', 'idempotency_key']);
|
|
799
|
+
const _early = await spendPreflight(_tool, _sm, args, extra, _st, fn);
|
|
800
|
+
if (_early) { try { recordToolOutcome(_tool, { ok: !_early.isError, ms: Date.now() - _t0 }); } catch {} return _early; }
|
|
801
|
+
}
|
|
802
|
+
const _claim = _sm?.key ? await claimToolKey(_tool, _sm.key, args, _st) : null;
|
|
803
|
+
if (_claim?.answer) return _claim.answer;
|
|
804
|
+
let r;
|
|
805
|
+
try { r = await toolCtx.run(_st, () => fn(args, extra)); }
|
|
806
|
+
catch (e) { if (_claim) await finishToolKey(_claim, _st, null, e); throw e; }
|
|
807
|
+
if (_claim) await finishToolKey(_claim, _st, r, null);
|
|
694
808
|
try { recordToolOutcome(_tool, { ok: !r?.isError, ms: Date.now() - _t0 }); } catch {}
|
|
695
809
|
// the charge, once, on every paid reply that did not already say it (chargeNote above)
|
|
696
810
|
try {
|
|
@@ -2219,6 +2333,16 @@ export const INBOX_SOURCES = [
|
|
|
2219
2333
|
// conversation id in its PATH, so `:id` is filled in by reply_to_inbox_item; that route sends to the customer only
|
|
2220
2334
|
// with confirm:true, which reply_to_inbox_item passes because its own contract is "the user already said yes".
|
|
2221
2335
|
{ source: 'intercom', provider: 'intercom', read: '/api/intercom/conversations', reply: '/api/intercom/conversations/:id/reply', kind: 'support', label: 'Intercom conversation', query: { view: 'open' } },
|
|
2336
|
+
// TIKTOK AND LINKEDIN COMMENTS, added 2026-11-11 (connector gap audit C1/G1). Both channels had working per-post
|
|
2337
|
+
// readers and repliers (list/reply_to_tiktok_comment, list/reply_to_linkedin_comment) and NEITHER was a source here,
|
|
2338
|
+
// so the inbox that calls itself unified could not see the busiest comment stream a TikTok-first brand has. Both
|
|
2339
|
+
// vendors read comments PER POST, so each declares `needs` like YouTube does — with its OWN argument rather than
|
|
2340
|
+
// sharing `videoId`, because a YouTube id handed to TikTok (or the reverse) would read as a broken channel.
|
|
2341
|
+
// EACH REPLY NEEDS TWO IDS, so the native id carries both, joined by `|` (a character neither vendor's ids use):
|
|
2342
|
+
// TikTok wants the post id beside the comment id, LinkedIn wants the post urn beside the comment urn
|
|
2343
|
+
// (`parentComment`). normalizeInboxItem builds it from the read route's own answer; reply_to_inbox_item splits it.
|
|
2344
|
+
{ source: 'tiktok', provider: 'tiktok_ads', read: '/api/tiktok-account/comments', reply: '/api/tiktok-account/comment-reply', kind: 'comment', label: 'TikTok comment', needs: { arg: 'tiktokVideoId', param: 'videoId', from: 'list_tiktok_videos' } },
|
|
2345
|
+
{ source: 'linkedin', provider: 'linkedin', read: '/api/linkedin/comments', reply: '/api/linkedin/comment', kind: 'comment', label: 'LinkedIn comment', needs: { arg: 'linkedinPostUrn', param: 'postUrn', from: 'list_published_posts (the urn post_to_linkedin_page returned)' } },
|
|
2222
2346
|
];
|
|
2223
2347
|
|
|
2224
2348
|
export const INBOX_SOURCE_NAMES = INBOX_SOURCES.map((s) => s.source);
|
|
@@ -2338,7 +2462,10 @@ export function normalizeInboxItem(source, row = {}) {
|
|
|
2338
2462
|
// the author line, which is exactly the leak the Bluesky assertion already watches for. Skipping non-primitives
|
|
2339
2463
|
// means a key can be listed once and read on whichever vendor actually spells it that way.
|
|
2340
2464
|
const pick = (...keys) => { for (const k of keys) { const v = k.split('.').reduce((o, p) => (o == null ? o : o[p]), row); if (v != null && typeof v !== 'object' && String(v).trim()) return String(v).trim(); } return ''; };
|
|
2341
|
-
|
|
2465
|
+
// A TWO-PART NATIVE ID for the sources whose reply needs the post too (TikTok, LinkedIn): `<post>|<comment>`.
|
|
2466
|
+
// The read loop puts the envelope's post id on each row when the row lacks it, so both parts come from the vendor.
|
|
2467
|
+
const twoPart = source === 'tiktok' ? [pick('videoId'), pick('commentId')] : source === 'linkedin' ? [pick('postUrn'), pick('commentUrn')] : null;
|
|
2468
|
+
const nativeId = twoPart ? (twoPart[0] && twoPart[1] ? `${twoPart[0]}|${twoPart[1]}` : '') : pick('id', 'commentId', 'name', 'reviewId', 'tweet_id', 'messageId', 'eventId');
|
|
2342
2469
|
// A REVIEW WITH A STAR RATING AND NO WORDS IS STILL AN ITEM. Google reviews are frequently rating-only, and
|
|
2343
2470
|
// dropping them would hide the thing a brand most wants to answer. So text OR a rating is enough to keep it.
|
|
2344
2471
|
// AND A DM WITH NO WORDS IS STILL AN ITEM (2026-10-25): a story mention, a shared reel, a photo or content Meta
|
|
@@ -2363,10 +2490,11 @@ export function normalizeInboxItem(source, row = {}) {
|
|
|
2363
2490
|
// `from` and `sender*` are the DM spellings: Meta's conversation route hands back `from` as a display string
|
|
2364
2491
|
// (its `from.name` sibling is the COMMENT shape) and Bluesky's hands back a handle, falling back to the DID
|
|
2365
2492
|
// when the profile is not in the payload. A DID is ugly and it is still the truth about who wrote.
|
|
2366
|
-
author: pick('author', 'from.name', 'username', 'reviewer.displayName', 'authorDisplayName', 'snippet.authorDisplayName', 'from', 'sender', 'senderHandle', 'senderDid', ...(source === 'intercom' ? ['customer.name', 'customer.email'] : [])) || 'unknown',
|
|
2493
|
+
author: pick('author', 'from.name', 'username', 'reviewer.displayName', 'authorDisplayName', 'snippet.authorDisplayName', 'from', 'sender', 'senderHandle', 'senderDid', ...(source === 'intercom' ? ['customer.name', 'customer.email'] : []), ...(source === 'tiktok' ? ['displayName'] : []), ...(source === 'linkedin' ? ['actor'] : [])) || 'unknown',
|
|
2367
2494
|
text: text || icText,
|
|
2368
2495
|
rating: rating == null ? undefined : rating,
|
|
2369
|
-
|
|
2496
|
+
// TikTok stamps `create_time` in UNIX SECONDS, which Date.parse cannot read, so it is converted here rather than sorted last.
|
|
2497
|
+
at: (source === 'tiktok' && Number(row.createTime) > 0 ? new Date(Number(row.createTime) * 1000).toISOString() : '') || (source === 'intercom' ? pick('updatedAt', 'createdAt') : pick('created_time', 'createdAt', 'timestamp', 'createTime', 'created_utc', 'publishedAt', 'snippet.publishedAt', 'at', 'sentAt')) || '',
|
|
2370
2498
|
permalink: pick('permalink', 'permalink_url', 'url', 'link') || '',
|
|
2371
2499
|
// A MESSAGE THE BRAND ITSELF SENT NEEDS NO REPLY. `inbound === false` / `fromMe === true` is the DM way of
|
|
2372
2500
|
// saying what `reviewReply.comment` says on a review, and without it `unansweredOnly` would put the brand's
|
|
@@ -2374,7 +2502,9 @@ export function normalizeInboxItem(source, row = {}) {
|
|
|
2374
2502
|
answered: row.answered === true || !!pick('reviewReply.comment') || row.hasReplied === true
|
|
2375
2503
|
|| row.inbound === false || row.fromMe === true
|
|
2376
2504
|
// Intercom's waiting_since is set while the CUSTOMER waits on the team and cleared once a teammate replies.
|
|
2377
|
-
|| (source === 'intercom' && !row.waitingSince)
|
|
2505
|
+
|| (source === 'intercom' && !row.waitingSince)
|
|
2506
|
+
// A comment the BRAND wrote is not waiting for the brand: TikTok marks its own as `owner`, LinkedIn's actor is the Page.
|
|
2507
|
+
|| (source === 'tiktok' && row.ownerComment === true) || (source === 'linkedin' && row.isOurs === true),
|
|
2378
2508
|
canReply: !!src.reply,
|
|
2379
2509
|
...(row.messageId || row.eventId ? { messageId: String(row.messageId || row.eventId) } : {}),
|
|
2380
2510
|
// WHAT THE DM IS (Meta's read routes, 2026-10-25): its kind, the sentence to read out, the media / story /
|
|
@@ -2780,6 +2910,7 @@ export const WIDGET_WITHHELD_WHY = Object.freeze({
|
|
|
2780
2910
|
create_reddit_ads_lead_form: 'Reddit retired onsite lead forms, so there is nothing it can create',
|
|
2781
2911
|
save_location: 'a place is saved from the Hermoso app (the location picker) or the CLI; here list_elements reads the saved places and any @name in a prompt uses one',
|
|
2782
2912
|
delete_location: 'a place is removed in the Hermoso app (the location picker) or the CLI; here list_elements reads the saved places',
|
|
2913
|
+
get_instructions: 'every tool description on this host is already the full one (SLIM_TOOL_DESCRIPTIONS is never applied here), so there is nothing to fetch',
|
|
2783
2914
|
});
|
|
2784
2915
|
export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload', 'billing_status', 'connect_connector', 'x_post_insights', 'x_post_insights_historical', 'fetch_social_data', 'remix_static', ...WIDGET_MONEY_MOVEMENT_TOOLS, ...Object.keys(WIDGET_WITHHELD_WHY)]);
|
|
2785
2916
|
// remix_static is clone_static under its old name (kept for callers that cached it); on ChatGPT two tools for one
|
|
@@ -3061,7 +3192,7 @@ export const widgetHostHides = (name, widgetHost) => (widgetHost
|
|
|
3061
3192
|
// self-describing schema, and the default roster sits at its token ceiling. find_tools finds these, call_tool or a
|
|
3062
3193
|
// direct tools/call runs them, and enable_tools({groups:['create']}) (or 'all', or ?tools=all) lists them. post_edit's description names
|
|
3063
3194
|
// edit_timeline, and edit_timeline's names video_frames, so a caller that starts from post_edit reaches both.
|
|
3064
|
-
export const ON_DEMAND_TOOLS = new Set(['save_location', 'delete_location', 'edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft', 'make_static_ads', 'make_listing_set', 'reangle_video', 'repurpose_transcript', 'tiktok_ideas', 'generate_sfx', 'repurpose_image', 'add_presenter', 'list_products', 'add_product', 'refresh_products', 'update_product', 'remove_product', 'make_short', 'finish_short', 'rewrite_ad_line', 'save_video_preset', 'list_video_presets', 'update_video_preset', 'delete_video_preset', 'make_from_preset', 'list_winning_ads']); // rewrite_ad_line (2026-11-08): multiply_ad's description and its change note name it // list_winning_ads (2026-11-08): multiply_ad's description names it // the five video-preset tools (2026-11-08): clone_video's description names save_video_preset, whose reply names make_from_preset; list_video_presets names the rest // make_short / finish_short (2026-11-08): clone_video's description names make_short, and make_short's description and reply name finish_short // make_static_ads (2026-10-06): plan_variations' description names it, so a caller planning variants reaches it; // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine) // generate_sfx (2026-11-03): find_sound's and generate_voice's descriptions name it
|
|
3195
|
+
export const ON_DEMAND_TOOLS = new Set(['submit_to_gallery', 'list_gallery_submissions', 'withdraw_from_gallery', 'save_location', 'delete_location', 'edit_timeline', 'video_frames', 'recast_hook', 'face_check', 'list_templates', 'finish_draft', 'make_static_ads', 'make_listing_set', 'reangle_video', 'repurpose_transcript', 'tiktok_ideas', 'generate_sfx', 'repurpose_image', 'add_presenter', 'list_products', 'add_product', 'refresh_products', 'update_product', 'remove_product', 'make_short', 'finish_short', 'rewrite_ad_line', 'save_video_preset', 'list_video_presets', 'update_video_preset', 'delete_video_preset', 'make_from_preset', 'list_winning_ads']); // rewrite_ad_line (2026-11-08): multiply_ad's description and its change note name it // list_winning_ads (2026-11-08): multiply_ad's description names it // the five video-preset tools (2026-11-08): clone_video's description names save_video_preset, whose reply names make_from_preset; list_video_presets names the rest // make_short / finish_short (2026-11-08): clone_video's description names make_short, and make_short's description and reply name finish_short // make_static_ads (2026-10-06): plan_variations' description names it, so a caller planning variants reaches it; // recast_hook and face_check (2026-09-24): hook_variants names recast_hook, and recast_hook's reply names face_check // finish_draft (2026-10-03): every 480p Seedance 2.5 video reply names it (draftLine) // generate_sfx (2026-11-03): find_sound's and generate_voice's descriptions name it
|
|
3065
3196
|
|
|
3066
3197
|
// ── A TOOL NAME A HOST STILL HOLDS MUST KEEP ANSWERING (2026-09-14) ─────────────────────────────────────────────
|
|
3067
3198
|
// ChatGPT users get the tool roster OpenAI SNAPSHOTTED at review time, never a live tools/list (memory:
|
|
@@ -3685,11 +3816,48 @@ export async function ingestFileParam(tool, args, upload = apiUploadUrl) {
|
|
|
3685
3816
|
rest[target] = u;
|
|
3686
3817
|
return rest;
|
|
3687
3818
|
}
|
|
3819
|
+
// ── THE HEAVIEST DESCRIPTIONS, SLIMMED; THE FULL TEXT ON DEMAND (2026-11-11) ──────────────────────────────────────────
|
|
3820
|
+
// Sixteen of the longest descriptions in the default roster (measured on the registry stub, 2026-11-11) were each
|
|
3821
|
+
// ~1.7-2.2K characters, re-sent on every turn by every host that does not defer schemas. Each is replaced HERE, per host,
|
|
3822
|
+
// by a subset of its own sentences, copied verbatim (the gates that prevent real mistakes stay: who is in frame, the
|
|
3823
|
+
// publish confirm, what spends credits, the never-guess-the-account rules), plus a one-line pointer. The ORIGINAL text is
|
|
3824
|
+
// unchanged at its registration and served whole by get_instructions({topic:'<tool>'}), so no rule is lost, only moved.
|
|
3825
|
+
// NEVER ON A WIDGET HOST (ChatGPT): its descriptions are OpenAI's reviewed snapshot, and a pointer to a tool it does not
|
|
3826
|
+
// list is exactly the wording OpenAI's scan holds (chatgpt-tool-scan-holds), so ChatGPT keeps every full description.
|
|
3827
|
+
export const SLIM_TOOL_DESCRIPTIONS = Object.freeze({
|
|
3828
|
+
make_thumbnail: 'Render a click-driving YOUTUBE / Shorts / Instagram THUMBNAIL or video cover through the full production pipeline (concept, casting, scene, render, tweaks, text), not a bare image prompt. Use it for any "thumbnail", "video cover" or MrBeast-style packaging ask INSTEAD of generate_image. Default: 4 variants at 1K (YouTube shows 1280x720 at most), each billed; 2K or 4K only when the user asks. dryRun:true prices it free. The headline overlay is free. WHO IS IN FRAME — never assume or silently substitute a stranger. A framework with a person and no face photo is refused (nothing charged): ask the user once — themselves (a face photo, identity-locked), a generated person (`castGenericPerson:true`), or a people-free framework. TEXT — default is a CLEAN render with the headline TYPESET over it (free, legible, correctly spelled): pass `headline`. `bakeText:true` only on an explicit ask for words painted INTO the image. ALWAYS check the returned postRenderCheck against the image before presenting it.',
|
|
3829
|
+
schedule_post: 'Use this to schedule a social media post for later, on one channel or several. Costs no credits (X alone bills per call: hermoso_credits). FREE PLAN: 5 posts a month on up to 3 channels (a post counts once; UTC month); paid plans are unlimited. Over it the post is refused and nothing is queued: relay the refusal as written, never retry or split the post. A MULTI-SLIDE creative goes in imageUrls[] as a CAROUSEL, in order, never just slide 1. Name the time in `at`, or pass `useQueue:true` for the brand’s next free POSTING SLOT (“just queue it”). Hermoso never guesses the account: Pinterest needs `boardId` (list_pinterest_boards), else refused; a LinkedIn COMPANY PAGE post needs `linkedinOrganizationId` (list_linkedin_pages), else it goes to the person’s own profile. Several Facebook Pages need `pageId` (list_meta_pages): none named is refused at scheduling with the Pages listed, so ask the user which. Resolve these FIRST; let the user pick. Posts GO LIVE PUBLICLY by default.',
|
|
3830
|
+
post_to_x: 'Use this when the user wants to post to X (Twitter) now. Publish to the user’s connected X (Twitter) account — a single post, a post with an image or video render attached, a reply to an existing post, or a whole THREAD (pass `thread` as an array and each part is posted as a reply to the one before). This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. Text is NEVER truncated. Write altText whenever you attach a render. COSTS CREDITS at X’s per-post price, every post in a thread billed: a post that contains a link (any web address, and every quote post) is X’s link rate, far higher than one without (hermoso_credits gives both figures). When the user asks what a post will cost, call this with dryRun:true: it posts NOTHING and returns that exact post’s costCredits. THIS TOOL POSTS ORGANICALLY — it does not create an ad campaign. Needs X connected (Settings > Connectors > X).',
|
|
3831
|
+
clip_video: 'Cut ONE long video into several RANKED, ready-to-post short clips (podcast, webinar, interview, talk, long ad cut -> Reels/Shorts/TikTok). Transcribes with timestamps, picks the strongest SELF-CONTAINED moments, then cuts + reframes each with ffmpeg — no video model renders anything, so it is fast and cheap. NOT supported: TikTok / Instagram / Facebook links, and anything age-restricted, private, members-only, geo-blocked or still LIVE — those fail fast with the real reason and are fully refunded, so ask for a direct file or an upload rather than retrying. SUBTITLES ARE BURNED IN BY DEFAULT (white sentence case timed by the spoken words, thin black outline, bottom safe band, no box) because short-form is watched on mute; captions:false for clean footage. TIMING IS APPROXIMATE (per-sentence, not word-level); never promise frame-accurate sync.',
|
|
3832
|
+
post_to_bluesky: 'Use this when the user wants to post to Bluesky. Publishing costs no credits. Publish a post to Bluesky as the connected account. Text up to 300 characters — Bluesky ALSO caps a post at 3000 UTF-8 bytes, so an emoji-heavy post can be under 300 characters and still be refused; Hermoso checks both and says which limit. Bluesky requires a CONFIRMED EMAIL on the account before it will process any video — if it is unconfirmed you get a refusal saying so, and reconnecting will not help. Returns the post\'s public bsky.app URL. Connect at Settings > Connectors > Bluesky.',
|
|
3833
|
+
make_explainer: 'AI HOST EPISODE: format \'host_episode\' = one presenter talking to camera, the camera changing every piece (frontal, three-quarter, close), the same host and set throughout, native voice, 16:9. It returns a 480p DRAFT; HD (720p) is a SEPARATE call with `fromDraft` (quote it with dryRun, run it only when the user asks). Otherwise: turn a TOPIC into a finished narrated explainer video, in one of TWO LANES (`lane`). BURNED ON-SCREEN TEXT IS OFF BY DEFAULT; the film ships clean unless the user asks; `captions:true` adds held key points and `subtitles:true` adds subtitles timed to the narration. Takes SEVERAL minutes — one image render per frame; NOT for a short product ad: use render_ad, generate_video or make_template_ad.',
|
|
3834
|
+
multiply_ad: 'MULTIPLY a winning video ad into N variants: each gets a NEW character, outfit, location and/or objects while the cut, the camera motion, the pacing and the ORIGINAL AUDIO stay exactly as they were, and any burned-in captions are removed. ONE JOB PER VARIANT: get_job each until done; never describe a variant before its URL arrives. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each variant bills perVariantCredits. dryRun:true pays for that read; pass its `plan` back to render without paying again. No ad: list_winning_ads. New words: rewrite_ad_line.',
|
|
3835
|
+
set_post_refill: 'Turn AUTOPILOT POSTING (autoposting, the posting refill) on, change it or pause it. Available on paid plans: on the Free plan switching it on is refused, so relay that and do not retry. PASS ONLY WHAT CHANGES. It always makes NEW posts (a fresh image, carousel or video for each) and never re-posts the Library. Ask the user for anything missing (goal, mode, channels) rather than guessing. `mode` picks what happens to each batch: "review" (the default) keeps the fresh posts as drafts until a human approves them (run_post_refill approve), "auto" schedules them straight away. `enabled:false` is the PAUSE — it removes the recurring job outright, and posts already queued are left alone (cancel those with cancel_scheduled).',
|
|
3836
|
+
post_to_linkedin_page: 'Use this when the user wants to post to a LinkedIn company Page. Publishing costs no credits. This is a DIFFERENT thing from post_to_linkedin, which publishes to the person’s own profile: pick the one the user actually asked for and never substitute. organizationId comes from list_linkedin_pages; omit it only when the account administers exactly one Page. This PUBLISHES immediately and PUBLICLY — ALWAYS show the user the exact text and get an explicit yes BEFORE calling. LinkedIn does NOT allow the image, video, captions or thumbnail of a published post to be swapped afterwards, so get all of that right first (the copy can still be edited with manage_linkedin_post).',
|
|
3837
|
+
post_to_telegram: 'Use this when the user wants to post to a Telegram channel or group. Publishing costs no credits. Publish to a Telegram channel, group or chat as the brand\'s own bot. WHICH CHAT IS ALWAYS REQUIRED AND IS NEVER GUESSED: pass chatId as the public channel\'s @username (e.g. @yourchannel) or its numeric id (a group is negative; a supergroup or channel starts with -100). THE BOT MUST BE IN THE CHAT — an administrator with Post Messages for a channel, an unrestricted member for a group; if it is not, Telegram refuses and the error says to add it rather than to reconnect. 0 credits. Connect at Settings > Connectors > Telegram.',
|
|
3838
|
+
generate_image: 'Use this when the user asks for an image ad, ad creative, product photo or social post graphic for their business or product, or an ANIMATED GIF (format gif) or SVG file (format svg). It renders a finished image on a real image model, on their saved brand (product photos, logo), and returns a URL ready to post. For a video, use render_ad. The saved brand’s product photo is used automatically (see get_brand / list_product_photos), so never ask the user to upload it again. MULTI-BRAND: hydration uses the SAVED brand; for a different brand (a fresh draft_brand) pass its own productImages/logo as refImages with useBrand:false, or the wrong product lands in the shot. model = a catalog id from hermoso_capabilities (omit for the default). Seconds. Spends credits.',
|
|
3839
|
+
make_motion_film: 'MOTION GRAPHICS and ANIMATED VIDEO in one call: kinetic typography (animated text), a logo animation or intro, a product launch film, an animated social cut. Pick it for \'animated video\', \'motion graphics\', \'kinetic text\', \'text animation\', \'logo animation\', \'logo intro\', \'animated explainer\' asks. STEPS: without confirm it is a FREE quote that gives both prices (length, the music\'s tempo; the spec in structuredContent): say both; preview:true is a 1-credit contact sheet of the still version; confirm:true renders. Only the user\'s or the brand\'s words appear: never invent a claim, number or review.',
|
|
3840
|
+
post_to_youtube: 'Use this when the user wants to upload a video or Short to YouTube. Publishing costs no credits. Publish a finished video to the brand’s connected YouTube channel. Pass a Hermoso render URL (or an upload_file url for a local/external file). PUBLISHES PUBLICLY BY DEFAULT: a plain "post this to YouTube" puts it ON the channel (confirm the title with the user, as for any publish) and notifies subscribers as YouTube does. Pass the privacy the user states instead: "unlisted" (link-only) or "private" (eyes-only). A video meant to run as a YouTube/Google AD should go up "unlisted" — private videos CANNOT be used as ads. Never choose unlisted for music on your own. Needs a connected YouTube channel (Settings > Connectors > YouTube).',
|
|
3841
|
+
hook_variants: 'HOOK MULTIPLIER: give ONE finished video ad N NEW OPENING HOOKS, N complete versions to A/B test. Pass the video\'s FILE URL (a render, job result, list_library or upload_file). 1-5 versions, default 3. COST: quoteOnly:true returns the exact credits free: state that first. A paid planning read (planCredits) runs next, then each planned version bills like fix_beat for the hook\'s seconds. dryRun:true pays for that read; pass its `plan` back to render exactly those. Returns ONE JOB PER VERSION; call get_job on each until done, never describe a version before its URL arrives.',
|
|
3842
|
+
post_x_article: 'Publish a long-form ARTICLE to the user’s connected X (Twitter) account — X’s own long-form format, which is a different thing from a long POST. THE HARD LIMIT TO PLAN AROUND: X allows only about 10 Article DRAFTS and 5 Article PUBLISHES per account per DAY, it publishes that cap nowhere, and it counts a FAILED attempt against them — so never iterate on an article by republishing it, and use `publish: false` to save a draft for the user to read in X’s own composer when they want to review before it goes out. This PUBLISHES immediately and PUBLICLY: show the user the full text and get an explicit yes first, because a published Article can NEVER be edited (X’s own rule — edit_x_post will refuse it) and the only remedy is to delete and republish, which costs another of the five. Costs credits (X bills per API request, and this is three of them). Needs X connected (Settings > Connectors > X).',
|
|
3843
|
+
render_ad: 'Use this when the user asks for a video ad, Reel, TikTok ad, UGC video or commercial for their business or product It renders a finished ad on a real video model, on their brand (real product photos, brand voice), as an mp4 ready to post: timed shots, clean speech, text set in post, no music, captions or end card unless asked. Pass plan_ad’s full output as `creative` (plan_ad turns a one-line ask into the concept). Most models cap a clip at 15s and the longest at 30s: dryRun:true shows the act split free, before spending. The brand’s saved product photo (get_brand / list_product_photos) is the real product in the shots, with no upload needed. CAST A SAVED CREATOR with `creator` (list_creators) so the same person stars in every ad; otherwise each render casts a new face. The user’s own pictures (creative.inserts, or `inserts`) are laid in full screen at their seconds. An app shown on a device screen with no real screens to lay there returns needsAppScreens, nothing charged: ask, then pass screenChoice or screens. 1–3 min; poll get_job while it renders. Spends credits.',
|
|
3844
|
+
});
|
|
3845
|
+
export const slimPointer = (name) => ` Full rules: get_instructions({topic:'${name}'}).`;
|
|
3846
|
+
const _slimmed = new WeakMap();
|
|
3847
|
+
// The canonical (full) description of each slimmed tool, recorded the first time the def passes through, for get_instructions.
|
|
3848
|
+
export const FULL_TOOL_DESCRIPTIONS = new Map();
|
|
3849
|
+
function slimDef(name, def) {
|
|
3850
|
+
if (!def || typeof def !== 'object' || !Object.prototype.hasOwnProperty.call(SLIM_TOOL_DESCRIPTIONS, name)) return def;
|
|
3851
|
+
if (typeof def.description === 'string') FULL_TOOL_DESCRIPTIONS.set(name, def.description); // the latest canon's (a stdio and a hosted canon can differ)
|
|
3852
|
+
let o = _slimmed.get(def);
|
|
3853
|
+
if (!o) { o = { ...def, description: SLIM_TOOL_DESCRIPTIONS[name] + slimPointer(name) }; _slimmed.set(def, o); }
|
|
3854
|
+
return o;
|
|
3855
|
+
}
|
|
3688
3856
|
const _widgetDescribed = new WeakMap();
|
|
3689
3857
|
export function defForHost(name, def, widgetHost) {
|
|
3690
3858
|
if (widgetHost) def = withFileParam(name, def);
|
|
3691
3859
|
explicitHints(def);
|
|
3692
|
-
let out = widgetHost ? renamedForWidgetHost(stripOutputSchema(def), name) : stripOutputSchema(def);
|
|
3860
|
+
let out = widgetHost ? renamedForWidgetHost(stripOutputSchema(def), name) : slimDef(name, stripOutputSchema(def));
|
|
3693
3861
|
if (widgetHost && out && Object.prototype.hasOwnProperty.call(WIDGET_HOST_DESCRIPTIONS, name)) {
|
|
3694
3862
|
let o = _widgetDescribed.get(out); if (!o) { o = { ...out, description: WIDGET_HOST_DESCRIPTIONS[name] }; _widgetDescribed.set(out, o); } out = o;
|
|
3695
3863
|
}
|
|
@@ -4402,6 +4570,8 @@ export function guardUnknownArgs(server) {
|
|
|
4402
4570
|
}
|
|
4403
4571
|
}
|
|
4404
4572
|
const out = await orig.call(this, tool, args, toolName);
|
|
4573
|
+
// `context` (a META argument, arg-guard.mjs): the parse above stripped it unless the tool declares it; carried here.
|
|
4574
|
+
if (out && typeof out === 'object' && typeof args?.context === 'string' && args.context && !Object.prototype.hasOwnProperty.call(out, 'context')) { try { Object.defineProperty(out, CONTEXT_SYM, { value: args.context, enumerable: false, configurable: true }); } catch {} }
|
|
4405
4575
|
if (unknown.length && mode !== 'off' && out && typeof out === 'object') { try { Object.defineProperty(out, UNKNOWN_ARGS_SYM, { value: unknownArgsNote(toolName, unknown, declared), enumerable: false, configurable: true }); Object.defineProperty(out, UNKNOWN_ARGS_DETAIL_SYM, { value: unknownArgsDetail(unknown, declared), enumerable: false, configurable: true }); } catch {} }
|
|
4406
4576
|
return out;
|
|
4407
4577
|
};
|
|
@@ -4581,6 +4751,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4581
4751
|
try { if (handler) handler._hermosoTool = name; } catch {}
|
|
4582
4752
|
try { noteBrandKeys(name, def); } catch {} // which arg names the Hermoso brand, for the after-reply notes
|
|
4583
4753
|
def = withAccountArg(name, def); // a multi-account channel's tool names WHICH account (see MULTI_ACCOUNT_CHANNELS)
|
|
4754
|
+
def = withSpendArgs(name, def, group); // a paying tool takes get_cost / expected_credits / idempotency_key (spend-guard.mjs)
|
|
4584
4755
|
// TITLE IS MIRRORED INTO `annotations`, and it is deliberately a DUPLICATE rather than a move.
|
|
4585
4756
|
// The MCP spec ranks the TOP-LEVEL `title` above `annotations.title`, and the SDK emits it top-level, so
|
|
4586
4757
|
// that is the correct home and it stays. But Anthropic's directory requirements list `title` AMONG the
|
|
@@ -4713,6 +4884,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4713
4884
|
}));
|
|
4714
4885
|
} catch { return {}; }
|
|
4715
4886
|
};
|
|
4887
|
+
// A TOOL'S REVISION (2026-11-11): 10 hex characters of its description plus its argument schema, so a call_tool that
|
|
4888
|
+
// names an older revision is refused with the current shape (see makeCallToolHandler). Cached per handle.
|
|
4889
|
+
const _revCache = new WeakMap();
|
|
4890
|
+
const toolRevision = (h) => {
|
|
4891
|
+
if (!h) return '';
|
|
4892
|
+
if (_revCache.has(h)) return _revCache.get(h);
|
|
4893
|
+
let js = '';
|
|
4894
|
+
try { js = h.inputSchema ? JSON.stringify(z.toJSONSchema(h.inputSchema)) : ''; } catch { js = ''; }
|
|
4895
|
+
const rev = createHash('sha256').update(String(h.description || '') + '\n' + js).digest('hex').slice(0, 10);
|
|
4896
|
+
try { _revCache.set(h, rev); } catch {}
|
|
4897
|
+
return rev;
|
|
4898
|
+
};
|
|
4716
4899
|
// ── QUERY SELF-HEAL (2026-09-03) ───────────────────────────────────────────────────────────────────────────────
|
|
4717
4900
|
// The ledger's `no_match` dead ends are what users ask for IN THEIR OWN WORDS, and the first real one was a user
|
|
4718
4901
|
// saying "instant forms" for what our catalog calls create_meta_lead_form. A search that only matches literal
|
|
@@ -5014,8 +5197,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5014
5197
|
const _oaFit = _otherAppRows.filter((r) => r.score >= _bestHere && (r.full || r.score >= 1.4 * _bestHere)).sort((x, y) => y.score - x.score);
|
|
5015
5198
|
const _homes = [...new Set(_oaFit.filter((r) => r.score >= 0.8 * _oaFit[0]?.score).slice(0, 3).map((r) => APP_HOME_TEXT(r.name, ctx)))];
|
|
5016
5199
|
if (!related && !quiet && !_homes.length && /[a-z0-9]{2}/i.test(String(q || ''))) reportDeadEnd('no_match', 'find_tools', `find_tools found nothing for: ${(q || '(empty)').replace(/["'`]/g, '').slice(0, 80)}${g ? ' in group ' + g : ''}`, { query: q, group: g });
|
|
5017
|
-
for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
|
|
5018
|
-
const lines = top.map((r) => `• ${r.name} [${r.group}${g && r.group !== g ? `, outside the ${g} group` : ''}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] —${r.description}\n cost: ${r.cost.label} · health: ${healthLabel(r.health)}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
|
|
5200
|
+
for (const r of top) { r.params = compactParams(ctx.handleOf[r.name]); r.revision = toolRevision(ctx.handleOf[r.name]); }
|
|
5201
|
+
const lines = top.map((r) => `• ${r.name} [${r.group}${g && r.group !== g ? `, outside the ${g} group` : ''}${r.inRoster ? '' : ', not in your list'}${r.hold ? ', ' + r.hold : ''}] —${r.description}\n cost: ${r.cost.label} · health: ${healthLabel(r.health)} · revision: ${r.revision}\n params: ${Object.entries(r.params).map(([k, v]) => `${k}: ${v}`).join(' | ') || '(none)'}`);
|
|
5019
5202
|
const looser = top.length > total ? top.length - total : 0;
|
|
5020
5203
|
const text = related
|
|
5021
5204
|
? `${total} tool(s) match${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}${total > cap ? ` (showing the top ${cap} — narrow the query)` : ''}${looser ? `${total ? '; the other' : ''} ${looser} shown ${looser === 1 ? 'is a looser match' : 'are looser matches'}, ranked below` : ''}. ${ctx.widgetHost ? 'Call any of them directly by name; one marked not_connected needs that connector first.' : 'Run any of them with call_tool({name, args}) — a tool that is "not in your list" still runs; one marked not_connected needs that connector first.'} COST is what the call spends (free means free on every plan); HEALTH is what this server has seen recently — "no recent calls" means we have not seen it run, not that it is broken, and a row marked FAILING or held is ranked last rather than hidden.\n${lines.join('\n')}`
|
|
@@ -5079,9 +5262,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5079
5262
|
const bare = raw.replace(/^mcp__[A-Za-z0-9_-]*?__(?=[a-z])/, '').replace(/^hermoso[:./]/i, '');
|
|
5080
5263
|
return handleOf[bare] ? bare : raw;
|
|
5081
5264
|
};
|
|
5082
|
-
const makeCallToolHandler = (ctx) => async ({ name, args } = {}, extra) => {
|
|
5265
|
+
const makeCallToolHandler = (ctx) => async ({ name, args, revision, context } = {}, extra) => {
|
|
5083
5266
|
const n = bareToolName(String(name || '').trim(), ctx.handleOf);
|
|
5084
5267
|
const h = ctx.handleOf[n];
|
|
5268
|
+
// `context` may ride on call_tool itself or inside args (a META argument, arg-guard.mjs); it is never an argument of the tool.
|
|
5269
|
+
const _ctxText = typeof context === 'string' && context ? context : (args && typeof args === 'object' && typeof args.context === 'string' ? args.context : '');
|
|
5270
|
+
if (args && typeof args === 'object' && Object.prototype.hasOwnProperty.call(args, 'context')) { args = { ...args }; delete args.context; }
|
|
5085
5271
|
// A RETIRED NAME ANSWERS THE SAME SENTENCE HERE AS IT DOES ON A DIRECT CALL (2026-09-17). LEGACY_TOOL_NAMES was
|
|
5086
5272
|
// built for `tools/call` from a host holding an old snapshot and wired ONLY into installHeldToolCalls — so an
|
|
5087
5273
|
// agent that reached the same name through call_tool got "does not exist" plus the six wrong suggestions above,
|
|
@@ -5117,6 +5303,13 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5117
5303
|
// A WIDGET HOST RUNS ONLY WHAT IT LISTS (2026-09-30, OpenAI's generic-executor rule — see EXECUTOR_TOOLS). Reached
|
|
5118
5304
|
// only by a host still holding call_tool from an older scan; a tool this session does not list is refused by name.
|
|
5119
5305
|
if (ctx.widgetHost && h.enabled === false) return { content: [{ type: 'text', text: `${n} is not in your Hermoso tool list on this host, so call_tool will not run it. Use the tools in your list.` }], isError: true };
|
|
5306
|
+
// A STALE SCHEMA IS REFUSED, NOT HALF-RUN (2026-11-11, a describe → invoke revision handshake):
|
|
5307
|
+
// find_tools gives each tool a `revision`; a call that names an old one gets the CURRENT parameters and revision
|
|
5308
|
+
// back and runs nothing, so the agent re-reads instead of sending arguments the tool no longer means.
|
|
5309
|
+
if (typeof revision === 'string' && revision.trim() && revision.trim() !== toolRevision(h)) {
|
|
5310
|
+
const params = Object.entries(compactParams(h)).map(([k, v]) => `${k}: ${v}`).join(' | ');
|
|
5311
|
+
return { content: [{ type: 'text', text: `${n} has changed since revision ${revision.trim()}: nothing was run. Current revision ${toolRevision(h)}. Current params: ${params || '(none)'}. Rebuild the arguments from these and call again with revision "${toolRevision(h)}".` }], structuredContent: { error: 'stale_revision', name: n, revision: toolRevision(h), params: compactParams(h) }, isError: true };
|
|
5312
|
+
}
|
|
5120
5313
|
let input = args && typeof args === 'object' ? args : {};
|
|
5121
5314
|
const extraKeys = undeclaredArgs(h, input);
|
|
5122
5315
|
if (extraKeys.length) {
|
|
@@ -5136,8 +5329,23 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5136
5329
|
// rename in the dependency shows up here as a loud "not a function" rather than a silently skipped call.
|
|
5137
5330
|
const fn = h.handler || h.callback;
|
|
5138
5331
|
if (typeof fn !== 'function') return { content: [{ type: 'text', text: `${n} is registered but has no runnable handler on this server build.` }], isError: true };
|
|
5332
|
+
if (_ctxText && input && typeof input === 'object') { try { Object.defineProperty(input, CONTEXT_SYM, { value: _ctxText, enumerable: false, configurable: true }); } catch {} }
|
|
5139
5333
|
return h.inputSchema ? await fn(input, extra) : await fn(extra);
|
|
5140
5334
|
};
|
|
5335
|
+
// THE FULL TEXT, ON DEMAND (2026-11-11): see SLIM_TOOL_DESCRIPTIONS. A slimmed tool's whole original description, any
|
|
5336
|
+
// tool's canonical description, or the guide to the paid-call controls. Free, read-only, no request leaves this process.
|
|
5337
|
+
server.registerTool('get_instructions', {
|
|
5338
|
+
title: 'Read a tool\'s full instructions',
|
|
5339
|
+
description: 'Return the FULL instructions for a Hermoso tool whose description ends "Full rules: get_instructions(…)", or for any tool by name, or topic "paid-calls" for get_cost, expected_credits, idempotency_key, revision and context. Read it before a call that depends on rules the short description leaves out. Free.',
|
|
5340
|
+
inputSchema: { topic: z.string().describe('a tool name (e.g. make_thumbnail) or "paid-calls"') },
|
|
5341
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
5342
|
+
}, wrap(async (a) => {
|
|
5343
|
+
const t = String(a.topic || '').trim().replace(/^mcp__[A-Za-z0-9_-]*?__(?=[a-z])/, '');
|
|
5344
|
+
if (/^(paid[-_ ]?calls?|get_cost|expected_credits|idempotency(_key)?|costs?|revision|context)$/i.test(t)) return ok(PAID_CALLS_GUIDE, { topic: 'paid-calls' });
|
|
5345
|
+
const full = FULL_TOOL_DESCRIPTIONS.get(t) || (Array.isArray(TOOL_CANON) ? TOOL_CANON.find((e) => e.name === t)?.def?.description : '') || '';
|
|
5346
|
+
if (full) return ok(`${t} — full instructions:\n${full}`, { topic: t, slimmed: Object.prototype.hasOwnProperty.call(SLIM_TOOL_DESCRIPTIONS, t) });
|
|
5347
|
+
return { content: [{ type: 'text', text: `No instructions for "${t}". Pass a tool name exactly as listed (find_tools finds one) or "paid-calls". Slimmed tools: ${Object.keys(SLIM_TOOL_DESCRIPTIONS).join(', ')}.` }], isError: true };
|
|
5348
|
+
}));
|
|
5141
5349
|
server.registerTool('find_tools', {
|
|
5142
5350
|
title: 'Find a Hermoso tool by name or task',
|
|
5143
5351
|
description: 'Search EVERY Hermoso tool — your starting list is deliberately short, and everything else in the product is here — by name, task or group. Each row gives the tool\'s PARAMETERS in one line, its CREDIT COST (free means free on every plan; a tool that runs a model quotes the live per-model figure) and its recent HEALTH on this server (failure rate and typical duration, or "no recent calls", which means unseen and not broken). Use it the moment the user asks for something you do not see a tool for (a campaign, an ad set, a lead form, a click-to-WhatsApp ad, a report, keywords, audiences): a tool missing from your list is NEVER proof the feature is missing. Then run the tool with call_tool. A tool that is failing or needs a connector this workspace has not made is ranked last and marked, never hidden — pass onlyHealthy:true if you want those left out. Free, read-only.',
|
|
@@ -5155,6 +5363,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5155
5363
|
inputSchema: {
|
|
5156
5364
|
name: z.string().describe('the tool name exactly as find_tools returned it, e.g. create_meta_lead_form'),
|
|
5157
5365
|
args: z.record(z.any()).optional().describe('the tool\'s arguments as an object, exactly as its own schema takes them'),
|
|
5366
|
+
revision: z.string().optional().describe('the revision find_tools gave for this tool; a changed tool is refused unrun, with its current params'),
|
|
5367
|
+
context: z.string().optional().describe(CONTEXT_ARG_TEXT + '; every tool also accepts it directly'),
|
|
5158
5368
|
},
|
|
5159
5369
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
5160
5370
|
}, sessionBound(makeCallToolHandler, ctx));
|
|
@@ -5683,7 +5893,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5683
5893
|
server.registerTool('list_inbox', {
|
|
5684
5894
|
title: 'One inbox for comments, replies, mentions, reviews and DMs',
|
|
5685
5895
|
description: "EVERYTHING PEOPLE SAID TO THIS BRAND, across every connected channel, in one list: Facebook and "
|
|
5686
|
-
+ "Instagram comments, Threads replies and mentions, YouTube comments, Google Business reviews, "
|
|
5896
|
+
+ "Instagram comments, Threads replies and mentions, YouTube comments, TikTok comments, LinkedIn Page comments, Google Business reviews, "
|
|
5687
5897
|
+ "Bluesky replies and mentions, and X mentions — plus DIRECT MESSAGES on Meta (Messenger and Instagram "
|
|
5688
5898
|
+ "Direct), Instagram, Bluesky, X and Telegram. Support tickets too: the open conversations in the brand's Intercom inbox. Use this for 'what do I need to reply to', 'any new comments', 'any new DMs', 'how are people responding'. Each "
|
|
5689
5899
|
+ "item carries a composite id you hand straight to reply_to_inbox_item. A channel that is not connected is "
|
|
@@ -5691,12 +5901,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5691
5901
|
+ "never mistaken for a quiet week. Each DM conversation folds to ONE item (the newest thing the other person said). "
|
|
5692
5902
|
+ "Messenger and Instagram DMs need no argument; each has a messageKind (text, story_mention, story_reply, share, "
|
|
5693
5903
|
+ "image, video...), a spoken summary, and unavailable:true when Meta withholds the content. Bluesky and X DMs need "
|
|
5694
|
-
+ "conversationId (list_bluesky_convos / list_x_dms). FREE ON EVERY CHANNEL EXCEPT X, which bills per API call — X mentions and X DMs cost credits per item returned, "
|
|
5904
|
+
+ "conversationId (list_bluesky_convos / list_x_dms). TikTok and LinkedIn comments are read per post: pass tiktokVideoId / linkedinPostUrn. FREE ON EVERY CHANNEL EXCEPT X, which bills per API call — X mentions and X DMs cost credits per item returned, "
|
|
5695
5905
|
+ "and every other source only re-reads what the per-channel tools already read.",
|
|
5696
5906
|
inputSchema: {
|
|
5697
5907
|
sources: z.array(z.string()).optional().describe(`Limit to these sources: ${INBOX_SOURCE_NAMES.join(', ')}. Omit for every connected channel.`),
|
|
5698
5908
|
postId: z.string().optional().describe('Restrict Facebook/Instagram to one post or media id (Meta reads comments per post).'),
|
|
5699
5909
|
videoId: z.string().optional().describe('Restrict YouTube to one video.'),
|
|
5910
|
+
tiktokVideoId: z.string().optional().describe('Include the comments on this TikTok post (its id, from list_tiktok_videos). TikTok reads comments per post.'),
|
|
5911
|
+
linkedinPostUrn: z.string().optional().describe('Include the comments on this LinkedIn Page post (its urn, as post_to_linkedin_page returned). LinkedIn reads comments per post.'),
|
|
5700
5912
|
// DECLARED BECAUSE A SOURCE ASKS FOR IT. Before 2026-08-25 `meta_dm` declared `needs.arg: conversationId`
|
|
5701
5913
|
// and this schema did not carry it, so the note telling a caller to pass it named an argument the tool
|
|
5702
5914
|
// refused — and the handler quietly read `postId` for anything that was not `videoId`. The check now
|
|
@@ -5712,7 +5924,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5712
5924
|
notes: z.array(z.string()).describe('sources that could not be read, and why — never silently dropped'),
|
|
5713
5925
|
},
|
|
5714
5926
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
5715
|
-
}, wrap(async ({ sources, postId, videoId, conversationId, pageId, limit, unansweredOnly }) => {
|
|
5927
|
+
}, wrap(async ({ sources, postId, videoId, tiktokVideoId, linkedinPostUrn, conversationId, pageId, limit, unansweredOnly }) => {
|
|
5716
5928
|
const want = Array.isArray(sources) && sources.length
|
|
5717
5929
|
? sources.map((x) => String(x).trim().toLowerCase())
|
|
5718
5930
|
: INBOX_SOURCE_NAMES;
|
|
@@ -5724,9 +5936,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5724
5936
|
// argument that was not `videoId` collapsed onto `postId` — which meant a source declaring a THIRD argument
|
|
5725
5937
|
// silently read the wrong caller's id and could never be satisfied by the note it printed. A map cannot do
|
|
5726
5938
|
// that: an arg this tool does not accept is simply absent, and absent is what produces the helpful note.
|
|
5727
|
-
const argValues = { postId, videoId, conversationId };
|
|
5939
|
+
const argValues = { postId, videoId, tiktokVideoId, linkedinPostUrn, conversationId };
|
|
5728
5940
|
for (const src of INBOX_SOURCES.filter((x) => want.includes(x.source))) {
|
|
5729
|
-
const q = { limit: per };
|
|
5941
|
+
const q = src.source === 'tiktok' ? { maxCount: per } : { limit: per }; // TikTok's page size is max_count
|
|
5730
5942
|
if (src.needs) {
|
|
5731
5943
|
// These sources read PER POST (or, for a DM, per conversation), so without an id there is nothing to ask
|
|
5732
5944
|
// for. Saying so is very different from letting the vendor 400 and filing it as "could not be read": one
|
|
@@ -5760,7 +5972,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5760
5972
|
// conversation, so folding them would collapse every chat into one — keying the fold on `kind` would do
|
|
5761
5973
|
// exactly that. The trigger is therefore the field the fold consumes, not the category.
|
|
5762
5974
|
const use = src.dmReplyId ? foldConversationRows(rows, d?.[src.dmReplyId]) : rows;
|
|
5763
|
-
|
|
5975
|
+
// The per-post comment sources carry their post on the ENVELOPE; it is the first half of their two-part id.
|
|
5976
|
+
const withPost = (r) => (src.source === 'tiktok' && !r.videoId && d?.videoId ? { ...r, videoId: d.videoId } : src.source === 'linkedin' && !r.postUrn && d?.postUrn ? { ...r, postUrn: d.postUrn } : r);
|
|
5977
|
+
for (const r0 of use) { const r = withPost(r0); const it = normalizeInboxItem(src.source, src.dmReplyId && typeof d?.conversationId === 'string' && !r.conversationId ? { ...r, conversationId: d.conversationId } : r); if (it) items.push(it); }
|
|
5764
5978
|
} catch (e) {
|
|
5765
5979
|
// AN ID THAT DOES NOT RESOLVE ENDS THE CALL AS AN ERROR (2026-10-26, customer report: an unconnected pageId
|
|
5766
5980
|
// answered "0 items"). The caller named a Page; reading nothing from it is not a quiet inbox, it is a wrong id.
|
|
@@ -5803,6 +6017,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
5803
6017
|
else if (t.source === 'threads' || t.source === 'threads_mention') payload.replyToId = t.nativeId;
|
|
5804
6018
|
else if (t.source === 'youtube') payload.parentId = t.nativeId;
|
|
5805
6019
|
else if (t.source === 'google_business') payload.reviewName = t.nativeId;
|
|
6020
|
+
// TWO-PART ids (see normalizeInboxItem): TikTok wants the post beside the comment, LinkedIn the post urn beside the
|
|
6021
|
+
// comment urn it replies under. A malformed id is refused here rather than sent with half of what the vendor needs.
|
|
6022
|
+
else if (t.source === 'tiktok' || t.source === 'linkedin') {
|
|
6023
|
+
const cut = String(t.nativeId).lastIndexOf('|');
|
|
6024
|
+
const post = cut > 0 ? t.nativeId.slice(0, cut) : '', cmt = cut > 0 ? t.nativeId.slice(cut + 1) : '';
|
|
6025
|
+
if (!post || !cmt) return { content: [{ type: 'text', text: `"${String(id).slice(0, 80)}" is missing its post or comment half. Re-read list_inbox for a current id.` }], isError: true };
|
|
6026
|
+
if (t.source === 'tiktok') { payload.videoId = post; payload.commentId = cmt; } else { payload.postUrn = post; payload.parentComment = cmt; }
|
|
6027
|
+
}
|
|
5806
6028
|
// Bluesky's native id is the post's AT-URI. The route resolves the two strong refs (root AND parent, each a
|
|
5807
6029
|
// uri+cid pair) from it with one getPosts call — carrying four values on the wire would mean four ways for a
|
|
5808
6030
|
// caller to detach a reply from its thread, and the cid is a content hash nobody but the server can keep fresh.
|
|
@@ -6671,7 +6893,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
6671
6893
|
verifiedAccount: z.boolean().optional().describe('true = verified accounts only'), hasPortfolio: z.boolean().optional().describe('true = creators with a portfolio only'), hasPublicContactEmail: z.boolean().optional().describe('true = creators with a public contact email only'),
|
|
6672
6894
|
featuredInPaidAds: z.boolean().optional().describe('true = creators featured in paid ads only'), excludeMessagedCreators: z.boolean().optional().describe('true = hide creators this brand has already messaged'),
|
|
6673
6895
|
limit: z.number().optional().describe('1 to 50'), cursor: z.string().optional().describe('next page: the cursor value from the previous reply'),
|
|
6674
|
-
pageId: z.string().optional().describe("Facebook Page id linked to the brand's Instagram; from list_meta_pages"), account: z.string().optional().describe('direct Instagram account (@handle or id) when several are connected; from list_connector_accounts'),
|
|
6896
|
+
pageId: z.string().optional().describe("Facebook Page id linked to the brand's Instagram; from list_meta_pages; if omitted, the Page that can search is used"), account: z.string().optional().describe('direct Instagram account (@handle or id) when several are connected; from list_connector_accounts'),
|
|
6675
6897
|
},
|
|
6676
6898
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
6677
6899
|
}, wrap(async (a) => {
|
|
@@ -9918,7 +10140,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9918
10140
|
locales: z.array(z.number()).optional().describe('Meta language ids from find_meta_audiences(type:"adlocale")'),
|
|
9919
10141
|
publisherPlatforms: z.array(z.enum(['facebook', 'instagram', 'audience_network', 'messenger', 'threads'])).optional().describe('placement platforms: facebook, instagram, audience_network, messenger or threads'),
|
|
9920
10142
|
facebookPositions: z.array(z.string()).optional().describe('feed, story, facebook_reels, marketplace, search, instream_video, right_hand_column, …'),
|
|
9921
|
-
instagramPositions: z.array(z.string()).optional().describe('stream, story, reels,
|
|
10143
|
+
instagramPositions: z.array(z.string()).optional().describe('stream, story, reels, profile_feed, profile_reels, ig_search, explore_home. "explore_home" needs "stream" beside it; the old "explore" placement is retired by Meta and refused'),
|
|
9922
10144
|
messengerPositions: z.array(z.string()).optional().describe('Messenger placements, e.g. sponsored_messages'), audienceNetworkPositions: z.array(z.string()).optional().describe('Audience Network placement names, as Meta defines them'),
|
|
9923
10145
|
threadsPositions: z.array(z.enum(['threads_stream'])).optional().describe('Threads feed. Threads needs "instagram" in publisherPlatforms too (and "stream" if you list instagramPositions); the ad runs under the Threads profile linked to the Instagram account, found automatically'),
|
|
9924
10146
|
devicePlatforms: z.array(z.enum(['mobile', 'desktop'])).optional().describe('mobile or desktop'), userOs: z.array(z.enum(['iOS', 'Android'])).optional().describe('iOS or Android'),
|
|
@@ -23552,6 +23774,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23552
23774
|
frames: z.number().int().min(2).max(16).optional().describe('format gif only: frames in the loop, 2 to 16 (default 9)'),
|
|
23553
23775
|
frameMs: z.number().int().min(20).max(2000).optional().describe('format gif only: milliseconds per frame, 20 to 2000, rounded to 10 (default 200; 9 frames x 200 = a 1.8 s loop)'),
|
|
23554
23776
|
complexity: z.enum(['low', 'medium', 'high']).optional().describe('only with model gpt-image-2.6 / 2.6-flare (the default refuses it), when the user asks: how much it plans before drawing (default high). No separate charge'),
|
|
23777
|
+
background: z.enum(['transparent']).optional().describe('transparent = a PNG with NO background (alpha channel): a cut-out product, icon, sticker or logo to place on anything. Runs on gpt-image-2.6 (default) or gpt-image-2.6-flare, drawn from the prompt alone (no refImages, mask or markup; the saved brand\'s photos stay off it), priced like a plain image of that size. The reply\'s `background` says what the file really has. To cut the background out of a photo the user already has, point them to the Remove background tool in the Hermoso app'),
|
|
23555
23778
|
imageSize: z.string().optional().describe('pixel-size preset for models that support it: 1K/2K, and 4K on the models hermoso_capabilities lists with a 4K imageSize price (a 4K ask on any other model is refused, free) — omit for the default'),
|
|
23556
23779
|
fixLabel: z.boolean().optional().describe('default true: when the saved brand\'s product photo rides in this render, the product\'s label on the finished image is READ and compared with the photo, and re-printed from the photo at close range ONLY if it came out wrong (a label that is already right costs only the check; a re-print costs more, and dryRun:true returns the up-to price). The reply says whether the label was checked, fixed or left as rendered (`labelPass`). Pass false when the user wants the packaging left exactly as generated: nothing is checked or re-printed.'),
|
|
23557
23780
|
logoPlacement: z.enum(['auto', 'overlay', 'in_scene', 'none']).optional().describe('overlay: real logo file laid flat. in_scene: the scene leaves a blank patch on the object and the real file is printed onto it (perspective, light). Never drawn by the model. auto (default): from the prompt'),
|
|
@@ -23563,13 +23786,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23563
23786
|
productCheck: z.any().optional().describe('present when the brand\'s product photo was attached: {verdict: match|mismatch|unclear|absent, wordmark, issues[]} — the render compared against the real product photo. mismatch/absent means the product in the image is NOT the brand\'s product; say so, never present it as done'),
|
|
23564
23787
|
logoPlaced: z.any().optional().describe('how the real logo file went on: {applied, mode: in_scene|fix|overlay, object?, fellBack?, notALogo?, asked?}; notALogo = the saved logo is a photo, so nothing was added (ask for the logo file only when the logo was asked for; asked:false means nobody asked for one, so say nothing about it)'),
|
|
23565
23788
|
format: z.string().optional().describe('gif or svg when one was asked for'),
|
|
23789
|
+
background: z.string().optional().describe('transparent or opaque, read off the delivered file, when a transparent background was asked for'),
|
|
23566
23790
|
preview: z.string().optional().describe('an SVG\'s PNG preview, for places that cannot show a vector'),
|
|
23567
23791
|
frames: z.number().optional().describe('an animated GIF\'s frame count, read off the delivered file'),
|
|
23568
23792
|
labelPass: z.any().optional().describe('present when the product\'s label lines were known: {status: checked (read and already right, left as rendered) | fixed (read wrong and re-printed from the photo) | left (not re-printed: label faces away, product not found, or the pass could not run) | off (fixLabel:false), read, expected[], note}. Tell the user the note.'),
|
|
23569
23793
|
},
|
|
23570
23794
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
23571
23795
|
_meta: openaiMeta(AD_RESULT_URI, 'Rendering your ad image…', 'Ad image ready'),
|
|
23572
|
-
}, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel, logoPlacement, job, format, frames, frameMs, complexity }) => {
|
|
23796
|
+
}, wrap(async ({ prompt, refImages, useBrand, aspectRatio, model, imageSize, raw, mask, fixLabel, logoPlacement, job, format, frames, frameMs, complexity, background }) => {
|
|
23573
23797
|
const refs = refImages?.length ? (await Promise.all(refImages.map(toRef))).filter(Boolean) : undefined;
|
|
23574
23798
|
const maskRef = mask ? await toRef(mask) : undefined; // a local mask file travels the same way a local reference does
|
|
23575
23799
|
// `raw === true` only — a raw render is opt-in and must be stated properly, so a truthy stray value never
|
|
@@ -23579,8 +23803,8 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
23579
23803
|
// NO MODEL AND NO JOB = AN IMAGE AD (2026-11-03): this tool is what an agent reaches for to make an image ad, so an
|
|
23580
23804
|
// unnamed render takes the static_ad job's engine (judged in the job-engine registry) rather than the catalog's
|
|
23581
23805
|
// general first row. A masked edit keeps its own rule (the server routes a mask to the one engine that takes it).
|
|
23582
|
-
if (!job && !model && !maskRef && format !== 'gif' && format !== 'svg') job = 'static_ad'; // a GIF / SVG picks its own engine (GPT Image 2.6)
|
|
23583
|
-
const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}), ...(logoPlacement ? { logoPlacement } : {}), ...(job ? { job } : {}), ...(format ? { format } : {}), ...(frames != null ? { frames } : {}), ...(frameMs != null ? { frameMs } : {}), ...(complexity ? { complexity } : {}) }; // fixLabel:false only when stated: the label check is ON by default. job: the image job's engine when no model is named (server: lib/image-job-engines.mjs)
|
|
23806
|
+
if (!job && !model && !maskRef && format !== 'gif' && format !== 'svg' && background !== 'transparent') job = 'static_ad'; // a GIF / SVG / transparent PNG picks its own engine (GPT Image 2.6)
|
|
23807
|
+
const _imgBody = { prompt, refImages: refs, useBrand: maskRef ? false : useBrand !== false, aspectRatio, model, imageSize, ...(maskRef ? { mask: maskRef } : {}), ...(raw === true ? { raw: true } : {}), ...(fixLabel === false ? { fixLabel: false } : {}), ...(logoPlacement ? { logoPlacement } : {}), ...(job ? { job } : {}), ...(format ? { format } : {}), ...(frames != null ? { frames } : {}), ...(frameMs != null ? { frameMs } : {}), ...(complexity ? { complexity } : {}), ...(background ? { background } : {}) }; // fixLabel:false only when stated: the label check is ON by default. job: the image job's engine when no model is named (server: lib/image-job-engines.mjs)
|
|
23584
23808
|
// A CALLER WITH A WAIT BUDGET GETS A QUEUED JOB (2026-09-21, R321). Only `/v1/tools/generate_image?wait=` sets
|
|
23585
23809
|
// `waitMs`; every other caller renders inside the request exactly as before. The server does the same brand-photo
|
|
23586
23810
|
// handling either way, then queues the ordinary image job; awaitRenderJob waits what the caller allowed and hands
|
|
@@ -27289,6 +27513,53 @@ function memoryNoteVerdict(text) {
|
|
|
27289
27513
|
return ok(`${d?.message || ''}${items ? '\n' + items : ''}${kept ? '\n' + kept : ''}${nf}`, { deleted: !!d?.deleted, needsConfirm: !!d?.needsConfirm });
|
|
27290
27514
|
}));
|
|
27291
27515
|
|
|
27516
|
+
// THE PUBLIC GALLERY (v2 2026-11-11, lib/gallery.mjs): one Publish action, no form. Title, format and niche are read off
|
|
27517
|
+
// the creation; the brand name shows. Reviewed by the Hermoso team before it is public, unpublished at once. Held on
|
|
27518
|
+
// demand (ON_DEMAND_TOOLS); each reply names the next tool.
|
|
27519
|
+
const galleryUrlOf = async (id, url) => {
|
|
27520
|
+
if (url) return String(url);
|
|
27521
|
+
const m = /^L(\d+)$/i.exec(String(id || '').trim()); if (!m) return '';
|
|
27522
|
+
const list = await readStore('heist.assets.v1');
|
|
27523
|
+
return (Array.isArray(list) ? list.filter((x) => x && x.url) : [])[Number(m[1])]?.url || '';
|
|
27524
|
+
};
|
|
27525
|
+
server.registerTool('submit_to_gallery', {
|
|
27526
|
+
title: 'Publish to the Hermoso gallery',
|
|
27527
|
+
description: "Publish ONE image or video this brand made in Hermoso to the public Hermoso gallery, only when the user asks for that item. Nothing to fill in: the title, format and niche come from how it was made, and the brand name is shown. Nothing is public until the Hermoso team approves it; withdraw_from_gallery unpublishes it any time. Uploaded files are refused. preview:true returns what would be public and saves nothing. Free.",
|
|
27528
|
+
inputSchema: {
|
|
27529
|
+
id: z.string().optional().describe('Library id (L0…) from list_library'),
|
|
27530
|
+
url: z.string().optional().describe('or the file link'),
|
|
27531
|
+
preview: z.boolean().optional(),
|
|
27532
|
+
},
|
|
27533
|
+
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true },
|
|
27534
|
+
}, wrap(async ({ id, url, preview }) => {
|
|
27535
|
+
const u = await galleryUrlOf(id, url);
|
|
27536
|
+
if (!u) return ok('Pass a Library id (L0… from list_library) or the file link. Nothing was published.', { submitted: false });
|
|
27537
|
+
const d = await apiPost('/api/gallery/submit', { url: u, via: 'agent', ...(preview ? { preview: true } : {}) });
|
|
27538
|
+
if (d?.preview) { const p = d.public || {}; return ok(`Would be public:\n Title: ${p.title}\n Brand: ${p.brandShown || 'none on the brand'}\n${(p.recipe || []).map((r) => ` ${r.k}: ${r.v}`).join('\n')}\nNothing was saved. Call again without preview to publish it.`, { preview: true, public: p }); }
|
|
27539
|
+
return ok(`${d?.message || 'Sent for review.'} Status: ${d?.status}${d?.publicUrl ? ` · ${d.publicUrl}` : ''} (id ${d?.id}). list_gallery_submissions shows it; withdraw_from_gallery unpublishes it.`, { submitted: true, id: d?.id, status: d?.status, publicUrl: d?.publicUrl || null });
|
|
27540
|
+
}));
|
|
27541
|
+
server.registerTool('list_gallery_submissions', {
|
|
27542
|
+
title: 'List gallery submissions',
|
|
27543
|
+
description: "This brand's submissions to the public Hermoso gallery: each one's id, status (pending review, approved with its public page, rejected) and file. Free.",
|
|
27544
|
+
inputSchema: { all: z.boolean().optional().describe('include withdrawn ones') },
|
|
27545
|
+
annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false },
|
|
27546
|
+
}, wrap(async ({ all }) => {
|
|
27547
|
+
const d = await apiGet('/api/gallery/mine', all ? { all: '1' } : {});
|
|
27548
|
+
const rows = d?.submissions || [];
|
|
27549
|
+
if (!rows.length) return ok('Nothing from this brand is in the gallery or waiting for review. submit_to_gallery adds one when the user asks.', { submissions: [] });
|
|
27550
|
+
return ok(rows.map((r) => `${r.id} · ${r.status}${r.publicUrl ? ` · ${r.publicUrl}` : ''} · ${r.title} · ${abs(r.url)}`).join('\n') + '\nwithdraw_from_gallery unpublishes one.', { submissions: rows });
|
|
27551
|
+
}));
|
|
27552
|
+
server.registerTool('withdraw_from_gallery', {
|
|
27553
|
+
title: 'Withdraw from the gallery',
|
|
27554
|
+
description: 'Unpublish: take an item off the public Hermoso gallery at once (its page stops working), whether pending or approved. Pass the submission id from list_gallery_submissions, or the file link. Free.',
|
|
27555
|
+
inputSchema: { id: z.string().optional().describe('submission id (g_…)'), url: z.string().optional().describe('or the file link') },
|
|
27556
|
+
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
27557
|
+
}, wrap(async ({ id, url }) => {
|
|
27558
|
+
if (!id && !url) return ok('Pass the submission id (list_gallery_submissions) or the file link. Nothing was changed.', { withdrawn: [] });
|
|
27559
|
+
const d = await apiPost('/api/gallery/withdraw', { ...(id ? { id } : {}), ...(url ? { url } : {}) });
|
|
27560
|
+
return ok(d?.message || 'Unpublished.', { withdrawn: d?.withdrawn || [] });
|
|
27561
|
+
}));
|
|
27562
|
+
|
|
27292
27563
|
server.registerTool('fetch_asset', {
|
|
27293
27564
|
title: 'Fetch asset',
|
|
27294
27565
|
description: 'Resolve a generated asset reference (a /generated/… path or any URL) to a clickable absolute URL + a direct download URL.',
|
package/package.json
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.532",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
|
-
"description": "AI marketing agent and AI ad generator, run from your own AI agent.
|
|
5
|
+
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1199 tools. No markup on models on Starter and up. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on any plan; Free includes 5 posts a month and every paid plan posts without a limit. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads, ChatGPT Ads and AppLovin Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky, Telegram, WordPress, Discord, Slack, Whop and Google Business Profile. INSTAGRAM DM AUTOMATIONS: a keyword comment, a DM, a story reply or an ig.me link gets an automatic private reply from the brand. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
8
8
|
"hermoso": "bin/hermoso.mjs"
|