hermoso 0.1.527 → 0.1.528
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/ad-spy-keyword.mjs +29 -0
- package/mcp/tools.mjs +73 -38
- package/package.json +1 -1
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// A KEYWORD AD SEARCH IS ACROSS ALL ADVERTISERS (2026-11-07). Pure, so tools/ad-spy-keyword-check.mjs runs the real code.
|
|
2
|
+
// Twin file: cli/mcp/ad-spy-keyword.mjs (byte-identical).
|
|
3
|
+
const flatQ = (t) => String(t || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').trim();
|
|
4
|
+
|
|
5
|
+
// Is this ad's advertiser PAGE the searched name ("Liquid Death Mountain Water" yes, "Garage Beer" no)?
|
|
6
|
+
export function pageIsQuery(pageName, query) {
|
|
7
|
+
const q = flatQ(query), n = flatQ(pageName);
|
|
8
|
+
if (!q || !n) return false;
|
|
9
|
+
return ` ${n} `.includes(` ${q} `) || n.replace(/ /g, '').includes(q.replace(/ /g, ''));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Brand's own pages first (stable), other advertisers after and flagged; nothing is dropped.
|
|
13
|
+
export function orderKeywordAds(raw, query) {
|
|
14
|
+
const rows = Array.isArray(raw) ? raw : [];
|
|
15
|
+
if (!flatQ(query)) return { ordered: rows, otherCount: 0 };
|
|
16
|
+
const mine = [], other = [];
|
|
17
|
+
for (const x of rows) (pageIsQuery(x && x.page_name, query) ? mine : other).push(x);
|
|
18
|
+
return { ordered: [...mine, ...other.map((x) => ({ ...x, __other: true }))], otherCount: other.length };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Which pictures ride along, each captioned with the ROW it belongs to (a bare picture list drifts from the rows).
|
|
22
|
+
export function pictureRows(items, max = 3) {
|
|
23
|
+
const out = [];
|
|
24
|
+
(Array.isArray(items) ? items : []).forEach((x, i) => {
|
|
25
|
+
const u = x && (x.thumb || x.image || x.cover || x.media);
|
|
26
|
+
if (typeof u === 'string' && /^https?:\/\//.test(u) && !/\.(mp4|webm|mov)([?#]|$)/i.test(u) && out.length < max) out.push({ i, url: u, caption: `Picture below = ad ${i + 1} in the list${(x.page_name || x.advertiser || x.author) ? ` (advertiser: ${x.page_name || x.advertiser || x.author})` : ''}` });
|
|
27
|
+
});
|
|
28
|
+
return out;
|
|
29
|
+
}
|
package/mcp/tools.mjs
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// Spend tools hit routes guarded by gateSpend → requireAuth; locally the dev account always resolves (no auth
|
|
5
5
|
// needed today), and the SAME guard becomes authoritative under real auth — so this honors no-anon-spend as-is.
|
|
6
6
|
import { z } from 'zod';
|
|
7
|
+
import { orderKeywordAds, pictureRows } from './ad-spy-keyword.mjs';
|
|
7
8
|
import { absolutizeAssetUrl, publicOrigin } from './public-url.mjs';
|
|
8
9
|
import { apiGet, apiPost, apiPut, apiPatch, apiDelete, apiSSE, submitJob, getJob, getJobSettled, jobResult, pollJob, jobWaitMs, toRef, localRefVerdict, apiUpload, apiUploadUrl, isRemote, API_BASE, PROFILE, ENV_PREFIX, mcpCtx, storeSuffix, forgetWorkspaceScope, toolCtx, reportToolError, reportDeadEnd, hostRendersWidgets, connectedProviders, setPinnedProfile, signedOut, SIGN_IN_HINT, brandScope } from './client.mjs';
|
|
9
10
|
import { readFile } from 'node:fs/promises';
|
|
@@ -2882,15 +2883,15 @@ export const WIDGET_HOST_SPLITS = Object.freeze(withSplitAccounts({
|
|
|
2882
2883
|
splitOp('schedule_x_broadcast', { action: 'schedule' }, ['sourceId!', 'startAt!', 'manualPublish!', 'endAt', 'title', 'description', 'locale', 'availableForReplay', 'isLocked', 'chatOption', 'thumbnailMediaId', 'confirm'], splitW(false, false), 'Schedule an X live broadcast', 'Use this when the brand wants to schedule an X live video broadcast. sourceId comes from list_x_stream_sources. manualPublish is required: true waits for start_x_broadcast; false makes X put the brand on air automatically at startAt and needs confirm:true.' + X_BILLED),
|
|
2883
2884
|
splitOp('update_x_broadcast', { action: 'update' }, ['id!', 'startAt', 'endAt', 'manualPublish', 'title', 'description', 'locale', 'availableForReplay', 'isLocked', 'chatOption', 'thumbnailMediaId', 'confirm'], splitW(true, true), 'Change a scheduled X broadcast', 'Use this when a scheduled X broadcast needs a new time, title or setting. Only the fields you pass change. Turning manualPublish off needs confirm:true.' + X_BILLED),
|
|
2884
2885
|
splitOp('start_x_broadcast', { action: 'go_live' }, ['id!', 'confirm'], splitW(true, false), 'Go live on a scheduled X broadcast', 'Use this when a broadcast scheduled with manualPublish:true should go on air NOW, publicly, as the brand. Needs confirm:true after the user approved the exact broadcast and stream.' + X_BILLED, { confirm: 'must be true: this goes on air publicly' }),
|
|
2885
|
-
splitOp('create_x_broadcast', { action: 'create' }, ['sourceId!'], splitW(
|
|
2886
|
-
splitOp('publish_x_broadcast', { action: 'publish' }, ['id!', 'title', 'locale', 'chatOption', 'skipPost', 'confirm'], splitW(true, false), 'Publish an X broadcast', 'Use this when a created X broadcast should go on air NOW, publicly, as the brand. X also posts it unless skipPost is true. Needs confirm:true.' + X_BILLED, { confirm: 'must be true: this goes on air publicly' }),
|
|
2886
|
+
splitOp('create_x_broadcast', { action: 'create' }, ['sourceId!', 'title', 'locale', 'chatOption', 'skipPost', 'confirm'], splitW(true, false), 'Go live on an X stream source now', 'Use this when the brand wants to go on air NOW on X without a schedule. It is public at once: X announces it in a post unless skipPost is true, and ends it if no video reaches the source within 5 minutes. sourceId comes from list_x_stream_sources. Needs confirm:true.' + X_BILLED, { confirm: 'must be true: this goes on air publicly' }),
|
|
2887
2887
|
splitOp('end_x_broadcast', { action: 'end' }, ['id!', 'confirm'], splitW(true, true), 'End an X broadcast', 'Use this when a running X broadcast should stop for everyone watching. Cannot be undone; needs confirm:true.' + X_BILLED, { confirm: 'must be true to end' }),
|
|
2888
2888
|
splitOp('delete_x_broadcast', { action: 'delete' }, ['id!', 'confirm'], splitW(true, true), 'Delete an X broadcast', 'Use this when a broadcast and its replay should be removed. End a running one first. Needs confirm:true.' + X_BILLED, { confirm: 'must be true to delete' }),
|
|
2889
2889
|
splitOp('cancel_x_broadcast', { action: 'cancel' }, ['id!', 'confirm'], splitW(true, true), 'Cancel a scheduled X broadcast', 'Use this when a scheduled X broadcast should be deleted. Cannot be undone; needs confirm:true.' + X_BILLED, { confirm: 'must be true to cancel' }),
|
|
2890
2890
|
],
|
|
2891
2891
|
manage_x_stream_source: [
|
|
2892
|
-
splitOp('list_x_stream_sources', { action: 'list' }, [], SPLIT_RO, 'List the X stream sources', 'Use this when you need the brand’s RTMP stream sources on X: id,
|
|
2893
|
-
splitOp('get_x_stream_source', { action: 'get' }, ['id!'], SPLIT_RO, 'Read one X stream source', 'Use this when you need one stream source’s
|
|
2892
|
+
splitOp('list_x_stream_sources', { action: 'list' }, [], SPLIT_RO, 'List the X stream sources', 'Use this when you need the brand’s RTMP stream sources on X: id, RTMPS URL, region, whether video is arriving. X leaves the stream key out; get_x_stream_key reads it. Read-only.' + X_BILLED),
|
|
2893
|
+
splitOp('get_x_stream_source', { action: 'get' }, ['id!'], SPLIT_RO, 'Read one X stream source', 'Use this when you need one stream source’s RTMPS URL, region and whether video is arriving. Read-only.' + X_BILLED),
|
|
2894
|
+
splitOp('get_x_stream_key', { action: 'stream_key' }, ['id!'], SPLIT_RO, 'Read an X stream source’s stream key', 'Use this when an encoder needs the stream key of one of the brand’s X stream sources. The key is a secret: put it in the encoder, never in a post. Read-only.' + X_BILLED),
|
|
2894
2895
|
splitOp('get_x_ingest_region', { action: 'region' }, [], SPLIT_RO, 'Read the nearest X ingest region', 'Use this when you need the ingest region nearest X’s server, to pass when creating a stream source. Read-only.' + X_BILLED),
|
|
2895
2896
|
splitOp('create_x_stream_source', { action: 'create' }, ['name!', 'region'], splitW(false, false), 'Create an X stream source', 'Use this when the brand needs a new RTMP ingest point for live video on X. The stream key is a secret, so never post it.' + X_BILLED),
|
|
2896
2897
|
splitOp('rename_x_stream_source', { action: 'rename' }, ['id!', 'name!'], splitW(false, true), 'Rename an X stream source', 'Use this when a stream source needs a new label. The stream key does not change.' + X_BILLED),
|
|
@@ -3789,6 +3790,12 @@ export const holdReasonFor = (name, ctx) => {
|
|
|
3789
3790
|
if (toolWithheldByServer(name, ctx.conn)) return 'unavailable'; // the server does not offer this capability right now (toolWithheldByServer)
|
|
3790
3791
|
if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && ctx.widgetHost) return 'host_policy';
|
|
3791
3792
|
if (EXECUTOR_TOOLS.has(name) && ctx.widgetHost) return 'host_policy'; // OpenAI's generic-executor rule — see EXECUTOR_TOOLS (its own sentence below)
|
|
3793
|
+
// A FOCUSED APP'S CAGE ANSWERS BEFORE THE CONNECTOR GATE (2026-11-07, measured live on ?tools=app-social): a tool that
|
|
3794
|
+
// lives in ANOTHER app was answered "needs the meta connection, connect it" when the workspace lacked Meta, which sends
|
|
3795
|
+
// the user to connect an account that still would not make the tool callable here. Where it lives is the answer;
|
|
3796
|
+
// the home app gives the connect answer itself if it is still needed there.
|
|
3797
|
+
if (ctx.app && ctx.directory === 'scoped' && (WITHHELD_FROM_DIRECTORY.has(name) || (DIRECTORY_BANNED_GROUPS.includes(ctx.groupOf[name]) && !APP_DIRECTORY_CREATE_ALLOW.has(name)))) return 'directory'; // the Claude cage answers for generation
|
|
3798
|
+
if (ctx.app && toolHeldBackByApp(name, ctx.groupOf[name], ctx) && !(ctx.widgetHost && appWidgetUnlisted(name))) return 'app_scope'; // another focused app's tool (a name renamed again on the app stays callable)
|
|
3792
3799
|
if (toolHeldBackByConnectors(name, ctx.conn)) {
|
|
3793
3800
|
// NOT CONNECTED vs NOT OFFERED are different answers (2026-09-03). "Connect it under Settings ▸ Connectors" is
|
|
3794
3801
|
// right for a provider with a tile and a dead end for one without (google_business: no tile until Google grants
|
|
@@ -3797,8 +3804,6 @@ export const holdReasonFor = (name, ctx) => {
|
|
|
3797
3804
|
if (prov && ctx.conn?.offered instanceof Set && !ctx.conn.offered.has(prov)) return 'not_offered';
|
|
3798
3805
|
return 'not_connected';
|
|
3799
3806
|
}
|
|
3800
|
-
if (ctx.app && ctx.directory === 'scoped' && (WITHHELD_FROM_DIRECTORY.has(name) || (DIRECTORY_BANNED_GROUPS.includes(ctx.groupOf[name]) && !APP_DIRECTORY_CREATE_ALLOW.has(name)))) return 'directory'; // the Claude cage answers for generation
|
|
3801
|
-
if (ctx.app && toolHeldBackByApp(name, ctx.groupOf[name], ctx) && !(ctx.widgetHost && appWidgetUnlisted(name))) return 'app_scope'; // another focused app's tool (a name renamed again on the app stays callable)
|
|
3802
3807
|
if (toolHeldBackByDirectory(name, ctx.groupOf[name], ctx)) return 'directory';
|
|
3803
3808
|
return null;
|
|
3804
3809
|
};
|
|
@@ -4837,6 +4842,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4837
4842
|
const rows = [];
|
|
4838
4843
|
const tokenWeight = tokenWeightFor(ctx);
|
|
4839
4844
|
const _offGroup = new Map(); // name → { grp, h } for tools the group filter excluded (see below)
|
|
4845
|
+
// A JOB ANOTHER HERMOSO APP DOES IS NOT A JOB HERMOSO CANNOT DO (2026-11-07, measured in claude.ai on Hermoso Social):
|
|
4846
|
+
// asked "what did my Meta ads spend this week", find_tools found nothing in the app and the model told the user
|
|
4847
|
+
// "Hermoso can't tell you. Its catalog has no tool that reads ad spend" — while Ads Manager does exactly that. The
|
|
4848
|
+
// cage still holds (another app's tool is never listed, described or callable here): such a tool is SCORED by the
|
|
4849
|
+
// same ranking as everything else and kept aside, and when it scores at least as well as the best tool this app has
|
|
4850
|
+
// AND either answers every word of the ask or clearly outranks (1.4x) that tool, the reply names the app it lives in.
|
|
4851
|
+
// The bar keeps "AI video ad generate" on the Claude Social app from pointing at Ads Manager's video-upload tools. Only an app_scope hold counts — a Claude-directory generation tool has no app to point at.
|
|
4852
|
+
const _otherAppRows = [];
|
|
4840
4853
|
for (const [name, h] of Object.entries(ctx.handleOf)) {
|
|
4841
4854
|
if (!h) continue;
|
|
4842
4855
|
if (toolWithheldByServer(name, ctx.conn)) continue; // not offered right now: absent from search, like an unoffered provider
|
|
@@ -4848,14 +4861,15 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4848
4861
|
// `?tools=directory` returned generate_image, generate_video, generate_voice, render_ad and ~40 more, each marked
|
|
4849
4862
|
// hold:'directory' WITH its description, so the connection reviewed as "no media generation" described the
|
|
4850
4863
|
// generation lanes to anyone who searched. A caged tool is absent from search, like an unoffered provider.
|
|
4851
|
-
|
|
4864
|
+
const _otherApp = !!(ctx.app && q && holdReasonFor(name, ctx) === 'app_scope');
|
|
4865
|
+
if ((ctx.directory || ctx.app) && toolHeldBackByDirectory(name, grp, ctx) && !_otherApp) continue; // a focused app searches only itself too
|
|
4852
4866
|
// A GROUP FILTER NARROWS, IT NEVER HIDES (2026-09-07). `find_tools({query:'tiktok_creator_info', group:'channels'})` reported a
|
|
4853
4867
|
// dead end because the tool lives in channel_admin — the exact-name hit was thrown away by the filter, and the agent
|
|
4854
4868
|
// was told we have no such tool. Off-group matches are kept aside; if the scoped search finds nothing, they are
|
|
4855
4869
|
// returned WITH their real group so the caller learns which group to enable instead of giving up.
|
|
4856
|
-
if (g && grp !== g) { if (!_offGroup.has(name)) _offGroup.set(name, { grp, h }); continue; }
|
|
4870
|
+
if (g && grp !== g) { if (!_otherApp && !_offGroup.has(name)) _offGroup.set(name, { grp, h }); continue; }
|
|
4857
4871
|
const desc = String(h.description || '');
|
|
4858
|
-
let score = 0, _cov = 0, _nh = 0, _nd = 0; // words answered, NAME hits, and name hits that are the word itself rather than a synonym (the match count below)
|
|
4872
|
+
let score = 0, _cov = 0, _nh = 0, _nd = 0, _nw = 0; // words answered, NAME hits, and name hits that are the word itself rather than a synonym (the match count below)
|
|
4859
4873
|
if (q) {
|
|
4860
4874
|
// A NAME-SHAPED ASK IS ALSO ITS WORDS (2026-09-12). Agents search the name they guess (list_meta_campaigns,
|
|
4861
4875
|
// update_meta_ad, edit_meta): kept as one literal token it matched nothing and filed a dead end, while its parts
|
|
@@ -4897,8 +4911,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4897
4911
|
if (!score) continue;
|
|
4898
4912
|
if (words.length > 1) score += covered; // coverage: a tool that answers MORE of the words outranks one that answers one of them loudly
|
|
4899
4913
|
if (words.length > 1 && nameHits === words.length) score += 2; // every word landed in the NAME: a phrase hit
|
|
4900
|
-
_cov = covered; _nh = nameHits; _nd = directHits;
|
|
4914
|
+
_cov = covered; _nh = nameHits; _nd = directHits; _nw = words.length;
|
|
4901
4915
|
}
|
|
4916
|
+
if (_otherApp) { _otherAppRows.push({ name, score, full: _cov >= _nw }); continue; } // scored, never shown (see _otherAppRows)
|
|
4902
4917
|
const hold = toolHoldReason(name, ctx);
|
|
4903
4918
|
const health = toolHealth(name);
|
|
4904
4919
|
// A CALLER MAY ASK FOR ONLY THE WORKING ONES, AND NOTHING IS EVER HIDDEN UNLESS THEY DO. Monid hides an
|
|
@@ -4940,23 +4955,27 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4940
4955
|
// THE MOST VALUABLE ROW ON THE DEFECT BOARD: what a user asked for, in their agent's words, that our catalog could
|
|
4941
4956
|
// not name. Unquoted and lowercased on purpose — the ledger collapses quoted strings to <q>, and one group per
|
|
4942
4957
|
// distinct ask is exactly what we want to read.
|
|
4958
|
+
const _bestHere = rows.reduce((m, r) => Math.max(m, r.score === Number.MAX_SAFE_INTEGER ? 0 : r.score), 0);
|
|
4959
|
+
const _oaFit = _otherAppRows.filter((r) => r.score >= _bestHere && (r.full || r.score >= 1.4 * _bestHere)).sort((x, y) => y.score - x.score);
|
|
4960
|
+
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)))];
|
|
4961
|
+
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 });
|
|
4962
|
+
for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
|
|
4963
|
+
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)'}`);
|
|
4964
|
+
const looser = top.length > total ? top.length - total : 0;
|
|
4965
|
+
const text = related
|
|
4966
|
+
? `${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')}`
|
|
4967
|
+
: `No tool matches${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}. Try a broader word (e.g. "lead", "campaign", "report") or a group: ${TOOL_GROUP_NAMES.join(', ')}.`;
|
|
4968
|
+
const otherAppNote = _homes.length ? `\nALSO IN ANOTHER HERMOSO APP: tools that match this as well as or better than the ones above are in ${_homes.join('; ')} (same Hermoso account). This connection does not carry them. If none above does what the user asked, do not say Hermoso cannot do it: say that app does, and that it can be added alongside this one.` : '';
|
|
4943
4969
|
if (!related && g && _offGroup.size) {
|
|
4944
4970
|
// Re-score the excluded tools by NAME only (the cheap, unambiguous half): an exact or token hit outside the
|
|
4945
4971
|
// asked-for group is an answer, not a dead end — "it exists, in channel_admin; enable that group".
|
|
4946
4972
|
const qw = String(q || '').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
|
|
4947
4973
|
const off = [..._offGroup.entries()].filter(([name]) => qw.some((w) => name === w || name.includes(w))).slice(0, cap)
|
|
4948
4974
|
.map(([name, { grp, h }]) => `• ${name} [${grp}, not in the ${g} group] — ${String(h.description || '').replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
4949
|
-
if (off.length) return ok(`Nothing in the ${g} group matches, but these tools do — they live in another group (enable that group with enable_tools, then call the tool by name):\n${off.join('\n')}`, { query: q, group: g, offGroup: off.length });
|
|
4975
|
+
if (off.length) return ok(`Nothing in the ${g} group matches, but these tools do — they live in another group (enable that group with enable_tools, then call the tool by name):\n${off.join('\n')}${otherAppNote}`, { query: q, group: g, offGroup: off.length, ...(_homes.length ? { otherApps: _homes } : {}) });
|
|
4950
4976
|
}
|
|
4951
4977
|
// A query with no word of two or more characters ("a", "?") is a probe, not an ask the catalog failed to answer,
|
|
4952
4978
|
// so it answers "nothing found" without landing on the defect board.
|
|
4953
|
-
if (!related && !quiet && /[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 });
|
|
4954
|
-
for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
|
|
4955
|
-
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)'}`);
|
|
4956
|
-
const looser = top.length > total ? top.length - total : 0;
|
|
4957
|
-
const text = related
|
|
4958
|
-
? `${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')}`
|
|
4959
|
-
: `No tool matches${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}. Try a broader word (e.g. "lead", "campaign", "report") or a group: ${TOOL_GROUP_NAMES.join(', ')}.`;
|
|
4960
4979
|
// THE NEXT STEP, NAMED. The text already ends "Run any of them with call_tool({name, args})"; this is the same
|
|
4961
4980
|
// instruction with the actual name in it, plus the connect step when the best match is the one that is held.
|
|
4962
4981
|
const best = top[0];
|
|
@@ -4967,7 +4986,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
4967
4986
|
: { do: ctx.widgetHost ? `${best.name}({ … })` : `call_tool({ name: '${best.name}', args: { … } })`, why: `${best.name} is the best match${best.inRoster ? '' : ' and is not in your list, which does not stop it running'}${best.cost?.free ? ' and it is free' : ''}` });
|
|
4968
4987
|
if (best.health?.state === 'failing') hints.push({ do: `consider the next row, or tell the user ${best.name} is currently failing`, why: `${best.failures || best.health.failures} of its last ${best.health.calls} calls on this server failed` });
|
|
4969
4988
|
}
|
|
4970
|
-
return withHints({ content: [{ type: 'text', text }], structuredContent: { total, related, tools: top.map(({ score, _penalty, _cov, _nh, _nd, _exact, ...r }) => r) } }, hints);
|
|
4989
|
+
return withHints({ content: [{ type: 'text', text: text + otherAppNote }], structuredContent: { total, related, tools: top.map(({ score, _penalty, _cov, _nh, _nd, _exact, ...r }) => r), ...(_homes.length ? { otherApps: _homes } : {}) } }, hints);
|
|
4971
4990
|
};
|
|
4972
4991
|
// "DID YOU MEAN" HAS TO DISCRIMINATE, AND THE OLD ONE DID NOT (2026-09-17, off the defect board). Not a `// ──`
|
|
4973
4992
|
// banner on purpose: tools/docs-data.mjs turns every banner into a public docs section, and this note sits
|
|
@@ -8823,7 +8842,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8823
8842
|
// against developers.google.com/youtube/v3/docs/thumbnails/set, which lists it among the accepted scopes — so
|
|
8824
8843
|
// there is no reconnect here.
|
|
8825
8844
|
// The two limits worth telling the model about are the ones it cannot discover: a channel must be phone-VERIFIED
|
|
8826
|
-
// to set custom thumbnails at all, and YouTube caps the file at
|
|
8845
|
+
// to set custom thumbnails at all, and YouTube caps the file at 50MB since 2026-09-14 (the server compresses over that).
|
|
8827
8846
|
// ---------- YOUTUBE CHANNEL BRANDING (2026-08-19) ----------
|
|
8828
8847
|
// On `youtube.force-ssl`, which we already hold and must hold regardless (it is the only scope Google
|
|
8829
8848
|
// accepts for comments). Hermoso generated brand creative all day and could not apply any of it to the
|
|
@@ -8883,7 +8902,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
8883
8902
|
}));
|
|
8884
8903
|
server.registerTool('set_youtube_thumbnail', {
|
|
8885
8904
|
title: 'Set the custom thumbnail on a YouTube video',
|
|
8886
|
-
description: 'Set the CUSTOM THUMBNAIL on a video already on the connected channel, using a Hermoso image — a make_thumbnail render, a generated image, or a frame. The thumbnail is the single biggest lever on YouTube click-through and YouTube otherwise auto-picks a frame, so a published video without one is leaving reach on the table. It changes ONLY the thumbnail — video, title and privacy are untouched — but it is public and immediate, so show the user which image is going on which video and get a yes first. Custom thumbnails require a VERIFIED YouTube channel (a phone number at youtube.com/verify); without it YouTube refuses and the error says so. Images
|
|
8905
|
+
description: 'Set the CUSTOM THUMBNAIL on a video already on the connected channel, using a Hermoso image — a make_thumbnail render, a generated image, or a frame. The thumbnail is the single biggest lever on YouTube click-through and YouTube otherwise auto-picks a frame, so a published video without one is leaving reach on the table. It changes ONLY the thumbnail — video, title and privacy are untouched — but it is public and immediate, so show the user which image is going on which video and get a yes first. Custom thumbnails require a VERIFIED YouTube channel (a phone number at youtube.com/verify); without it YouTube refuses and the error says so. Works on Shorts too, once YouTube has finished processing the Short. Images up to YouTube’s 50MB cap go as they are; a larger one is compressed automatically. The image must be Hermoso-HOSTED, which is not the same as Hermoso-GENERATED: the user’s own artwork works, put it through upload_file first and pass the URL that returns. An arbitrary external host is refused. 0 credits. Needs a connected YouTube channel.',
|
|
8887
8906
|
inputSchema: { ...MANAGE_BRAND, videoId: z.string().describe('the YouTube video id (what post_to_youtube returned)'), imageUrl: z.string().describe('a Hermoso-hosted image URL — a make_thumbnail / list_library render, OR any image of the user’s own passed through upload_file first. An arbitrary external host is refused.') },
|
|
8888
8907
|
outputSchema: { ok: z.boolean().optional(), videoId: z.string().optional(), thumbnailUrl: z.string().nullable().optional(), bytes: z.number().optional(), url: z.string().optional(), note: z.string().optional() },
|
|
8889
8908
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: true },
|
|
@@ -9153,9 +9172,9 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9153
9172
|
}));
|
|
9154
9173
|
server.registerTool('manage_x_broadcast', {
|
|
9155
9174
|
title: 'Schedule, update, go live on or cancel an X broadcast',
|
|
9156
|
-
description: "Schedule, update, go live on, or cancel an X LIVE VIDEO broadcast on the connected account. action 'schedule' needs sourceId (from manage_x_stream_source), startAt, and manualPublish, which is REQUIRED with no default: true = nothing airs until action 'go_live'; false = X puts the brand ON AIR automatically at startAt (needs confirm:true). 'update' changes only the fields you pass (Hermoso re-sends the rest, because X's update replaces the whole schedule). 'go_live' airs a manualPublish schedule NOW, publicly, as the brand (confirm:true). 'cancel' deletes a schedule and cannot be undone (confirm:true). X retired recurring broadcasts: schedule each occurrence on its own. WITHOUT A SCHEDULE: 'create'
|
|
9175
|
+
description: "Schedule, update, go live on, or cancel an X LIVE VIDEO broadcast on the connected account. action 'schedule' needs sourceId (from manage_x_stream_source), startAt, and manualPublish, which is REQUIRED with no default: true = nothing airs until action 'go_live'; false = X puts the brand ON AIR automatically at startAt (needs confirm:true). 'update' changes only the fields you pass (Hermoso re-sends the rest, because X's update replaces the whole schedule). 'go_live' airs a manualPublish schedule NOW, publicly, as the brand (confirm:true). 'cancel' deletes a schedule and cannot be undone (confirm:true). X retired recurring broadcasts: schedule each occurrence on its own. WITHOUT A SCHEDULE: 'create' puts sourceId ON AIR NOW (confirm:true; X announces it in a post unless skipPost:true, and ends it if no video arrives within 5 minutes; X retired the separate 'publish' step), 'end' stops one for everyone (confirm:true), 'delete' removes one and its replay (confirm:true; end it first). X limits broadcasting by account (Premium or 1,000 followers) and says so. Confirm the exact broadcast, time and stream with the user before passing confirm:true; a refusal is free." + XB_SCOPE_NOTE,
|
|
9157
9176
|
inputSchema: {
|
|
9158
|
-
action: z.enum(['schedule', 'update', 'go_live', 'cancel', 'create', '
|
|
9177
|
+
action: z.enum(['schedule', 'update', 'go_live', 'cancel', 'create', 'end', 'delete']).describe('schedule, update, go_live, cancel, create, end or delete'),
|
|
9159
9178
|
id: z.string().optional().describe('broadcast id, for every action but schedule and create'),
|
|
9160
9179
|
sourceId: z.string().optional().describe('stream source id from manage_x_stream_source (schedule, create)'),
|
|
9161
9180
|
startAt: z.string().optional().describe('ISO 8601 or epoch ms'),
|
|
@@ -9165,14 +9184,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9165
9184
|
availableForReplay: z.boolean().optional().describe('true = keep the broadcast available as a replay'), isLocked: z.boolean().optional().describe("true = lock the broadcast (X's isLocked flag)"),
|
|
9166
9185
|
chatOption: z.string().optional().describe('X chat permission option (numeric string)'),
|
|
9167
9186
|
thumbnailMediaId: z.string().optional().describe('pre-live slate media id'),
|
|
9168
|
-
skipPost: z.boolean().optional().describe('
|
|
9187
|
+
skipPost: z.boolean().optional().describe('create: true skips the post X would make announcing it'),
|
|
9169
9188
|
confirm: z.boolean().optional().describe('true only after the user approved this exact public or irreversible action'),
|
|
9170
9189
|
},
|
|
9171
9190
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
9172
9191
|
}, wrap(async (a) => {
|
|
9173
9192
|
const d = await apiPost('/api/x/broadcasts/manage', a);
|
|
9174
9193
|
if (d.action === 'cancel') return ok(d.deleted ? `Cancelled the scheduled broadcast ${d.id}. Cost ${d.costCredits ?? '?'} credits.` : `X did not confirm cancelling ${d.id}.`, d);
|
|
9175
|
-
if (d.action === 'create') return ok(`
|
|
9194
|
+
if (d.action === 'create') return ok(`On air: broadcast [${d.broadcast?.id}]. ${d.note}${d.shareUrl ? ` ${d.shareUrl}` : ''} Cost ${d.costCredits ?? '?'} credits.`, d);
|
|
9176
9195
|
if (d.action === 'publish') return ok(`Broadcast ${d.id} published, state ${d.state || '?'}. Cost ${d.costCredits ?? '?'} credits.`, d);
|
|
9177
9196
|
if (d.action === 'end') return ok(d.ended ? `Ended broadcast ${d.id}. Cost ${d.costCredits ?? '?'} credits.` : `X did not confirm ending ${d.id} (state ${d.state || '?'}).`, d);
|
|
9178
9197
|
if (d.action === 'delete') return ok(d.deleted ? `Deleted broadcast ${d.id} and its replay. Cost ${d.costCredits ?? '?'} credits.` : `X did not confirm deleting ${d.id}.`, d);
|
|
@@ -9181,12 +9200,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9181
9200
|
}));
|
|
9182
9201
|
server.registerTool('manage_x_stream_source', {
|
|
9183
9202
|
title: 'Manage X stream sources',
|
|
9184
|
-
description: "The connected X account's STREAM SOURCES, the RTMP ingest points a live broadcast plays from. action 'list' (default) and 'get' (id) show each source's id
|
|
9203
|
+
description: "The connected X account's STREAM SOURCES, the RTMP ingest points a live broadcast plays from. action 'list' (default) and 'get' (id) show each source's id (19 digits), RTMPS URL, region and whether video is arriving; 'stream_key' (id) reads its stream key, which X leaves out of the list; 'create' (name, optional region, the nearest one when omitted) makes one and returns its key; 'rename' (id, name); 'delete' (id, confirm:true: it kills the stream key); 'region' reads the nearest ingest region. Pass a source's id as sourceId to manage_x_broadcast. The stream key is a secret, so never post it." + XB_SCOPE_NOTE,
|
|
9185
9204
|
inputSchema: {
|
|
9186
|
-
action: z.enum(['list', 'get', 'create', 'rename', 'delete', 'region']).optional().describe("default 'list'"),
|
|
9187
|
-
id: z.string().optional().describe('source id, for get, rename and delete'),
|
|
9205
|
+
action: z.enum(['list', 'get', 'create', 'rename', 'delete', 'region', 'stream_key']).optional().describe("default 'list'"),
|
|
9206
|
+
id: z.string().optional().describe('source id, for get, stream_key, rename and delete'),
|
|
9188
9207
|
name: z.string().optional().describe('label, for create and rename'),
|
|
9189
|
-
region: z.string().optional().describe("create only:
|
|
9208
|
+
region: z.string().optional().describe("create only: an ingest region such as us-east-1 or us-west-2; omitted = the nearest, from action 'region'"),
|
|
9190
9209
|
confirm: z.boolean().optional().describe('true only after the user approved deleting this source'),
|
|
9191
9210
|
},
|
|
9192
9211
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
@@ -9891,9 +9910,11 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
9891
9910
|
}, wrap(async (a) => { const d = await apiGet('/api/meta/playables', a); return ok(d.note, d); }));
|
|
9892
9911
|
server.registerTool('create_meta_ad', {
|
|
9893
9912
|
title: 'Build a full Meta ad (campaign -> ad set -> ad, paused)',
|
|
9894
|
-
description: 'Build a complete, ready-to-run Meta ad: campaign -> ad set (FULL targeting + budget + schedule + bidding) -> creative -> ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it carries 24-hour Meta PREVIEW LINKS for the first ad — hand them to the user; preview_meta_ad renders any ad in any placement). Needs ads-management on the connected account. BOOST AN EXISTING POST: pass boostPostId — a post you have ALREADY published (numeric id, the <pageId>_<postId> form, or a permalink) — INSTEAD of any creative, and the ad promotes that post exactly as published, comments and all.
|
|
9913
|
+
description: 'Build a complete, ready-to-run Meta ad: campaign -> ad set (FULL targeting + budget + schedule + bidding) -> creative -> ad(s), ALL created PAUSED — it spends NOTHING until you activate the campaign with set_meta_campaign_status(confirm:true). This is the "create a campaign and put the ads on it" path. IMAGE, VIDEO (uploaded, transcoded and thumbnailed for you) and CAROUSEL (format:"carousel", 2–10 cards each with its own headline/description/link) all work. Targeting is the `targeting` object: geo down to cities with a radius, age, gender, interests, behaviours, custom audiences and lookalikes, languages, placements, devices and OS. For a conversion objective pass pixelId + conversionEvent and the ad set optimizes for that conversion. Schedule with startTime/endTime + dayparting; bid with bidStrategy + bidAmountUsd/minRoas; use lifetimeBudgetUsd (with endTime) for a fixed flight. Attach to an existing campaign with campaignId or an existing ad set with adSetId. Everything is READ BACK from Meta before you are told it exists — print the returned summary verbatim (it carries 24-hour Meta PREVIEW LINKS for the first ad — hand them to the user; preview_meta_ad renders any ad in any placement). Needs ads-management on the connected account. BOOST AN EXISTING POST: pass boostPostId — a post you have ALREADY published (numeric id, the <pageId>_<postId> form, or a permalink) — INSTEAD of any creative, and the ad promotes that post exactly as published, comments and all. The post keeps its own caption and media, so message/headline/description do NOT apply. AN INSTAGRAM BOOST TAKES A BUTTON: pass cta (e.g. SHOP_NOW, SIGN_UP) and link and the boosted post carries that button to that link; a FACEBOOK post boost takes no button and keeps the post as published. Anything not applied is named in the reply, and the reply reads back from Meta which post the ad promotes. Targeting, budget, schedule, bidding and PAUSED-by-default all work identically. Find ids with list_meta_posts. AN INSTAGRAM POST NEEDS boostTarget:"instagram" — IG media ids and Facebook post ids are both bare digits, so Hermoso never guesses, and a Facebook boost given an IG media id is refused. Instagram eligibility is checked for free before anything is created (Meta refuses to boost a post carrying licensed music or an interactive element).',
|
|
9895
9914
|
inputSchema: {
|
|
9896
|
-
boostPostId: z.string().optional().describe('Promote a post that ALREADY EXISTS instead of building a new ad from media. Accepts the numeric post id, <pageId>_<postId>, or a permalink (an Instagram post is its NUMERIC media id — an instagram.com link carries only a shortcode, which Meta cannot resolve). Cannot be combined with image/video inputs
|
|
9915
|
+
boostPostId: z.string().optional().describe('Promote a post that ALREADY EXISTS instead of building a new ad from media. Accepts the numeric post id, <pageId>_<postId>, or a permalink (an Instagram post is its NUMERIC media id — an instagram.com link carries only a shortcode, which Meta cannot resolve). Cannot be combined with image/video inputs. The post keeps its caption and media; an INSTAGRAM boost also takes cta + link (a button on the boosted post), a Facebook one takes neither.'), boostTarget: z.enum(['facebook','instagram']).optional().describe("Which surface the boosted post lives on. Default facebook. REQUIRED for an Instagram post: an IG media id and a Facebook post id are both bare digits, so this is never inferred — Meta takes a different creative for each (object_story_id for a Page post; object_id + instagram_user_id + source_instagram_media_id for an IG post). list_meta_posts(target:'instagram') returns the ids."),
|
|
9916
|
+
partnershipAdCode: z.string().optional().describe("PARTNERSHIP AD FROM A CREATOR'S CODE: the ad code a creator shares for their own Instagram post (their post ▸ ⋯ ▸ Partnership label and ads ▸ Ad code). The ad promotes THEIR post as a partnership ad run by this brand — no media id needed. Use INSTEAD of boostPostId; cannot be combined with your own media, a catalog or carousel. An expired or unknown code is refused in plain words."),
|
|
9917
|
+
partnershipAdFormat: z.enum(['both', 'single', 'auto']).optional().describe("Which identities a boosted partnership post shows (Meta's ad_format): both = creator AND brand (Meta's default), single = only the first identity, auto = Meta picks per impression (Instagram only). Only with partnershipAdCode or boostPostId."),
|
|
9897
9918
|
adAccountId: z.string().describe('ad account id (act_… or digits — from list_meta_pages)'),
|
|
9898
9919
|
format: z.enum(['auto', 'carousel']).optional().describe('auto = one ad per asset (image or video); carousel = ONE multi-card ad'),
|
|
9899
9920
|
imageUrl: z.string().optional().describe('public https image URL for the ad creative'),
|
|
@@ -10496,7 +10517,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
10496
10517
|
}));
|
|
10497
10518
|
server.registerTool('meta_insights', {
|
|
10498
10519
|
title: 'Meta ad performance metrics',
|
|
10499
|
-
description: 'Pull performance INSIGHTS (spend, impressions, reach, clicks, CTR, CPC, CPM, conversions) for a connected ad account, or a specific campaign / ad set / ad. Pass adAccountId (for auth); optionally objectId to scope to one object and level to break the numbers down. BREAKDOWNS are what make the numbers actionable — a flat total says an ad cost $X, never WHO it worked on: pass breakdowns:"age,gender", "publisher_platform,platform_position" (which placement), "country" / "region" / "
|
|
10520
|
+
description: 'Pull performance INSIGHTS (spend, impressions, reach, clicks, CTR, CPC, CPM, conversions) for a connected ad account, or a specific campaign / ad set / ad. Pass adAccountId (for auth); optionally objectId to scope to one object and level to break the numbers down. BREAKDOWNS are what make the numbers actionable — a flat total says an ad cost $X, never WHO it worked on: pass breakdowns:"age,gender", "publisher_platform,platform_position" (which placement), "country" / "region" / "comscore_market" (where — US metro; Meta retired "dma" for it, and "dma" is sent as comscore_market), "impression_device" / "device_platform" (what they held). Comma-separated; "placement", "device" and "geo" are accepted as aliases; an unknown value is REJECTED, never silently ignored. THREE breakdowns need an ad-account OPT-IN from 2026-08-06 — impression_device, hourly_stats_aggregated_by_audience_time_zone and frequency_value: Meta returns NO ROWS (not an error) for an account that has not opted in, so they are always ATTEMPTED, and if nothing comes back the report is re-run WITHOUT them and `droppedBreakdowns` + a note name the missing dimension and say an account admin can enable it in Ads Manager. A dropped dimension is ABSENT, never zero — never present the remaining total as if it were still split by it. Date window: datePreset OR since+until (YYYY-MM-DD). datePreset is Meta\'s OWN enum — today, yesterday, last_3d, last_7d, last_14d, last_28d, last_30d, last_90d, this_week_mon_today, this_week_sun_today, last_week_mon_sun, last_week_sun_sat, this_month, last_month, this_quarter, last_quarter, this_year, last_year, maximum, data_maximum. THERE IS NO "lifetime": Meta disabled it in Graph API v10.0 and replaced it with "maximum" (the last 37 months); anything unrecognised is refused by name here rather than 400ing at Meta. Read-only.',
|
|
10500
10521
|
inputSchema: {
|
|
10501
10522
|
adAccountId: z.string().describe('ad account id (act_… or digits)'),
|
|
10502
10523
|
objectId: z.string().optional().describe('a campaign / ad set / ad id to scope to (default: the whole account)'),
|
|
@@ -12129,6 +12150,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
12129
12150
|
merchantCenterId: z.string().optional().describe('makes this a RETAIL (Shopping-feed) Performance Max — the Merchant Center account whose products it advertises, from list_merchant_accounts. It advertises the WHOLE feed; partitioning by brand/category is not built and is refused by name'),
|
|
12130
12151
|
feedLabel: z.string().optional().describe('optional, retail only: narrow to ONE Merchant Center feed by its feed label. Omit to use products from all feeds'),
|
|
12131
12152
|
brandGuidelinesEnabled: z.boolean().optional().describe('default TRUE, matching Google’s own default since v21: businessName + logos are linked to the CAMPAIGN. Pass false only for the pre-v21 layout, where they sit on the asset group instead'),
|
|
12153
|
+
imageExtraction: z.boolean().optional().describe('default FALSE: Google may NOT take images from your landing pages and serve them as ad creative. Sent explicitly, because Google switches image extraction ON by default for new Performance Max campaigns from 2026-12-04. Pass true to allow it; the reply reads the setting back from Google'),
|
|
12132
12154
|
assetGroup: z.object({
|
|
12133
12155
|
name: z.string().describe('asset group name'),
|
|
12134
12156
|
finalUrls: z.array(z.string()).describe('the landing page(s) — at least one'),
|
|
@@ -17249,7 +17271,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17249
17271
|
inputSchema: {
|
|
17250
17272
|
adAccountId: z.string().optional().describe('Pinterest ad account id, from list_pinterest_ads_campaigns'),
|
|
17251
17273
|
name: z.string().describe('campaign name, ≤255 characters'),
|
|
17252
|
-
objective: z.enum(['AWARENESS', 'CONSIDERATION', 'WEB_CONVERSION', 'CATALOG_SALES', 'VIDEO_COMPLETION', 'APP_INSTALL', 'SALES', 'LEADS', 'CTV_CONSIDERATION']).describe('Pinterest requires an objective and will not guess one'),
|
|
17274
|
+
objective: z.enum(['AWARENESS', 'CONSIDERATION', 'WEB_CONVERSION', 'CATALOG_SALES', 'VIDEO_COMPLETION', 'APP_INSTALL', 'SALES', 'LEADS', 'CTV_CONSIDERATION']).describe('Pinterest requires an objective and will not guess one. CATALOG_SALES and WEB_CONVERSION are LEGACY: Pinterest disables creating them on 2027-02-01 — use SALES (with intendedPromotionType CATALOG for a shopping/catalog campaign) instead'),
|
|
17253
17275
|
dailyBudget: z.number().optional().describe('daily cap in the ad account’s currency'),
|
|
17254
17276
|
lifetimeBudget: z.number().optional().describe('lifetime cap instead of a daily one — Pinterest then requires endTime'),
|
|
17255
17277
|
isPerformancePlus: z.boolean().optional().describe('Pinterest Performance+ on this campaign; with no lifetime budget Pinterest also requires isFlexibleDailyBudgets:true'),
|
|
@@ -19090,14 +19112,14 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
19090
19112
|
endDate: z.string().optional().describe('YYYY-MM-DD (UTC). Default is yesterday'),
|
|
19091
19113
|
fields: z.array(z.string()).optional().describe('defaults to everything this authorization can read. Demographics are audience_ages, audience_genders, audience_countries, audience_cities'),
|
|
19092
19114
|
},
|
|
19093
|
-
outputSchema: { displayName: z.string().nullable().optional(), username: z.string().nullable().optional(), isBusinessAccount: z.boolean().nullable().optional(), followers: z.number().nullable().optional(), following: z.number().nullable().optional(), totalLikes: z.number().nullable().optional(), videos: z.number().nullable().optional(), metrics: z.array(z.any()).optional(), audience: z.any().optional(), days: z.number().optional(), note: z.string().optional() },
|
|
19115
|
+
outputSchema: { displayName: z.string().nullable().optional(), username: z.string().nullable().optional(), isBusinessAccount: z.boolean().nullable().optional(), verifiedBusinessAccount: z.boolean().nullable().optional(), hasAdvancedAccess: z.boolean().nullable().optional(), followers: z.number().nullable().optional(), following: z.number().nullable().optional(), totalLikes: z.number().nullable().optional(), videos: z.number().nullable().optional(), metrics: z.array(z.any()).optional(), audience: z.any().optional(), days: z.number().optional(), note: z.string().optional() },
|
|
19094
19116
|
annotations: { readOnlyHint: true, destructiveHint: false, openWorldHint: true },
|
|
19095
19117
|
}, wrap(async (a) => {
|
|
19096
19118
|
const d = await apiGet('/api/tiktok-account/insights', a);
|
|
19097
19119
|
const au = d.audience || {};
|
|
19098
19120
|
const top = (rows, key) => (rows || []).slice(0, 4).map(r => `${r[key]} ${Math.round((r.percentage || 0) * 100)}%`).join(', ');
|
|
19099
19121
|
return ok([
|
|
19100
|
-
`${d.displayName || d.username || 'This account'}${d.username ? ` (@${d.username})` : ''}: ${d.followers ?? '?'} followers, ${d.videos ?? '?'} videos, ${d.totalLikes ?? '?'} total likes${d.
|
|
19122
|
+
`${d.displayName || d.username || 'This account'}${d.username ? ` (@${d.username})` : ''}: ${d.followers ?? '?'} followers, ${d.videos ?? '?'} videos, ${d.totalLikes ?? '?'} total likes${d.verifiedBusinessAccount === true ? ', a Verified Business Account' : d.isBusinessAccount === true ? ', a Business account' : ''}${d.hasAdvancedAccess === true ? ' with Advanced Access' : ''}.`,
|
|
19101
19123
|
d.days ? `${d.days} day(s) of daily metrics.` : '',
|
|
19102
19124
|
au.ages && au.ages.length ? `Age: ${top(au.ages, 'age')}` : '',
|
|
19103
19125
|
au.genders && au.genders.length ? `Gender: ${top(au.genders, 'gender')}` : '',
|
|
@@ -26098,8 +26120,12 @@ function memoryNoteVerdict(text) {
|
|
|
26098
26120
|
// Every other client keeps both, byte for byte.
|
|
26099
26121
|
const widget = hostRendersWidgets();
|
|
26100
26122
|
const cards = adSpyCards(platform ? items.map((x) => (x && !x.platform ? { ...x, platform } : x)) : items);
|
|
26101
|
-
|
|
26102
|
-
|
|
26123
|
+
// EVERY ATTACHED PICTURE NAMES THE AD IT BELONGS TO (2026-11-07, "Liquid Death" test: the model said some previews
|
|
26124
|
+
// showed another brand's cans). The blocks used to be the first three IMAGE-capable rows, bare, in a list that
|
|
26125
|
+
// skipped video-only rows and silently dropped any fetch that failed — so picture 2 was routinely ad 5, and the
|
|
26126
|
+
// model read it as ad 2. A caption block precedes each picture with its row number and advertiser.
|
|
26127
|
+
const blocks = [];
|
|
26128
|
+
for (const [r, blk] of await Promise.all(pictureRows(items).map(async (r) => [r, await imageBlock(r.url).catch(() => null)]))) if (blk) blocks.push({ type: 'text', text: r.caption }, blk);
|
|
26103
26129
|
const links = widget ? [] : items.slice(0, 6).map((x, i) => (x && (x.media || x.image || x.cover)) ? `ad ${i + 1}: ${x.media || x.image || x.cover}` : null).filter(Boolean);
|
|
26104
26130
|
const guide = items.length ? '' : '\n\nNo advertiser matched that name. Product LINES are usually advertised by their PARENT brand\u2019s page \u2014 resolve the parent company first (the product\u2019s website footer, or your web search) and retry with that companyName; also try `query` (keyword search across ALL advertisers\u2019 ad copy) and status \u201cALL\u201d (includes past ads). Never conclude a brand runs no ads from a single name miss.';
|
|
26105
26131
|
const rows = widget ? stripAdMedia(items) : items;
|
|
@@ -26154,9 +26180,18 @@ function memoryNoteVerdict(text) {
|
|
|
26154
26180
|
const asked = String(a.companyName).trim().toLowerCase();
|
|
26155
26181
|
if (!pages.some(pg => pg.toLowerCase() === asked)) note = `\n\nNote: “${a.companyName}” resolved to the advertiser page${pages.length > 1 ? 's' : ''} ${pages.slice(0, 3).map(pg => `“${pg}”`).join(', ')}${pages.length > 3 ? ` (+${pages.length - 3} more)` : ''}. If that is not the brand you meant, pass pageId, or use pull_competitor_ads with the brand’s domain (it resolves the brand’s own page through its site).`;
|
|
26156
26182
|
}
|
|
26157
|
-
|
|
26183
|
+
// A KEYWORD SEARCH IS ACROSS ALL ADVERTISERS, NOT ONE BRAND'S ADS (2026-11-07). "Liquid Death" returned Garage Beer
|
|
26184
|
+
// (which mentions it in its copy), Taylor Morrison, Ticketmaster and Resona beside the real Liquid Death rows, and
|
|
26185
|
+
// the reply gave no way to tell them apart. Each row now says whether its advertiser page IS the searched name, the
|
|
26186
|
+
// brand's own pages sort first, and the note says how many are other advertisers. Rows are never dropped: a
|
|
26187
|
+
// collab or a reseller's ad can be exactly what the user wants.
|
|
26188
|
+
const kwQuery = a.query || (note ? a.companyName : '');
|
|
26189
|
+
const { ordered, otherCount } = kwQuery ? orderKeywordAds(raw, kwQuery) : { ordered: raw, otherCount: 0 };
|
|
26190
|
+
if (otherCount) note += `\n\nNote: this is a KEYWORD search across ALL advertisers' ad copy — ${otherCount} of the ${raw.length} ads fetched were run by OTHER advertisers (rows with from_other_advertiser:true), not by “${kwQuery}”. Their pictures show THEIR creative; always attribute an ad to its page_name. For ${kwQuery}'s own ads use companyName/pageId.`;
|
|
26191
|
+
const ads = ordered.slice(0, nAds(a.limit)).map((x) => {
|
|
26158
26192
|
const s = x.snapshot || {};
|
|
26159
26193
|
return qp({
|
|
26194
|
+
from_other_advertiser: x.__other ? true : undefined,
|
|
26160
26195
|
page_name: x.page_name, body: trunc(typeof s.body === 'string' ? s.body : s.body?.text), cta: s.cta_text, link: s.link_url,
|
|
26161
26196
|
dates: adRunLine(x), // start -> end · days running, from epoch seconds too (the string fields alone left rows dateless)
|
|
26162
26197
|
media: s.videos?.[0]?.video_sd_url || s.images?.[0]?.resized_image_url || s.cards?.[0]?.resized_image_url || s.cards?.[0]?.video_sd_url || s.videos?.[0]?.video_preview_image_url,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.528",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "AI marketing agent and AI ad generator, run from your own AI agent. 1178 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 10 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",
|