hermoso 0.1.189 → 0.1.192

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
@@ -5,7 +5,7 @@ scripts. Research the ads already winning in a market, generate finished image &
5
5
  composited in, copy + CTA included), publish them to your own social channels, and build & manage the ad
6
6
  campaigns behind them — all over [MCP](https://modelcontextprotocol.io) tools, a CLI, or installable Claude skills.
7
7
 
8
- **745 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
8
+ **746 tools.** `tools/list` is always the authoritative set; `hermoso_capabilities` (free) returns the live model
9
9
  catalog with exact per-render credit costs plus the full capability map.
10
10
 
11
11
  **What it connects to.** Ad platforms: Meta, Google Ads, TikTok Ads, LinkedIn Ads, Reddit Ads, X Ads,
@@ -168,7 +168,7 @@ block entirely if you signed in above; it is there for CI, where the process can
168
168
 
169
169
  Then ask your agent: *“Generate an image ad with Hermoso.”*
170
170
 
171
- ### What the 745 tools cover
171
+ ### What the 746 tools cover
172
172
 
173
173
  **Ad spy / research** — `find_competitors`, `competitor_teardown`, `pull_competitor_ads`, `research_ads`; the
174
174
  Meta / Google / LinkedIn ad libraries (`search_meta_ads`, `search_google_ads`, `search_linkedin_ads`); organic
package/mcp/tools.mjs CHANGED
@@ -1812,6 +1812,8 @@ export function parseToolScope(raw) {
1812
1812
  // everything on 2026-08-16, so a caller who genuinely wants all 436 tools needs a way to say so that is not
1813
1813
  // "list every group by name and hope none was added since".
1814
1814
  if (asked.includes('all')) return { groups: [...TOOL_GROUP_NAMES] };
1815
+ if (asked.includes('directory-full')) return { groups: [...DEFAULT_TOOL_GROUPS], directory: 'full' }; // the submitted listing: everything but the billing tools — see DIRECTORY_BILLING_TOOLS
1816
+ if (asked.includes('directory')) return { groups: [...DIRECTORY_GROUPS], directory: 'scoped' }; // the fully scoped fallback — see DIRECTORY_GROUPS
1815
1817
  const unknown = asked.filter((v) => !TOOL_GROUP_NAMES.includes(v));
1816
1818
  if (unknown.length) {
1817
1819
  // No tool COUNT in this message: a committed count goes stale (four different wrong numbers shipped at once
@@ -1825,6 +1827,39 @@ export function parseToolScope(raw) {
1825
1827
  // a HOST rule, not a capability we removed: every other surface still offers both.
1826
1828
  export const WITHHELD_FROM_WIDGET_HOSTS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload']);
1827
1829
 
1830
+ // ── THE DIRECTORY ROSTER (2026-09-02) — `?tools=directory` ─────────────────────────────────────────────────────
1831
+ // Anthropic's Software Directory Policy prohibits "software that uses AI models to generate images, video, or
1832
+ // audio content" (design aids excepted) and software that "executes financial transactions on behalf of users".
1833
+ // Hermoso as a whole does both, so the Claude Connectors Directory listing is a SCOPED server: the `create` group
1834
+ // is out, every tool that calls a generative image/video/audio model is out wherever it lives, and the three tools
1835
+ // that move money are out (the same three ChatGPT is denied). Dave, 2026-09-02: "We're way more than ad generation,
1836
+ // we can focus on all the other huge benefits like our organic posting, ads management, analytics, dms, etc."
1837
+ // It is a CAGE, deliberately, unlike every other scope: enable_tools may not widen it into `create` or `all`,
1838
+ // because the listing's compliance acknowledgments are only honest if no path on the connection reaches a
1839
+ // generative model. Pinned by tools/directory-roster-check.mjs.
1840
+ export const DIRECTORY_GROUPS = ['research', 'channels', 'channel_admin', 'ads', 'analytics', 'files', 'workspace'];
1841
+ export const DIRECTORY_BANNED_GROUPS = ['create'];
1842
+ // Generative-media tools that live OUTSIDE `create` (post-production and variation lanes) plus the money movers.
1843
+ // Kept as an explicit set rather than a regex so a new generative tool has to be named here — a regex that
1844
+ // happened to miss it would list it in the directory.
1845
+ export const WITHHELD_FROM_DIRECTORY = new Set([
1846
+ 'buy_credits', 'upgrade_plan', 'set_auto_reload',
1847
+ 'generate_image', 'generate_video', 'generate_avatar', 'generate_voice', 'render_ad', 'plan_ad', 'plan_variations',
1848
+ 'make_thumbnail', 'make_explainer', 'product_sizzle', 'dub_video', 'change_voice', 'edit_video', 'recast_motion',
1849
+ 'upscale_video', 'reframe_video', 'multiply_ad', 'remix_static', 'stitch_video', 'fix_beat',
1850
+ ]);
1851
+ // TWO DIRECTORY MODES (Dave, 2026-09-02, after the directory turned out to list Tofu Ads — an AI ad-image generator
1852
+ // — under the policy's design-asset carve-out): `directory` is the fully scoped cage above; `directory-full` keeps
1853
+ // generation (an ad-creation workflow with the brand's own product and copy, the carve-out's shape) and withholds
1854
+ // ONLY the three tools that move money, which is the listing that was actually submitted. Both are cages for what
1855
+ // they withhold: enable_tools never re-lists a billing tool on either.
1856
+ export const DIRECTORY_BILLING_TOOLS = new Set(['buy_credits', 'upgrade_plan', 'set_auto_reload']);
1857
+ export const toolHeldBackByDirectory = (name, group, ctx) => {
1858
+ if (!ctx?.directory) return false;
1859
+ if (ctx.directory === 'full') return DIRECTORY_BILLING_TOOLS.has(name);
1860
+ return WITHHELD_FROM_DIRECTORY.has(name) || DIRECTORY_BANNED_GROUPS.includes(group);
1861
+ };
1862
+
1828
1863
  // ── A REFERENCE LOOKUP MUST NOT BE AN APP SURFACE (2026-08-24) ─────────────────────────────────────────────────
1829
1864
  // MEASURED IN CHATGPT, not theorised: "make a 4 second vertical video ad for a coffee roaster" produced a
1830
1865
  // full-height scrolling MODEL CATALOG card ahead of the ad, and the catalog is ~70 rows, so the thing the user
@@ -1969,6 +2004,10 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
1969
2004
  const want = (Array.isArray(groups) ? groups : []).map((g) => String(g || '').trim().toLowerCase()).filter(Boolean);
1970
2005
  if (!want.length) return { content: [{ type: 'text', text: "Name at least one group to turn on, e.g. groups:['ads']." }], isError: true };
1971
2006
  const expand = want.includes('all') ? [...TOOL_GROUP_NAMES] : want;
2007
+ // THE DIRECTORY CAGE (2026-09-02): a connection scoped for the Claude directory may not be widened into the
2008
+ // generative group, by name or through 'all' — see DIRECTORY_GROUPS. The way to those tools is the app or the
2009
+ // unscoped server URL, and the refusal says so instead of silently trimming the ask.
2010
+ if (ctx.directory === 'scoped' && (want.includes('all') || expand.some((g) => DIRECTORY_BANNED_GROUPS.includes(g)))) return { content: [{ type: 'text', text: `This connection is the Claude directory listing, which deliberately carries no image, video or audio generation tools and no billing tools. Those live in the Hermoso app (https://app.hermoso.ai) and on the unscoped server URL https://app.hermoso.ai/mcp — not on this connection. Groups you can turn on here: ${DIRECTORY_GROUPS.join(', ')}.` }], isError: true };
1972
2011
  const unknown = expand.filter((g) => !TOOL_GROUP_NAMES.includes(g));
1973
2012
  // REFUSED BY NAME, never dropped: silently ignoring a typo would report success and leave the agent looking
1974
2013
  // for a tool that is still disabled — the failure this whole mechanism exists to avoid.
@@ -1989,6 +2028,7 @@ const makeEnableToolsHandler = (ctx) => async ({ groups }) => {
1989
2028
  // says how many and why, because a group that turns on "8 tools" when the agent expected 240 with no
1990
2029
  // explanation is the [[prompt-rosters-go-stale]] failure — the agent concludes the capability is missing.
1991
2030
  if (toolHeldBackByConnectors(name, ctx.conn)) { heldBack++; continue; }
2031
+ if (toolHeldBackByDirectory(name, grp, ctx)) continue; // the cage holds across a group flip too
1992
2032
  try { h.enable(); n++; } catch {}
1993
2033
  }
1994
2034
  }
@@ -2048,6 +2088,7 @@ function newToolScope(opts) {
2048
2088
  enabledGroups: asked, groupOf: Object.create(null), handleOf: Object.create(null),
2049
2089
  conn: opts.connectors || null,
2050
2090
  widgetHost: !!opts.widgetHost,
2091
+ directory: opts.directory === 'full' ? 'full' : (opts.directory ? 'scoped' : false),
2051
2092
  };
2052
2093
  }
2053
2094
  // THE THREE REASONS A REGISTERED TOOL IS HELD BACK. Applied identically on the build path, the replay path and
@@ -2067,6 +2108,7 @@ function applyToolGates(h, name, group, ctx, opts) {
2067
2108
  if (!ctx.enabledGroups.has(group)) { try { h.disable(); } catch {} }
2068
2109
  if (WITHHELD_FROM_WIDGET_HOSTS.has(name) && opts.widgetHost) { try { h.disable(); } catch {} }
2069
2110
  if (toolHeldBackByConnectors(name, ctx.conn)) { try { h.disable(); } catch {} }
2111
+ if (toolHeldBackByDirectory(name, group, ctx)) { try { h.disable(); } catch {} } // (4) the directory cage — see DIRECTORY_GROUPS
2070
2112
  }
2071
2113
 
2072
2114
  // Replay the cached canon onto a fresh server. This is the whole hot path for every session after the first.
@@ -15352,9 +15394,20 @@ function buildTools(rawServer, opts = {}, sink = null) {
15352
15394
  if (!memory.length) return ok('Memory is empty for this workspace.', { memory: [] });
15353
15395
  return ok(`${memory.length} memory item(s):\n` + memory.map(m => ` • [${m.category}] ${m.text}${m.id ? ` (${m.id})` : ''}`).join('\n'), { memory });
15354
15396
  }));
