hermoso 0.1.305 → 0.1.308
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 +8 -7
- package/mcp/http.mjs +58 -8
- package/mcp/tools.mjs +51 -19
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -155,9 +155,10 @@ the routes.
|
|
|
155
155
|
|
|
156
156
|
## Instant: the hosted Claude.ai connector
|
|
157
157
|
|
|
158
|
-
Paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude →
|
|
159
|
-
|
|
160
|
-
handshake is open
|
|
158
|
+
Paste **`https://app.hermoso.ai/mcp?src=readme`** into Claude → Customize → Connectors → Add → *Add custom connector*,
|
|
159
|
+
press Continue, choose **Sign in now** under Authentication (Claude's detector pre-selects "No sign-in" because our
|
|
160
|
+
discovery handshake is open, and with that your first request comes back "Authentication required"), press Add and
|
|
161
|
+
Connect, approve with your Hermoso account, and you are done: the full toolset with your saved brand context, billed to your plan.
|
|
161
162
|
|
|
162
163
|
## Quickstart for Claude Code (one command)
|
|
163
164
|
|
|
@@ -181,10 +182,10 @@ claude plugin marketplace add hermoso-ai/hermoso && claude plugin install hermos
|
|
|
181
182
|
Rather install the CLI by hand? `npm install -g hermoso` puts the same `hermoso` command on your PATH, and the
|
|
182
183
|
skills use it when it is there.
|
|
183
184
|
|
|
184
|
-
The hosted URL works in Claude Code too, but
|
|
185
|
-
`claude mcp add --transport http hermoso "https://app.hermoso.ai/mcp?src=readme"
|
|
186
|
-
reports `! Needs authentication`
|
|
187
|
-
|
|
185
|
+
The hosted URL works in Claude Code too, but the plugin is the lighter path there because it loads no tool list
|
|
186
|
+
into your sessions. If you want the connector: `claude mcp add --transport http hermoso "https://app.hermoso.ai/mcp?src=readme"`,
|
|
187
|
+
and `claude mcp list` reports `! Needs authentication` until you run `claude mcp login hermoso` once and approve in
|
|
188
|
+
your browser (`--no-browser` prints the link on a headless machine). Measured against Claude Code 2.1.282 on 2026-09-25.
|
|
188
189
|
|
|
189
190
|
Your agent now has the full studio **with your workspace's context**: the brand profile, products, logos and
|
|
190
191
|
learned memory you set up in the web app apply automatically (`get_brand` shows what's saved; omit `brand` in
|
package/mcp/http.mjs
CHANGED
|
@@ -12,7 +12,8 @@
|
|
|
12
12
|
//
|
|
13
13
|
// When the cloud step happens, the remaining work is small and explicit (see ENABLE CHECKLIST at the bottom).
|
|
14
14
|
// ───────────────────────────────────────────────────────────────────────────────────────────────────────
|
|
15
|
-
import { randomUUID } from 'node:crypto';
|
|
15
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
16
|
+
import { createRequire } from 'node:module';
|
|
16
17
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
17
18
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
18
19
|
import { registerTools, MCP_INSTRUCTIONS, parseToolScope, DEFAULT_TOOL_GROUPS } from './tools.mjs';
|
|
@@ -20,7 +21,15 @@ import { mcpCtx, connectedProviders } from './client.mjs';
|
|
|
20
21
|
|
|
21
22
|
// Mount the remote connector onto the Express app. No-op unless explicitly enabled + auth-backed.
|
|
22
23
|
// `verifyBearer(token) -> {userId, accountId, email} | null` MUST be supplied by the caller (the real auth seam).
|
|
23
|
-
|
|
24
|
+
// The release version directories show as ours (Smithery printed the hard-coded "1.0.0"). Server side this file sits
|
|
25
|
+
// beside cli/, whose package.json carries the release; inside the npm package there is no ../cli/ and ../package.json
|
|
26
|
+
// IS the CLI package. One resolver, so the byte-identical twins agree.
|
|
27
|
+
const PKG_VERSION = (() => { const r = createRequire(import.meta.url); for (const p of ['../cli/package.json', '../package.json']) { try { const v = r(p).version; if (v) return v; } catch {} } return '0.0.0'; })();
|
|
28
|
+
export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStart = null, onSessionEnd = null, onAnonDiscovery = null, onFirstCall = null, onConnectEvent = null } = {}) {
|
|
29
|
+
// Outcomes of a real connect, for the host's connect watch (lib/mcp-connect-watch.mjs): a bearer we reject, and the
|
|
30
|
+
// first tools/list of a signed-in session (keyed by the token's hash, so a FRESH token's first listing marks a
|
|
31
|
+
// completed connect). Never throws into the request.
|
|
32
|
+
const connectEvent = (req, evt) => { if (typeof onConnectEvent !== 'function') return; try { onConnectEvent({ ua: String(req?.headers?.['user-agent'] || '').slice(0, 160), ...evt }); } catch {} };
|
|
24
33
|
if ((process.env.HERMOSO_MCP_REMOTE ?? process.env.HEIST_MCP_REMOTE) !== '1') return false; // gate 1: off by default
|
|
25
34
|
if (typeof verifyBearer !== 'function') { // gate 2: refuse without real auth
|
|
26
35
|
console.error('[mcp-remote] REFUSING to mount: no token verifier wired. A remote, money-spending MCP must authenticate every caller (no-anon-spend). Wire Firebase Auth → verifyBearer first.');
|
|
@@ -233,8 +242,40 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
233
242
|
// chat said "I'll send a connect card" and none ever came. Its catalog connectors (Stripe, Notion, Vercel) work
|
|
234
243
|
// because their servers challenge the first request. So that client, and any caller that asks with
|
|
235
244
|
// `?auth=required`, gets the challenge instead of the anonymous preview; everyone else keeps discovery.
|
|
236
|
-
|
|
245
|
+
//
|
|
246
|
+
// THE SAME DEFECT ON THREE MORE HOSTS, MEASURED BY THE clientInfo EACH PROBE SENDS (2026-09-25). The anonymous
|
|
247
|
+
// initialize is logged by name (below), and a probe from each host's add/connect flow was captured on prod:
|
|
248
|
+
// • claude.ai's "Add custom connector" dialog — clientInfo "Anthropic" 1.0.0, UA python-httpx — pre-selected
|
|
249
|
+
// "No sign-in [Detected]" on our 200, so a user who keeps the default gets a connector whose first tool call
|
|
250
|
+
// fails. Challenged, the same dialog detects "Sign in now" + "Claude's published identity (CIMD)".
|
|
251
|
+
// • Gemini CLI's connect test — clientInfo "mcp-test-client" 0.0.1, UA node — reported "Connected" and never
|
|
252
|
+
// offered sign-in; its session client names itself "gemini-cli-mcp-client".
|
|
253
|
+
// • Windsurf — UA windsurf/* — two anonymous handshakes on 2026-09-21 and never a signed-in session.
|
|
254
|
+
// Matched by the name the client gives itself, never by UA alone: python-httpx and node are also most of the
|
|
255
|
+
// registry crawlers, which keep the anonymous preview.
|
|
256
|
+
const SIGNIN_UPFRONT_CLIENTS = new Set(['Anthropic', 'mcp-test-client', 'gemini-cli-mcp-client']);
|
|
257
|
+
const signinUpfront = (req) => /^(grok-connectors-manager|windsurf)\b/i.test(String(req.headers['user-agent'] || ''))
|
|
258
|
+
|| SIGNIN_UPFRONT_CLIENTS.has(clientInfoOf(req.body))
|
|
237
259
|
|| String(req.query?.auth || '').toLowerCase() === 'required';
|
|
260
|
+
// ── THE DEFAULT IS INVERTED: A TOKENLESS HANDSHAKE IS CHALLENGED UNLESS IT IS A KNOWN LIVENESS/DIRECTORY BOT (2026-09-25) ──
|
|
261
|
+
// Matching the connect probes host by host (Grok, then claude.ai's "Anthropic", Gemini's "mcp-test-client") was
|
|
262
|
+
// whack-a-mole, and Mistral's proved it the same afternoon: its setup dialog sends clientInfo "mcp" 0.1.0 with UA
|
|
263
|
+
// MistralAI-MCPClient/1.0, our 200 made it select "No Authentication", and it then DISABLED its OAuth option. A
|
|
264
|
+
// connector-setup flow reads a 200 to a tokenless initialize as "this server needs no sign-in", which for us is
|
|
265
|
+
// false, and the MCP authorization spec's answer to an unauthenticated request is the 401 + WWW-Authenticate that
|
|
266
|
+
// starts sign-in. So that is the default now. The anonymous preview is kept ONLY for the bots that only look:
|
|
267
|
+
// uptime/liveness monitors and directory/registry crawlers, recognised by the self-describing user-agent every one
|
|
268
|
+
// of them sends (a +https:// contact URL, or bot/crawler/probe/monitor/registry/... in the name; read off 7 days
|
|
269
|
+
// of 200s on /mcp, 2026-09-25). A plain library UA (node, undici, python-httpx, Go-http-client) is NOT a bot by
|
|
270
|
+
// itself: Gemini CLI is "node" and claude.ai's probe is python-httpx. `?auth=none` asks for the preview explicitly.
|
|
271
|
+
// Directories that list tools with a sign-in (Smithery, Glama's inspector, OpenAI's and Anthropic's reviews) use
|
|
272
|
+
// OAuth already. SIGNIN_UPFRONT_CLIENTS still wins over a bot-looking UA.
|
|
273
|
+
const ANON_PREVIEW_UA_RE = /\+https?:\/\/|\b(bot|crawler|spider|probe|scanner|health-?check|uptime|monitor|liveness|registry|collector|audit|checkup|validator|indexer|tripwire|research|catalog-health|signals)\b|mcpbeat|sentineloracle|mcp-watch|mcpwatch|verifymcp|mcp\.market|proofbench|factanker/i;
|
|
274
|
+
const anonPreviewAllowed = (req) => {
|
|
275
|
+
if (String(req.query?.auth || '').toLowerCase() === 'none') return true;
|
|
276
|
+
if (signinUpfront(req)) return false;
|
|
277
|
+
return ANON_PREVIEW_UA_RE.test(String(req.headers['user-agent'] || ''));
|
|
278
|
+
};
|
|
238
279
|
const isAllPreauth = (body) => {
|
|
239
280
|
const arr = Array.isArray(body) ? body : [body];
|
|
240
281
|
const methods = arr.map((m) => m && m.method).filter((v) => typeof v === 'string');
|
|
@@ -267,7 +308,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
267
308
|
const methodsOf = (body) => (Array.isArray(body) ? body : [body]).map((m) => m && m.method).filter(Boolean);
|
|
268
309
|
|
|
269
310
|
async function serveAnonDiscovery(req, res, scope) {
|
|
270
|
-
const server = new McpServer({ name: 'hermoso', version:
|
|
311
|
+
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
|
|
271
312
|
// `widgetHost` withholds the two commerce tools from ChatGPT (see registerTools). It is passed HERE as well
|
|
272
313
|
// as on the session path because OpenAI's own tool scanner reads this anonymous discovery roster — gating
|
|
273
314
|
// only the authenticated path would leave both tools listed in the submission.
|
|
@@ -310,9 +351,10 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
310
351
|
// unchanged in both branches: this decides bookkeeping, never access.
|
|
311
352
|
const didWork = methodsOf(req.body).includes('tools/call');
|
|
312
353
|
const user = token ? await verifyBearer(token, { stamp: didWork }).catch(() => null) : null;
|
|
354
|
+
if (!user && token) connectEvent(req, { step: 'mcp-auth', ok: false, reason: 'bearer token rejected', detail: `${token.slice(0, 4)}… (${token.length} chars) on ${req.method}` });
|
|
313
355
|
if (!user) {
|
|
314
356
|
// No valid bearer: allow ONLY the read-only discovery handshake (POST), fail CLOSED for everything else.
|
|
315
|
-
if (req.method === 'POST' && isAllPreauth(req.body) &&
|
|
357
|
+
if (req.method === 'POST' && isAllPreauth(req.body) && anonPreviewAllowed(req)) {
|
|
316
358
|
const scope = scopeFor(req, res);
|
|
317
359
|
if (scope === false) return; // unknown group — already answered 400
|
|
318
360
|
return serveAnonDiscovery(req, res, scope).catch(() => { try { challenge(res); } catch {} });
|
|
@@ -377,7 +419,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
377
419
|
// OPEN with the full roster, so the whole change would be silently inert. Never throws; see
|
|
378
420
|
// connectedProviders() ([[failed-read-is-not-empty]]).
|
|
379
421
|
const connectors = await mcpCtx.run({ token, remote: true, client: rememberedClient(req) }, () => connectedProviders());
|
|
380
|
-
const server = new McpServer({ name: 'hermoso', version:
|
|
422
|
+
const server = new McpServer({ name: 'hermoso', version: PKG_VERSION }, { instructions: MCP_INSTRUCTIONS });
|
|
381
423
|
registerTools(server, { only: scope.groups, directory: scope.directory || false, connectors, widgetHost: isWidgetHost(entry?.client || clientInfoOf(req.body), req) , hosted: true, client: entry?.client || rememberedClient(req) }); // the SAME tools as stdio (minus any the caller scoped out) — and every /api call they make carries this user's token
|
|
382
424
|
const transport = new StreamableHTTPServerTransport({
|
|
383
425
|
// CSPRNG, per the spec's SHOULD for session ids (Math.random() is not one).
|
|
@@ -400,7 +442,15 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
400
442
|
// published app cannot be observed from here). It costs one frame after the handshake, it is only sent to
|
|
401
443
|
// widget hosts, and if the host does honour it the stale-snapshot problem heals itself. The real belt is
|
|
402
444
|
// LEGACY_TOOL_NAMES in tools.mjs, which keeps every name a snapshot could hold answering.
|
|
403
|
-
|
|
445
|
+
//
|
|
446
|
+
// ── MEASURED NOT GUILTY OF "No app tools available yet" (2026-09-25) ─────────────────────────────────────────
|
|
447
|
+
// A fresh ChatGPT developer-mode app shows "No app tools available yet" right after Allow. This nudge was the
|
|
448
|
+
// first suspect (it lands on the notification stream ChatGPT opens at connect), so it was switched off and the
|
|
449
|
+
// connect repeated: same panel. What IS true: our tools/list answered 200 with the whole roster inside the
|
|
450
|
+
// connect every time, and a plain page reload — with ZERO further requests from ChatGPT to us — lists every
|
|
451
|
+
// tool. The panel is rendered before ChatGPT's own background sync finishes and never re-reads it. So the
|
|
452
|
+
// nudge is back on, as it had been since 2026-09-14; MCP_LIST_CHANGED_NUDGE=0 turns it off.
|
|
453
|
+
if (process.env.MCP_LIST_CHANGED_NUDGE !== '0' && isWidgetHost(entry.client, req)) { const t = setTimeout(() => { try { server.sendToolListChanged(); } catch {} }, 2500); if (t && typeof t.unref === 'function') t.unref(); }
|
|
404
454
|
// If the handshake never completes (client drops, initialize rejected), nothing is in the map and both
|
|
405
455
|
// objects are otherwise reachable only from this request's still-open response — close them explicitly
|
|
406
456
|
// rather than leaving a session pinned by a dead socket.
|
|
@@ -414,7 +464,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
414
464
|
// Counted here, before the transport sees the body, so the tally is of what the CLIENT asked and not of what
|
|
415
465
|
// the SDK answered — a refused tools/call is still a call the roster earned.
|
|
416
466
|
for (const m of methodsOf(req.body)) {
|
|
417
|
-
if (m === 'tools/list') entry.listed = true;
|
|
467
|
+
if (m === 'tools/list') { if (!entry.listed) connectEvent(req, { step: 'tools-list', ok: true, tokenHash: createHash('sha256').update(token).digest('hex'), client: entry.client || '' }); entry.listed = true; }
|
|
418
468
|
else if (m === 'tools/call') {
|
|
419
469
|
entry.calls = (entry.calls || 0) + 1;
|
|
420
470
|
// THE FIRST CALL IS THE MILESTONE, NOT THE LISTING (2026-09-07). The connect_mcp reward was granted on "an API
|
package/mcp/tools.mjs
CHANGED
|
@@ -122,7 +122,7 @@ const timelineReviewText = (rv) => rv ? `\nREVIEW (${rv.verdict || 'unread'}${rv
|
|
|
122
122
|
const seamsText = (d) => {
|
|
123
123
|
const rows = Array.isArray(d?.seams) ? d.seams.filter((x) => x && x.before) : [], b = d?.budget, n = (x) => `${x >= 0 ? '+' : ''}${x}`;
|
|
124
124
|
const dl = (x) => x ? `exposure ${n(x.exposurePct)}%, black ${n(x.black)}, WB u${n(x.wbU)} v${n(x.wbV)}, grain ${n(x.grain)}, sharpness x${x.sharpness}` : 'unread';
|
|
125
|
-
return `${rows.length ? `\nSEAMS MATCHED: ${rows.map((x) => `seam ${x.seam} (${x.at}s) before ${dl(x.before)} -> after ${dl(x.after)}; ${x.applied}`).join(' | ')}` : ''}${b ? `\nBUDGET: ${b.total}s total - ${b.intro}s intro = ${b.survivingWindow.seconds}s of ${b.footage}${b.dropped?.length ? `; not shown: ${b.dropped.map((x) => `${x.from}-${x.to}s (${x.why})`).join(', ')}${b.fixes ? `. To keep it: ${b.fixes.join(' / ')}` : ''}` : ''}` : ''}`;
|
|
125
|
+
return `${rows.length ? `\nSEAMS MATCHED: ${rows.map((x) => `seam ${x.seam} (${x.at}s) before ${dl(x.before)} -> after ${dl(x.after)}; ${x.applied}${x.reframe ? `; ${x.reframe}` : ''}`).join(' | ')}` : ''}${b ? `\nBUDGET: ${b.total}s total - ${b.intro}s intro = ${b.survivingWindow.seconds}s of ${b.footage}${b.dropped?.length ? `; not shown: ${b.dropped.map((x) => `${x.from}-${x.to}s (${x.why})`).join(', ')}${b.fixes ? `. To keep it: ${b.fixes.join(' / ')}` : ''}` : ''}` : ''}`;
|
|
126
126
|
};
|
|
127
127
|
const okVideo = async (text, r) => {
|
|
128
128
|
if (r?.stillRendering) return ok(stillMsg(r), r); const p = r?.url ? await videoPosterBlock(r.url) : null; const t = text + geoLine(r) + qaLine(r); return { content: [{ type: 'text', text: p ? t + '\n(first frame attached — open the URL for the full video)' : t }, ...(p ? [p] : [])], structuredContent: r ?? {} }; };
|
|
@@ -1623,8 +1623,16 @@ function registerAppResources(server) {
|
|
|
1623
1623
|
// /api/workspace, where resolveWs re-authorizes the pin per request, so hosted and stdio now resolve identically.
|
|
1624
1624
|
const pk = async (base) => { const s = await storeSuffix(); return s ? `${base}.${s}` : base; };
|
|
1625
1625
|
async function readStore(base) {
|
|
1626
|
+
// A SIGNED-OUT READ IS NOT AN EMPTY STORE (2026-09-25). /api/store/bootstrap answers an anonymous caller 200 with
|
|
1627
|
+
// nothing in it, so with no key list_library said "The Library is empty for this workspace" — measured over stdio
|
|
1628
|
+
// on prod — and every other store-backed tool (memory, skills, swipefile, playbooks) would answer the same kind of
|
|
1629
|
+
// lie. A process with no credential that reads back NOTHING has no workspace, so it says how to sign in; a 401 on
|
|
1630
|
+
// the read means the same. Decided on what came back, not on the missing token alone, so a stubbed or local store
|
|
1631
|
+
// that does answer is still read.
|
|
1626
1632
|
const key = await pk(base); // deliberately OUTSIDE the try: an unresolvable workspace must fail loudly, not read the wrong one
|
|
1627
|
-
|
|
1633
|
+
const signIn = () => Object.assign(new Error(SIGN_IN_HINT), { status: 401, _signedOut: true });
|
|
1634
|
+
let dump; try { dump = await apiGet('/api/store/bootstrap'); } catch (e) { if (signedOut()) throw signIn(); if (e?.status === 401) throw e; return null; }
|
|
1635
|
+
if (signedOut() && (!dump || typeof dump !== 'object' || !Object.keys(dump).length)) throw signIn();
|
|
1628
1636
|
const raw = dump && dump[key] && dump[key].value;
|
|
1629
1637
|
if (typeof raw !== 'string') return null;
|
|
1630
1638
|
try { return JSON.parse(raw); } catch { return null; }
|
|
@@ -3064,6 +3072,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3064
3072
|
for (const m of String(h.description || '').matchAll(/\b[a-zA-Z][a-zA-Z0-9]*(?:_[a-zA-Z0-9]+)+\b|\b[a-z]+(?:[A-Z][a-z0-9]+)+\b/g)) if (m[0].length >= 6) set.add(sq(m[0]));
|
|
3065
3073
|
_squashIdx.set(h, set); return set;
|
|
3066
3074
|
};
|
|
3075
|
+
// THE MATCH COUNT find_tools REPORTS (2026-09-25). A row is a STRONG match when a query word ITSELF landed in its NAME
|
|
3076
|
+
// (or it is the exact tool name asked for) and it answers as many of the query words as the best-covering such row.
|
|
3077
|
+
// Tiered so a search always counts something: a synonym landing in the name ("tweet" → post_to_x) is the next tier,
|
|
3078
|
+
// and a description-only search is the last. An empty query (browsing a group) counts every row. Rows carry `_cov`
|
|
3079
|
+
// (words answered), `_nd` (direct name hits), `_nh` (name hits incl. synonyms) and `_exact`.
|
|
3080
|
+
const findToolsMatchCount = (rows, hasQuery) => {
|
|
3081
|
+
if (!hasQuery) return rows.length;
|
|
3082
|
+
const exact = rows.filter((r) => r._exact).length;
|
|
3083
|
+
const rest = rows.filter((r) => !r._exact);
|
|
3084
|
+
const direct = rest.filter((r) => r._nd > 0), named = rest.filter((r) => r._nh > 0);
|
|
3085
|
+
const pool = direct.length ? direct : named.length ? named : rest;
|
|
3086
|
+
const best = pool.reduce((m, r) => Math.max(m, r._cov || 0), 0);
|
|
3087
|
+
return exact + pool.filter((r) => (r._cov || 0) >= best).length;
|
|
3088
|
+
};
|
|
3067
3089
|
const makeFindToolsHandler = (ctx) => async ({ query = '', group = '', limit = 12, onlyHealthy = false } = {}) => {
|
|
3068
3090
|
const q = String(query || '').toLowerCase().trim();
|
|
3069
3091
|
const g = String(group || '').toLowerCase().trim();
|
|
@@ -3081,7 +3103,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3081
3103
|
// returned WITH their real group so the caller learns which group to enable instead of giving up.
|
|
3082
3104
|
if (g && grp !== g) { if (!_offGroup.has(name)) _offGroup.set(name, { grp, h }); continue; }
|
|
3083
3105
|
const desc = String(h.description || '');
|
|
3084
|
-
let score = 0;
|
|
3106
|
+
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)
|
|
3085
3107
|
if (q) {
|
|
3086
3108
|
// A NAME-SHAPED ASK IS ALSO ITS WORDS (2026-09-12). Agents search the name they guess (list_meta_campaigns,
|
|
3087
3109
|
// update_meta_ad, edit_meta): kept as one literal token it matched nothing and filed a dead end, while its parts
|
|
@@ -3092,24 +3114,25 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3092
3114
|
if (_fieldHits) score += 4 * _fieldHits;
|
|
3093
3115
|
const descLc = desc.toLowerCase();
|
|
3094
3116
|
const nameTokens = name.split('_');
|
|
3095
|
-
let nameHits = 0, covered = 0;
|
|
3117
|
+
let nameHits = 0, covered = 0, directHits = 0;
|
|
3096
3118
|
for (const { w, alts, literal } of words) {
|
|
3097
|
-
if (literal) { if (name.includes(w)) { score += 5; nameHits++; covered++; } continue; } // "find_tools" typed as-is
|
|
3119
|
+
if (literal) { if (name.includes(w)) { score += 5; nameHits++; directHits++; covered++; } continue; } // "find_tools" typed as-is
|
|
3098
3120
|
const exactTok = nameTokens.find((t) => t === w);
|
|
3099
|
-
if (exactTok) { score += 4 * tokenWeight(exactTok); nameHits++; covered++; continue; } // the word IS a name token
|
|
3121
|
+
if (exactTok) { score += 4 * tokenWeight(exactTok); nameHits++; directHits++; covered++; continue; } // the word IS a name token
|
|
3100
3122
|
// the best-weighted synonym/stem that is a name token — "tweet" must land on post_to_x's `x` (rare), not its `post` (everywhere)
|
|
3101
3123
|
let synBest = 0;
|
|
3102
3124
|
for (const t of nameTokens) for (const a of alts) if (a !== w && (t === a || (a.length >= 4 && t.startsWith(a) && t.length - a.length <= 2))) synBest = Math.max(synBest, 3 * stemAwareWeight(ctx, tokenWeight, t, a));
|
|
3103
3125
|
if (synBest) { score += synBest; nameHits++; covered++; continue; }
|
|
3104
|
-
if (w.length >= 4 && name.includes(w)) { score += 2; nameHits++; covered++; continue; } // the literal word inside a name token
|
|
3126
|
+
if (w.length >= 4 && name.includes(w)) { score += 2; nameHits++; directHits++; covered++; continue; } // the literal word inside a name token
|
|
3105
3127
|
const typoTok = nameTokens.find((t) => withinOneEdit(w, t));
|
|
3106
|
-
if (typoTok) { score += 2 * tokenWeight(typoTok); nameHits++; covered++; continue; } // a typo of a name token
|
|
3128
|
+
if (typoTok) { score += 2 * tokenWeight(typoTok); nameHits++; directHits++; covered++; continue; } // a typo of a name token
|
|
3107
3129
|
if (alts.some((a) => a.length >= 3 && descLc.includes(a))) { score += 1; covered++; continue; } // any form in the description
|
|
3108
3130
|
if (alts.some((a) => grp.includes(a))) { score += 1; covered++; }
|
|
3109
3131
|
}
|
|
3110
3132
|
if (!score) continue;
|
|
3111
3133
|
if (words.length > 1) score += covered; // coverage: a tool that answers MORE of the words outranks one that answers one of them loudly
|
|
3112
3134
|
if (words.length > 1 && nameHits === words.length) score += 2; // every word landed in the NAME: a phrase hit
|
|
3135
|
+
_cov = covered; _nh = nameHits; _nd = directHits;
|
|
3113
3136
|
}
|
|
3114
3137
|
const hold = toolHoldReason(name, ctx);
|
|
3115
3138
|
const health = toolHealth(name);
|
|
@@ -3117,7 +3140,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3117
3140
|
// endpoint in outage by default; we do not, because "Hermoso has no such tool" is the most expensive wrong
|
|
3118
3141
|
// answer this product can give, and a hidden row is indistinguishable from an absent capability.
|
|
3119
3142
|
if (onlyHealthy && (hold || health.state === 'failing')) continue;
|
|
3120
|
-
rows.push({ name, group: grp, score, inRoster: !!h.enabled, callable: !hold, hold, cost: costOf(name, grp, hold), health, title: String(h.title || ''), description: desc.replace(/\s+/g, ' ').slice(0, 240) });
|
|
3143
|
+
rows.push({ name, group: grp, score, _cov, _nh, _nd, inRoster: !!h.enabled, callable: !hold, hold, cost: costOf(name, grp, hold), health, title: String(h.title || ''), description: desc.replace(/\s+/g, ' ').slice(0, 240) });
|
|
3121
3144
|
}
|
|
3122
3145
|
// AN EXACT TOOL NAME OUTRANKS THE GROUP FILTER (2026-09-12). The name-shaped split above made a scoped search for a real
|
|
3123
3146
|
// tool in the wrong group find its WORDS in-group (tiktok_creator_info in channels → post_to_tiktok), so `total` was no
|
|
@@ -3127,7 +3150,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3127
3150
|
for (const lit of new Set(q.split(/[\s,]+/).map((r) => r.replace(/[^a-z0-9_]/g, '')).filter((r) => r.includes('_')))) {
|
|
3128
3151
|
const off = _offGroup.get(lit); if (!off) continue;
|
|
3129
3152
|
const hold = toolHoldReason(lit, ctx);
|
|
3130
|
-
rows.push({ name: lit, group: off.grp, score: Number.MAX_SAFE_INTEGER, inRoster: !!off.h.enabled, callable: !hold, hold, cost: costOf(lit, off.grp, hold), health: toolHealth(lit), title: String(off.h.title || ''), description: String(off.h.description || '').replace(/\s+/g, ' ').slice(0, 240) });
|
|
3153
|
+
rows.push({ name: lit, group: off.grp, score: Number.MAX_SAFE_INTEGER, _exact: true, inRoster: !!off.h.enabled, callable: !hold, hold, cost: costOf(lit, off.grp, hold), health: toolHealth(lit), title: String(off.h.title || ''), description: String(off.h.description || '').replace(/\s+/g, ' ').slice(0, 240) });
|
|
3131
3154
|
}
|
|
3132
3155
|
}
|
|
3133
3156
|
// WHAT IS SHOWN IS DECIDED BY RELEVANCE; THE ORDER WITHIN IT IS DECIDED BY HEALTH (2026-09-17). (A plain comment,
|
|
@@ -3141,13 +3164,18 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3141
3164
|
// what is shown. An exact name hit (score MAX_SAFE_INTEGER) carries no penalty at all — an agent that named a
|
|
3142
3165
|
// tool outright gets it first, with its hold and its health printed beside it.
|
|
3143
3166
|
rows.sort((a, b) => b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name)); // ties: the shorter, more specific name first
|
|
3144
|
-
|
|
3167
|
+
// HOW MANY "MATCH" IS A RELEVANCE COUNT, NEVER THE ROSTER (2026-09-25). Every tool whose description merely
|
|
3168
|
+
// contains one of the words scores, so "google ads report" reported 740 matches, which is the whole catalog and
|
|
3169
|
+
// tells an agent nothing. `total` now counts the STRONG matches (findToolsMatchCount): tools with a query word in
|
|
3170
|
+
// their NAME that answer as many of the words as the best such tool does. The ranking and the rows shown are
|
|
3171
|
+
// unchanged; the looser description-only hits are still ranked below and counted separately as `related`.
|
|
3172
|
+
const total = findToolsMatchCount(rows, !!q), related = rows.length, top = rows.slice(0, cap);
|
|
3145
3173
|
for (const r of top) r._penalty = r.score === Number.MAX_SAFE_INTEGER ? 0 : healthPenalty(r.health, r.hold);
|
|
3146
3174
|
top.sort((a, b) => a._penalty - b._penalty || b.score - a.score || a.name.length - b.name.length || a.name.localeCompare(b.name));
|
|
3147
3175
|
// THE MOST VALUABLE ROW ON THE DEFECT BOARD: what a user asked for, in their agent's words, that our catalog could
|
|
3148
3176
|
// not name. Unquoted and lowercased on purpose — the ledger collapses quoted strings to <q>, and one group per
|
|
3149
3177
|
// distinct ask is exactly what we want to read.
|
|
3150
|
-
if (!
|
|
3178
|
+
if (!related && g && _offGroup.size) {
|
|
3151
3179
|
// Re-score the excluded tools by NAME only (the cheap, unambiguous half): an exact or token hit outside the
|
|
3152
3180
|
// asked-for group is an answer, not a dead end — "it exists, in channel_admin; enable that group".
|
|
3153
3181
|
const qw = String(q || '').toLowerCase().split(/[^a-z0-9_]+/).filter(Boolean);
|
|
@@ -3155,11 +3183,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3155
3183
|
.map(([name, { grp, h }]) => `• ${name} [${grp}, not in the ${g} group] — ${String(h.description || '').replace(/\s+/g, ' ').slice(0, 200)}`);
|
|
3156
3184
|
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 });
|
|
3157
3185
|
}
|
|
3158
|
-
if (!
|
|
3186
|
+
if (!related) 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 });
|
|
3159
3187
|
for (const r of top) r.params = compactParams(ctx.handleOf[r.name]);
|
|
3160
3188
|
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)'}`);
|
|
3161
|
-
const
|
|
3162
|
-
|
|
3189
|
+
const looser = top.length > total ? top.length - total : 0;
|
|
3190
|
+
const text = related
|
|
3191
|
+
? `${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` : ''}. 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')}`
|
|
3163
3192
|
: `No tool matches${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}. Try a broader word (e.g. "lead", "campaign", "report") or a group: ${TOOL_GROUP_NAMES.join(', ')}.`;
|
|
3164
3193
|
// THE NEXT STEP, NAMED. The text already ends "Run any of them with call_tool({name, args})"; this is the same
|
|
3165
3194
|
// instruction with the actual name in it, plus the connect step when the best match is the one that is held.
|
|
@@ -3171,7 +3200,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
3171
3200
|
: { do: `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' : ''}` });
|
|
3172
3201
|
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` });
|
|
3173
3202
|
}
|
|
3174
|
-
return withHints({ content: [{ type: 'text', text }], structuredContent: { total, tools: top.map(({ score, _penalty, ...r }) => r) } }, hints);
|
|
3203
|
+
return withHints({ content: [{ type: 'text', text }], structuredContent: { total, related, tools: top.map(({ score, _penalty, _cov, _nh, _nd, _exact, ...r }) => r) } }, hints);
|
|
3175
3204
|
};
|
|
3176
3205
|
// "DID YOU MEAN" HAS TO DISCRIMINATE, AND THE OLD ONE DID NOT (2026-09-17, off the defect board). Not a `// ──`
|
|
3177
3206
|
// banner on purpose: tools/docs-data.mjs turns every banner into a public docs section, and this note sits
|
|
@@ -17369,6 +17398,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17369
17398
|
// a constant, or keyframes [{t | src, v, ease}] (shape spelled out on `segments`: one description beats thirteen copies)
|
|
17370
17399
|
const KF = z.union([z.number(), z.array(z.any())]);
|
|
17371
17400
|
// seam matching (lib/seam-match.mjs): 'auto' | 'off' | {grade, level, grain, blur: booleans, strength 0-1}
|
|
17401
|
+
const REFRAME_OPT = z.union([z.enum(['auto', 'off']), z.number()]);
|
|
17372
17402
|
const SEAM_MATCH = z.union([z.enum(['auto', 'off']), z.object({ grade: z.boolean().optional(), level: z.boolean().optional(), grain: z.boolean().optional(), blur: z.boolean().optional(), strength: z.number().optional() })]);
|
|
17373
17403
|
server.registerTool('edit_timeline', {
|
|
17374
17404
|
title: 'Compose an edit (timeline)',
|
|
@@ -17379,7 +17409,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17379
17409
|
+ "A VIRAL HOOK + THEIR PRODUCT: start from the hook. A clip matched to an unrelated hook never reads as one video, so write the clip AFTER the hook for it: a linking script + shot brief (the first line answers the hook, e.g. 'still waiting for the egg to land... anyway, come check out our restaurant'; what to film so it follows on; 5-15 s; then the pitch). The user records it, or you generate it (render_ad / generate_video, cost quoted first, on their OK); then join here: the hook with out:'payoff' and audio.tail:'payoff', then their clip. Match an existing unrelated clip only if they insist. A {generate:{prompt, seconds 3-8}} segment (a generated transition-only shot, paid, postEditTimeline) is never suggested; build it only when they explicitly ask for one. "
|
|
17380
17410
|
+ "LINK FIRST: an effect alone never connects two unrelated clips; the link comes from what is in the frames. (1) Match cut, the default: video_frames (with its MOTION readout) on the hook's last second and across the other clip; pick the out-point AND the in-point (in: seconds, not always 0) where a motion direction, a screen position or size, a shape, a surface, a gesture or a gaze carries across, then ride the effect on that shared motion. (2) Its host names the hook in the first line. If the two share nothing, say so and offer the follow clip made for the hook. "
|
|
17381
17411
|
+ "PRO, NOT IMOVIE: ease every curve (never linear on a move); keep the picture filling the frame through a move (scale up while it moves: two frames sliding side by side with a seam is the amateur tell); hide the handoff under the fastest, blurriest frames; carry direction into the next shot; cut on motion; end every effect cleanly; 0.2-0.6 s in total; a sound whose peak lands on the handoff (sfx whoosh at handoff minus 0.45 s, or the hook's own payoff sound). Moving segments get a real shutter blur automatically (motionBlur). "
|
|
17382
|
-
+ "EVERY SEAM IS MATCHED AUTOMATICALLY, hard cuts too: each cut is measured and the incoming clip graded (exposure, white balance, black level), grained UP (never smoothed) and softened while it moves toward the outgoing one; the reply gives before/after deltas per seam. match (timeline: every cut; segment: the cut into it): 'auto' default, 'off' for a deliberate contrast, or {grade, level, grain, blur: false to skip one, strength 0-1}; a segment's own constant exposure / contrast / saturation replaces the automatic grade. Still yours: subject size and headroom (scale it, never a jump from a third of the frame to two thirds) and sound (a 0.25-0.5 s J/L-cut, never a sonic wall). A clip placed after a hook gets a BUDGET (total - intro = its surviving window, and what was dropped). The plainest thing that links wins: a straight cut on action beats a decorative effect; over 0.5 s is too long in anything under 20 s; never flash more than 3 times a second. "
|
|
17412
|
+
+ "EVERY SEAM IS MATCHED AUTOMATICALLY, hard cuts too: each cut is measured and the incoming clip graded (exposure, white balance, black level), grained UP (never smoothed) and softened while it moves toward the outgoing one; the reply gives before/after deltas per seam. match (timeline: every cut; segment: the cut into it): 'auto' default, 'off' for a deliberate contrast, or {grade, level, grain, blur: false to skip one, strength 0-1}; a segment's own constant exposure / contrast / saturation replaces the automatic grade. A JUMP CUT (two moments of one shot at one framing) is punched in ~1.2x on the face automatically (reframe: 'off' or the step 1.1-1.5; a segment with its own scale is left alone). Still yours: subject size and headroom (scale it, never a jump from a third of the frame to two thirds) and sound (a 0.25-0.5 s J/L-cut, never a sonic wall). A clip placed after a hook gets a BUDGET (total - intro = its surviving window, and what was dropped). The plainest thing that links wins: a straight cut on action beats a decorative effect; over 0.5 s is too long in anything under 20 s; never flash more than 3 times a second. "
|
|
17383
17413
|
+ "RECIPES (c = the cut second, adapt freely): whip pan: A over its last 0.22 s x 0 to -0.22, scale 1 to 1.35, mblur 0 to 220, all ease in; B overlap 0.08, opacity 0 to 1 over 0.08, x 0.22 to 0, scale 1.35 to 1, mblur 220 to 0, all ease out over 0.3 s; whoosh at c-0.45. Zoom through: A over its last 0.35 s scale 1 to 3 ease in anchored on the object, blur 0 to 10; B overlap 0.12, opacity 0 to 1, scale 1.5 to 1 and blur 10 to 0 ease out over 0.4 s. Cut on action: A out ON the motion, B scale 1.08 to 1 ease out over 0.25 s, audio.lead 0.2. Speed ramp: speed [{src:t0,v:1},{src:t0+0.25,v:0.3}] then [{src:t1,v:0.3},{src:t1+0.1,v:2}] into the cut. Circle wipe: B overlap 0.5 + overlays [{mode:'mask', segment:1, start, end, html: a white div whose clip-path circle grows via @keyframes}]. Card (picture in picture: a proven ad playing in a rounded card over the host watching it, any length): the host segment full frame, then the clip ON TOP with at:0, fit:'contain' (crop to reframe it), scale ~0.6-0.7, y ~0.12, radius ~0.04-0.06; a card is not a cut, so it is never graded toward the host; duck it under the host's first line with audio.gain keys (the host's voice leads the switch) and end it on a hard cut at a sentence break. "
|
|
17384
17414
|
+ "SELF-CRITIQUE: the reply carries a vision REVIEW of each seam (pro / ok / amateur, linked or not, with fixes; about 2 credits, review:false skips it) and the seam frames. When it says ok or amateur, fix what it names and re-run the same sources (twice at most) before presenting; on a re-run give a generated segment {src: its URL, between: true} so it is not paid for twice. Then look at the WHOLE result once with video_frames, not only the seams: the first frame is not black or frozen, no dead air over ~0.3 s at the head, no lone black, flash or repeated frame at a cut, and nothing static for more than ~4-5 s (recut it or add a re-hook). "
|
|
17385
17415
|
+ "FOLLOW-UPS ('cut earlier', 'no splat', 'whip pan instead', 'use the second hook') re-run this with the SAME sources and the one change; the result echoes the resolved timeline (e.g. the found payoff cut) to edit from. Refusals are free and name the field.",
|
|
@@ -17402,6 +17432,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17402
17432
|
reverse: z.boolean().optional(),
|
|
17403
17433
|
between: z.boolean().optional().describe('a bridge clip between its neighbours (a re-used generated shot): trimmed and graded to them automatically'),
|
|
17404
17434
|
match: SEAM_MATCH.optional().describe('the cut INTO this segment (default: the timeline match)'),
|
|
17435
|
+
reframe: REFRAME_OPT.optional().describe('the cut INTO this segment'),
|
|
17405
17436
|
slowmo: z.enum(['blend', 'hold', 'flow']).optional().describe('how slow motion fills frames (flow = motion-interpolated)'),
|
|
17406
17437
|
scale: KF.optional(), x: KF.optional().describe('canvas widths'), y: KF.optional().describe('canvas heights'), rotate: KF.optional().describe('degrees'),
|
|
17407
17438
|
opacity: KF.optional(), blur: KF.optional(), mblur: KF.optional().describe('directional motion blur px'), mblurAngle: z.number().optional(),
|
|
@@ -17416,11 +17447,12 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17416
17447
|
motionBlur: z.boolean().optional().describe('default true: anything that moves gets a real shutter blur along its path'),
|
|
17417
17448
|
review: z.boolean().optional().describe('default true: a vision read of each seam (about 2 credits) comes back with the render, with the seam frames'),
|
|
17418
17449
|
match: SEAM_MATCH.optional().describe("every cut: 'auto' default"),
|
|
17450
|
+
reframe: REFRAME_OPT.optional().describe("every cut: 'auto' default"),
|
|
17419
17451
|
},
|
|
17420
17452
|
outputSchema: { ...JOB_OUT },
|
|
17421
17453
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
17422
17454
|
}, wrap(async (a) => {
|
|
17423
|
-
const op = { op: 'timeline', segments: a.segments, ...(a.overlays ? { overlays: a.overlays } : {}), ...(a.sfx ? { sfx: a.sfx } : {}), ...(a.size ? { size: a.size } : {}), ...(a.fps ? { fps: a.fps } : {}), ...(a.motionBlur != null ? { motionBlur: a.motionBlur } : {}), ...(a.review != null ? { review: a.review } : {}), ...(a.match != null ? { match: a.match } : {}) };
|
|
17455
|
+
const op = { op: 'timeline', segments: a.segments, ...(a.overlays ? { overlays: a.overlays } : {}), ...(a.sfx ? { sfx: a.sfx } : {}), ...(a.size ? { size: a.size } : {}), ...(a.fps ? { fps: a.fps } : {}), ...(a.motionBlur != null ? { motionBlur: a.motionBlur } : {}), ...(a.review != null ? { review: a.review } : {}), ...(a.match != null ? { match: a.match } : {}), ...(a.reframe != null ? { reframe: a.reframe } : {}) };
|
|
17424
17456
|
const r = await renderJob('postedit', { ...(a.videoUrl ? { videoUrl: a.videoUrl } : {}), ops: [op] }, 'MCP timeline');
|
|
17425
17457
|
const tl = r?.raw?.timeline;
|
|
17426
17458
|
const gen = Array.isArray(r?.raw?.generatedShots) && r.raw.generatedShots.length ? `\nGENERATED SHOT: ${r.raw.generatedShots.map((g) => `${g.model} ${g.seconds}s ${abs(g.video)}`).join('; ')} (billed as its own render)` : '';
|
|
@@ -17857,7 +17889,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
|
|
|
17857
17889
|
}))).filter(Boolean);
|
|
17858
17890
|
} catch {}
|
|
17859
17891
|
const d = await apiGet('/api/skills').catch(() => ({ skills: [] }));
|
|
17860
|
-
let custom = await readStore('heist.skills.v1'); if (!Array.isArray(custom)) custom = []; // the workspace's OWN skills (built-ins alone came from /api/skills)
|
|
17892
|
+
let custom = await readStore('heist.skills.v1').catch((e) => { if (e?._signedOut) return []; throw e; }); if (!Array.isArray(custom)) custom = []; // the workspace's OWN skills (built-ins alone came from /api/skills); signed out, the built-ins still list
|
|
17861
17893
|
const inApp = (d.skills || []).map(s => `${s.id} (${s.kind || s.group})`).join(', ');
|
|
17862
17894
|
const customLine = custom.map(s => `- ${s.name} (${s.id})`).join('\n');
|
|
17863
17895
|
const text = `Skill bundles (call get_skill with the name):\n${bundles.map(b => `- ${b.name}: ${b.description}`).join('\n') || '(none bundled)'}\n\nIn-app strategy skills + creative recipes (pass as plan_ad's recipe / create's skill): ${inApp}\n\nYour custom skills (save_skill / delete_skill):\n${customLine || '(none yet)'}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.308",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Marketing on autopilot, run from your own AI agent. 863 tools. Publishing, scheduling, ad campaign management, comments, DMs and analytics cost no credits on every plan; credits are only for generating creative and for Ad Spy research. AD PLATFORMS: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads, Pinterest Ads, Snapchat Ads, Microsoft Advertising, Apple Search Ads and ChatGPT Ads, plus product feeds in Google Merchant Center. PUBLISHING AND SCHEDULING: Facebook, Instagram, Threads, TikTok, YouTube, X, LinkedIn, Pinterest, Bluesky and Telegram. AD RESEARCH: the Meta, Google and LinkedIn ad libraries plus organic TikTok, Instagram, YouTube, Threads and Reddit. ANALYTICS: Google Analytics 4, Google Search Console and every connected platform's own post and campaign insights. Also brand onboarding, 50+ image and video generation models, ad scoring, competitor teardowns, Google Drive and OneDrive, a CLI and installable Claude skills.",
|
|
6
6
|
"type": "module",
|