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 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 → Settings → Connectors → *Add custom connector*, pick
159
- **Always required** when Claude asks about authentication (its detector suggests "None" because our discovery
160
- handshake is open; "None" would leave every tool call unauthenticated), approve with your Hermoso account, and you are done: the full toolset with your saved brand context, billed to your plan.
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 it is the worse path there and it is worth knowing why:
185
- `claude mcp add --transport http hermoso "https://app.hermoso.ai/mcp?src=readme"` is accepted, and then `claude mcp list`
186
- reports `! Needs authentication` because the client will not start the OAuth flow by itself: you have to open a
187
- session, run `/mcp`, find the server and press Authenticate. Measured against Claude Code 2.1.241 on 2026-08-23.
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
- export function mountRemoteMcp(app, { verifyBearer, publicBaseUrl, onSessionStart = null, onSessionEnd = null, onAnonDiscovery = null, onFirstCall = null } = {}) {
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
- const signinUpfront = (req) => /^grok-connectors-manager\b/i.test(String(req.headers['user-agent'] || ''))
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: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
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) && !signinUpfront(req)) {
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: '1.0.0' }, { instructions: MCP_INSTRUCTIONS });
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
- if (isWidgetHost(entry.client, req)) { const t = setTimeout(() => { try { server.sendToolListChanged(); } catch {} }, 2500); if (t && typeof t.unref === 'function') t.unref(); }
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
- let dump; try { dump = await apiGet('/api/store/bootstrap'); } catch { return null; }
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
- const total = rows.length, top = rows.slice(0, cap);
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 (!total && g && _offGroup.size) {
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 (!total) 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 });
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 text = total
3162
- ? `${total} tool(s) match${q ? ` "${q}"` : ''}${g ? ` in ${g}` : ''}${total > cap ? ` (showing ${cap} — narrow the query)` : ''}. 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')}`
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.305",
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",