15397
+ // MEMORY HYGIENE (2026-09-02) — byte-identical copies of lib/memory-hygiene.mjs's two regexes (this file ships without
15398
+ // lib/). tools/memory-hygiene-check.mjs pins the parity. Memory holds brand facts, never product/API mechanics or PII.
15399
+ const MEMORY_MECHANICS_RE = /\b(endpoint|API\b|APIs\b|webhook|rate[ -]?limit|access token|OAuth|graph\.[a-z]+|sandbox|dev(?:elopment)? mode|HTTP \d{3}|\b4\d\d\b(?: error)?|error (?:message|text|code)|'reduce the amount of data'|not (?:yet )?supported|deprecated|verified \d{4}-\d{2}-\d{2}|on this connection|Hermoso'?s? (?:own|publish record|tool|MCP|CLI)|(?:no|missing|lacks?) '?[\w ]{0,30}'? ?endpoint|tool (?:returns?|output|call)|(?:returns?|come back|comes back) (?:null|empty|zeros?|truncated|full bodies|counts only)|omit(?:s|ted)? zero rows|permission (?:edge|rows?|list|request)|subscribed_fields|paging cursor|pagination)\b/i;
15400
+ const MEMORY_PII_RE = /(\+\d[\d\s().-]{7,}\d)|(\(?\b\d{3}\)?[\s.-]\d{3}[\s.-]\d{4}\b)|([\w.+-]+@[\w-]+\.[\w.-]+)/;
15401
+ function memoryNoteVerdict(text) {
15402
+ const t = String(text || '').trim();
15403
+ if (!t) return { ok: false, reason: 'empty' };
15404
+ if (MEMORY_PII_RE.test(t)) return { ok: false, reason: 'pii', message: 'Not saved: Memory never holds a phone number or an email address.' };
15405
+ if (MEMORY_MECHANICS_RE.test(t)) return { ok: false, reason: 'mechanics', message: 'Not saved: that is about how Hermoso or a platform API behaves, not about the brand. Memory holds brand, audience, offer, taste and working preferences only — product behaviour lives in the tools themselves.' };
15406
+ return { ok: true };
15407
+ }
15355
15408
  server.registerTool('remember', {
15356
15409
  title: 'Remember a fact',
15357
- description: 'Save a durable fact or PREFERENCE about the brand, audience, or the user’s creative TASTE (e.g. “audience is first-time homebuyers”, “prefers bold lime accents”, “always captions off”) into the workspace Memory so it shapes FUTURE ads. For lasting things, not one-off requests. Merges into the existing Memory (never overwrites); de-dupes on identical text.',
15410
+ description: 'Save a durable fact or PREFERENCE about the brand, audience, or the user’s creative TASTE (e.g. “audience is first-time homebuyers”, “prefers bold lime accents”, “always captions off”) into the workspace Memory so it shapes FUTURE ads. For lasting things, not one-off requests. Merges into the existing Memory (never overwrites); de-dupes on identical text. NEVER how Hermoso, a tool, a connector or a platform API behaves (what a call returns, errors, permissions, limits, ids) and never a phone number or email — those are refused.',
15358
15411
  inputSchema: {
15359
15412
  text: z.string().describe('the fact/preference, concise'),
15360
15413
  category: z.string().optional().describe('short bucket: Brand, Audience, Taste, Do, Don’t, or Preference (default General)'),
@@ -15363,6 +15416,7 @@ function buildTools(rawServer, opts = {}, sink = null) {
15363
15416
  annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
15364
15417
  }, wrap(async (a) => {
15365
15418
  const text = String(a.text || '').trim(); if (!text) return { content: [{ type: 'text', text: 'Nothing to remember — pass text.' }], isError: true };
15419
+ { const v = memoryNoteVerdict(text); if (!v.ok) return { content: [{ type: 'text', text: v.message }], isError: true }; }
15366
15420
  let list = await readStore('heist.memory.v1'); if (!Array.isArray(list)) list = [];
15367
15421
  if (list.some(m => m && String(m.text || '').toLowerCase() === text.toLowerCase())) return ok(`Already in memory: “${text}”.`, { ok: true });
15368
15422
  const item = { id: newId('m'), text, category: String(a.category || 'General').trim() || 'General', source: 'mcp', createdAt: Date.now() };
@@ -15392,6 +15446,30 @@ function buildTools(rawServer, opts = {}, sink = null) {
15392
15446
  // died inside one conversation, while the web user's own board sat untouched beside them.
15393
15447
  // These are typed writers on purpose (see the store_set note above): they mint ids, de-dupe on the ad `key`, respect
15394
15448
  // the per-store caps and write the two-level tombstone a genuine delete needs.
15449
+ server.registerTool('tidy_memory', {
15450
+ title: 'Tidy the Memory list',
15451
+ description: 'Clean up and consolidate the workspace Memory: drops entries that are about how Hermoso, a tool, a connector or a platform API behaves (product behaviour, not the brand), drops phone numbers and emails, and merges near-duplicate facts into one sentence each. Call with no argument to get the PROPOSAL (what would be removed and merged, with reasons) — nothing changes. Call again with confirm:true to apply it through the same typed writers the app uses (deletes carry tombstones so they stay deleted on every device). One small model call; a few credits.',
15452
+ inputSchema: { confirm: z.boolean().optional().describe('true to APPLY the proposal; omit to only see it') },
15453
+ outputSchema: { scanned: z.number().optional(), remove: z.array(z.any()).optional(), merge: z.array(z.any()).optional(), applied: z.boolean().optional() },
15454
+ annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false },
15455
+ }, wrap(async (a) => {
15456
+ let list = await readStore('heist.memory.v1'); if (!Array.isArray(list)) list = [];
15457
+ if (!list.length) return ok('Memory is empty — nothing to tidy.', { scanned: 0, remove: [], merge: [], applied: false });
15458
+ const r = await apiPost('/api/memory/tidy', { items: list.map(m => ({ id: m.id, text: m.text, category: m.category })) });
15459
+ const d = r?.data || { remove: [], merge: [] };
15460
+ const byId = new Map(list.map(m => [String(m.id), m]));
15461
+ const lines = [];
15462
+ for (const x of d.remove || []) lines.push(`- remove [${x.reason}]: ${String(byId.get(x.id)?.text || x.id).slice(0, 140)}`);
15463
+ for (const m of d.merge || []) lines.push(`- merge ${m.ids.length} → “${m.text.slice(0, 140)}”`);
15464
+ if (!lines.length) return ok(`Scanned ${list.length} memories — nothing to remove or merge.`, { scanned: list.length, remove: [], merge: [], applied: false });
15465
+ if (!a.confirm) return ok(`Proposal over ${list.length} memories (nothing changed — call again with confirm:true to apply):\n${lines.join('\n')}`, { scanned: list.length, remove: d.remove, merge: d.merge, applied: false });
15466
+ const gone = new Set([...(d.remove || []).map(x => String(x.id)), ...(d.merge || []).flatMap(m => m.ids.map(String))]);
15467
+ for (const id of gone) await tombstone('heist.memory.v1', id);
15468
+ const merged = (d.merge || []).map(m => ({ id: newId('m'), text: m.text, category: m.category || 'General', source: 'tidy', createdAt: Date.now() }));
15469
+ await writeStore('heist.memory.v1', [...merged, ...list.filter(m => m && !gone.has(String(m.id)))].slice(0, 200));
15470
+ return ok(`Tidied: removed ${gone.size - (d.merge || []).flatMap(m => m.ids).length}, merged ${(d.merge || []).length} group(s). ${list.length} → ${list.length - gone.size + merged.length} memories.\n${lines.join('\n')}`, { scanned: list.length, remove: d.remove, merge: d.merge, applied: true });
15471
+ }));
15472
+
15395
15473
  const SWIPE_KEY = 'adInspo.swipefile.v1';
15396
15474
  const swipeEmpty = () => ({ collections: [{ id: 'default', name: 'My Swipefile' }], activeId: 'default', ads: [] });
15397
15475
  // Mirror the webapp's Swipe.read(): anything that isn't the {collections[], ads[]} shape is treated as an empty
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "hermoso",
3
- "version": "0.1.189",
3
+ "version": "0.1.192",
4
4
  "mcpName": "io.github.hermoso-ai/hermoso",
5
- "description": "AI ad studio and marketing MCP server with 745 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. 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.",
5
+ "description": "AI ad studio and marketing MCP server with 746 tools. Research the ads already running in any market, generate finished image, video and UGC avatar ads, publish and schedule them to your own channels, build and manage the ad campaigns behind them, and read what they achieved. 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",
7
7
  "bin": {
8
8
  "hermoso": "bin/hermoso.mjs"