hermoso 0.1.345 → 0.1.347
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/mcp/http.mjs +8 -2
- package/mcp/tools.mjs +12 -5
- package/package.json +1 -1
package/mcp/http.mjs
CHANGED
|
@@ -461,6 +461,12 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
461
461
|
|
|
462
462
|
const sid = req.headers['mcp-session-id'];
|
|
463
463
|
let entry = sid ? sessions.get(sid) : null;
|
|
464
|
+
// A CONNECT IS COMPLETE WHEN THE NEW TOKEN IS FIRST USED, WHATEVER THE METHOD (2026-09-29). This used to be reported on
|
|
465
|
+
// a session's first tools/list, and ChatGPT never lists on a reconnect — it opens each turn as initialize →
|
|
466
|
+
// initialized → GET → tools/call against the roster it already holds — so every ChatGPT sign-in since 09-25 read as
|
|
467
|
+
// "no completed connect" and paged. Reported once per session (and on each session-less request, which is rare); the
|
|
468
|
+
// watch matches it against the tokens it saw minted (lib/mcp-connect-watch.mjs) and ignores any other key.
|
|
469
|
+
if (!entry || !entry.connectSeen) { if (entry) entry.connectSeen = true; connectEvent(req, { step: 'mcp-use', ok: true, tokenHash: createHash('sha256').update(token).digest('hex') }); }
|
|
464
470
|
|
|
465
471
|
if (!entry) {
|
|
466
472
|
const init = req.method === 'POST' && hasInitialize(req.body);
|
|
@@ -516,7 +522,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
516
522
|
// WHO IS CALLING, captured at the ONE moment it is on the wire. `clientInfo` rides the `initialize`
|
|
517
523
|
// request and nothing afterwards, so it has to be remembered on the session or it is gone by the first
|
|
518
524
|
// tools/call. It is advisory only: it may not change auth, scope or spend — it decides PRESENTATION.
|
|
519
|
-
entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0 };
|
|
525
|
+
entry = { transport, server, user, lastSeen: Date.now(), client: rememberedClient(req), ua: String(req.headers['user-agent'] || '').slice(0, 120), src: srcOf(req), startedAt: Date.now(), listed: false, calls: 0, connectSeen: true };
|
|
520
526
|
if (typeof onSessionStart === 'function') { try { onSessionStart({ accountId: user.accountId || null, userId: user.userId || null, client: entry.client, ua: entry.ua, src: entry.src }); } catch {} }
|
|
521
527
|
// LOG THE NAME. `hostRendersWidgets()` matches it with a regex, and a regex over a string no one has
|
|
522
528
|
// ever read is a guess. One line per session (not per call) so a new host identifies itself once and
|
|
@@ -550,7 +556,7 @@ const inflightNameOf = (body) => { const msgs = Array.isArray(body) ? body : [bo
|
|
|
550
556
|
// Counted here, before the transport sees the body, so the tally is of what the CLIENT asked and not of what
|
|
551
557
|
// the SDK answered — a refused tools/call is still a call the roster earned.
|
|
552
558
|
for (const m of methodsOf(req.body)) {
|
|
553
|
-
if (m === 'tools/list')
|
|
559
|
+
if (m === 'tools/list') entry.listed = true; // the completed connect is reported above, on the token's first use of any kind
|
|
554
560
|
else if (m === 'tools/call') {
|
|
555
561
|
entry.calls = (entry.calls || 0) + 1;
|
|
556
562
|
// 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
|
@@ -18450,7 +18450,12 @@ function memoryNoteVerdict(text) {
|
|
|
18450
18450
|
outputSchema: { ok: z.boolean().optional(), id: z.string().optional(), creator: z.any().optional(), refQuality: z.any().optional() },
|
|
18451
18451
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false },
|
|
18452
18452
|
}, wrap(async (a) => {
|
|
18453
|
-
const name = String(a.name || '').trim()
|
|
18453
|
+
const name = String(a.name || '').trim();
|
|
18454
|
+
// A LINK TO A PHOTO'S WEB PAGE (an Unsplash / Pexels / Pinterest page, 2026-09-29) is saved as the PICTURE behind it,
|
|
18455
|
+
// so the cast never holds a page no render can read; a page with no reachable picture is refused here, saving nothing.
|
|
18456
|
+
const resolveLink = async (u) => (/^https?:\/\//i.test(u) ? ((await apiPost('/api/ref/resolve', { url: u }))?.url || u) : u);
|
|
18457
|
+
const image = await resolveLink(String(a.image || '').trim());
|
|
18458
|
+
if (Array.isArray(a.poses)) a.poses = await Promise.all(a.poses.filter((p) => typeof p === 'string' && p.trim()).slice(0, 4).map((p) => resolveLink(p.trim())));
|
|
18454
18459
|
// A PHOTO FLAGGED TOO UNCLEAR TO CAST (lowQualityRef, 2026-09-22) is REPLACED in place by a new portrait under the
|
|
18455
18460
|
// same name — scored by the same rule the onboarding used — or accepted as it is with useAnyway. A twin row would
|
|
18456
18461
|
// make the creator's own cast ambiguous (two rows named as the brand → nobody is cast).
|
|
@@ -20193,11 +20198,12 @@ function memoryNoteVerdict(text) {
|
|
|
20193
20198
|
// the engine; this wrapper only fills the brand's product photo. Held out of the default list on size (ON_DEMAND_TOOLS).
|
|
20194
20199
|
server.registerTool('recast_hook', {
|
|
20195
20200
|
title: 'Recast a viral hook with your subject',
|
|
20196
|
-
description: "RECAST A VIRAL HOOK so the brand's own product or saved creator is its subject (the egg that rolls off the table becomes your product rolling off the same table; the viral move is done by your host), then join it into your clip so the cut links by identity. hook: a public TikTok / Reel / Facebook / X / YouTube post link or a video file (a post link uses its one lookup). Subject: creator (a saved creator's name or id), image (a picture of a product or a person; a real person's photo confirms you have their consent, and it is logged), or neither for the brand's first product photo. mode 'auto' (default): 'swap' replaces the hook's subject inside its own scene with a region edit (camera, set, light, timing, on-screen text and sound kept), 'motion' makes your person perform the hook's moves (motion transfer; needs a person in the hook). A face in the hook or as the subject routes the swap to an editor that takes faces, found by a free local face check. swap: your own words for what changes ('the egg becomes our serum bottle'; look with video_frames first). start/end pick the window (3 s minimum; default the first 10 s). PAID and QUOTED FIRST: without confirm it returns the plan and the credits and renders nothing; call again with confirm:true once the user okays it. Returns one job: get_job until done, then post_edit with videoUrl = the recast hook and ops [{op:'join', clips:[{url: your clip}], bridge:{kind:'impact'}}].",
|
|
20201
|
+
description: "RECAST A VIRAL HOOK so the brand's own product or saved creator is its subject (the egg that rolls off the table becomes your product rolling off the same table; the viral move is done by your host), then join it into your clip so the cut links by identity. hook: a public TikTok / Reel / Facebook / X / YouTube post link or a video file (a post link uses its one lookup). Subject: creator (a saved creator's name or id), image (a picture of a product or a person; a real person's photo confirms you have their consent, and it is logged), or neither for the brand's first product photo. A DUO OR GROUP (2-4 people swapped in one pass, e.g. both performers of a duet): subjects [{creator or image, position: 'left'|'right'|'center' or your own words, refs?}], one per person; each is bound to its place in the frame and keeps that performer's mouth, head and hand movement and timing on the original sound, and every real face is checked like a single one. Up to 15 s per pass: a longer hook is recast in a 15 s window and the reply says which. mode 'auto' (default): 'swap' replaces the hook's subject inside its own scene with a region edit (camera, set, light, timing, on-screen text and sound kept), 'motion' makes your person perform the hook's moves (motion transfer; needs a person in the hook). A face in the hook or as the subject routes the swap to an editor that takes faces, found by a free local face check. swap: your own words for what changes ('the egg becomes our serum bottle'; look with video_frames first). start/end pick the window (3 s minimum; default the first 10 s). PAID and QUOTED FIRST: without confirm it returns the plan and the credits and renders nothing; call again with confirm:true once the user okays it. Returns one job: get_job until done, then post_edit with videoUrl = the recast hook and ops [{op:'join', clips:[{url: your clip}], bridge:{kind:'impact'}}].",
|
|
20197
20202
|
inputSchema: {
|
|
20198
20203
|
hook: z.string().describe('the viral hook: a post link or a video URL'),
|
|
20199
20204
|
creator: z.string().optional().describe("a saved creator's name or id (list_creators)"),
|
|
20200
20205
|
image: z.string().optional().describe('a picture of the subject instead: a product or a person'),
|
|
20206
|
+
subjects: z.array(z.object({ creator: z.string().optional().describe("a saved creator's name or id"), image: z.string().optional().describe('or a picture of this person'), position: z.string().optional().describe("where they are in the hook: 'left', 'right', 'center', or your own words ('who raps first'); all or none"), refs: z.array(z.string()).optional().describe("up to 3 more views of the SAME person (a saved creator's pose plates ride automatically)"), name: z.string().optional() }).passthrough()).max(4).optional().describe('a duo or group recast, 2-4 people, instead of creator/image'),
|
|
20201
20207
|
swap: z.string().optional().describe('what changes, in your own words'),
|
|
20202
20208
|
mode: z.enum(['auto', 'swap', 'motion']).optional(),
|
|
20203
20209
|
start: z.number().optional(), end: z.number().optional(),
|
|
@@ -20211,12 +20217,13 @@ function memoryNoteVerdict(text) {
|
|
|
20211
20217
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
20212
20218
|
}, wrap(async (a) => {
|
|
20213
20219
|
let b = {};
|
|
20214
|
-
|
|
20220
|
+
const subs = Array.isArray(a.subjects) ? a.subjects.filter(Boolean) : [];
|
|
20221
|
+
if (a.useBrand !== false && !a.creator && !a.image && !subs.length) { try { b = (await readStore('heist.brand.v1')) || {}; } catch { b = {}; } }
|
|
20215
20222
|
// a person's photo as the subject is recorded as implied likeness consent SERVER-side, only when the free face check finds a face there (a product photo is not a likeness)
|
|
20216
|
-
const body = { hook: a.hook, ...(a.creator ? { creator: a.creator } : {}), ...(a.image ? { image: a.image } : {}), ...(a.swap ? { swap: a.swap } : {}), ...(a.mode && a.mode !== 'auto' ? { mode: a.mode } : {}), ...(a.start != null ? { start: a.start } : {}), ...(a.end != null ? { end: a.end } : {}), ...(a.prompt ? { prompt: a.prompt } : {}), ...(a.model ? { model: a.model } : {}), ...(a.tier ? { tier: a.tier } : {}), ...(b && Array.isArray(b.productImages) && b.productImages.length ? { brand: { name: b.name || '', product: b.sells || b.name || '', productImages: b.productImages.slice(0, 1) } } : {}), ...(a.confirm === true ? { confirm: true } : {}) };
|
|
20223
|
+
const body = { hook: a.hook, ...(a.creator ? { creator: a.creator } : {}), ...(a.image ? { image: a.image } : {}), ...(subs.length ? { subjects: subs } : {}), ...(a.swap ? { swap: a.swap } : {}), ...(a.mode && a.mode !== 'auto' ? { mode: a.mode } : {}), ...(a.start != null ? { start: a.start } : {}), ...(a.end != null ? { end: a.end } : {}), ...(a.prompt ? { prompt: a.prompt } : {}), ...(a.model ? { model: a.model } : {}), ...(a.tier ? { tier: a.tier } : {}), ...(b && Array.isArray(b.productImages) && b.productImages.length ? { brand: { name: b.name || '', product: b.sells || b.name || '', productImages: b.productImages.slice(0, 1) } } : {}), ...(a.confirm === true ? { confirm: true } : {}) };
|
|
20217
20224
|
const r = await apiPost('/api/hooks/recast', body);
|
|
20218
20225
|
const p = r?.data || r;
|
|
20219
|
-
const head = `${p.mode === 'motion' ? 'Motion transfer' : 'Swap'} on ${p.engine}: ${p.hook?.window?.seconds}s of the hook (${p.hook?.window?.start}-${p.hook?.window?.end}s of ${p.hook?.durationSeconds}s). Subject: ${p.subject?.kind}${p.subject?.name ? ` "${p.subject.name}"` : ''}. Face in the hook: ${p.hook?.face}; in the subject: ${p.subject?.face}. Why: ${p.why}.${p.instruction ? `\nInstruction: ${p.instruction}` : ''}${p.consent ? `\n${p.consent}` : ''}`;
|
|
20226
|
+
const head = `${p.mode === 'motion' ? 'Motion transfer' : 'Swap'} on ${p.engine}: ${p.hook?.window?.seconds}s of the hook (${p.hook?.window?.start}-${p.hook?.window?.end}s of ${p.hook?.durationSeconds}s). ${Array.isArray(p.subjects) ? `Subjects: ${p.subjects.map((x) => `${x.element} ${x.position}${x.name ? ` "${x.name}"` : ''} (${x.kind}${x.refs ? `, ${x.refs} more view${x.refs === 1 ? '' : 's'}` : ''})`).join('; ')}` : `Subject: ${p.subject?.kind}${p.subject?.name ? ` "${p.subject.name}"` : ''}`}. Face in the hook: ${p.hook?.face}; in the subject: ${p.subject?.face}. Why: ${p.why}.${p.windowNote ? `\n${p.windowNote}` : ''}${p.instruction ? `\nInstruction: ${p.instruction}` : ''}${p.consent ? `\n${p.consent}` : ''}`;
|
|
20220
20227
|
if (!p.queued) return { content: [{ type: 'text', text: `QUOTE (nothing rendered, nothing charged): about ${p.credits} credits.\n${head}\nTell the user the price; after they okay it, call recast_hook again with the same inputs and confirm:true.` }], structuredContent: { plan: p, credits: p.credits, queued: false } };
|
|
20221
20228
|
return { content: [{ type: 'text', text: `Recast queued as job ${p.job?.id} (about ${p.credits} credits).\n${head}\nCall get_job until it reports done; it has no file until then. ${p.next || ''}` }], structuredContent: { plan: p, job: p.job, credits: p.credits, queued: true } };
|
|
20222
20229
|
}));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "hermoso",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.347",
|
|
4
4
|
"mcpName": "io.github.hermoso-ai/hermoso",
|
|
5
5
|
"description": "Marketing on autopilot, run from your own AI agent. 864 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",
|