sapiens-mcp 1.69.2 → 1.71.0

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.
@@ -0,0 +1,41 @@
1
+ // ============================================
2
+ // QUEM É O CLIENT — o motor que a pessoa usa do outro lado do MCP.
3
+ //
4
+ // O backend quer saber por onde um personagem nasceu ou foi ativado ("MCP
5
+ // (Claude)" em vez de "MCP (agente)" no alerta do dono). Dois transportes,
6
+ // duas fontes:
7
+ // - stdio: o handshake `initialize` traz clientInfo {name, version} e o
8
+ // Server do SDK guarda em getClientVersion(). É a fonte boa: "claude-ai",
9
+ // "claude-code", "cursor", "gemini-cli"...
10
+ // - remoto (streamable HTTP, stateless na Vercel): cada request é um Server
11
+ // novo e o initialize não fica. Sobra o User-Agent do HTTP, que viaja com
12
+ // o prefixo "ua:" pro backend saber que é pista, não nome.
13
+ // O valor cru vai pro backend, que rotula (convex/shared/characterOrigin.ts).
14
+ // Nunca é identidade: a identidade é sempre o bearer/login.
15
+ // ============================================
16
+ import { AsyncLocalStorage } from "node:async_hooks";
17
+ const MAX = 120;
18
+ const requestClient = new AsyncLocalStorage();
19
+ /** Limpa o que veio de fora: uma linha, sem caractere de controle, teto de 120. */
20
+ export function sanitizeClientName(raw) {
21
+ if (typeof raw !== "string")
22
+ return undefined;
23
+ const clean = raw
24
+ .replace(/[\u0000-\u001f\u007f]+/g, " ")
25
+ .replace(/\s+/g, " ")
26
+ .trim();
27
+ if (!clean)
28
+ return undefined;
29
+ return clean.slice(0, MAX);
30
+ }
31
+ /** Embrulha a chamada com a pista do client (call do stdio ou request remota). */
32
+ export function runWithClientHint(hint, fn) {
33
+ const clean = sanitizeClientName(hint);
34
+ if (!clean)
35
+ return fn();
36
+ return requestClient.run(clean, fn);
37
+ }
38
+ /** A pista do client da chamada em curso, se houver. */
39
+ export function getClientName() {
40
+ return requestClient.getStore();
41
+ }
package/dist/index.js CHANGED
@@ -13,6 +13,7 @@ import { getCachedTier, onTierVisibilityChange, probeTierInBackground, } from ".
13
13
  import { getPrompt, listPrompts } from "./prompts.js";
14
14
  import { listSkillResources, readSkillResource } from "./skills.js";
15
15
  import { convexMutation, getSessionToken } from "./convexClient.js";
16
+ import { runWithClientHint } from "./clientIdentity.js";
16
17
  // Telemetria stdio (fire-and-forget, NUNCA derruba a chamada). O stdio sempre
17
18
  // foi caixa-preta (cada cliente roda a própria cópia via npx), então o
18
19
  // transporte que mais roda — Helen/Cursor/Gemini CLI — era invisível na
@@ -77,7 +78,9 @@ server.setRequestHandler(CallToolRequestSchema, async (req) => {
77
78
  const name = req.params.name;
78
79
  const args = (req.params.arguments ?? {});
79
80
  const t0 = Date.now();
80
- const result = await callTool(name, args);
81
+ // O client do handshake (clientInfo.name) viaja com a chamada: é o que
82
+ // deixa o backend dizer "MCP (Claude)" em vez de "MCP (agente)".
83
+ const result = await runWithClientHint(server.getClientVersion()?.name, () => callTool(name, args));
81
84
  logStdioUsage({
82
85
  tool: name,
83
86
  action: typeof args.action === "string" ? args.action : null,
package/dist/registry.js CHANGED
@@ -76,7 +76,7 @@ export const TOOLS = {
76
76
  handler: repertorio,
77
77
  },
78
78
  sapiens_gallery: {
79
- description: "Browse, publicação e upload das imagens do user. Sub-actions: list (últimas N peças, com prompt/model/url + isPublic + acervoUrl; kind=image por default, kind=video traz os takes com posterUrl, a capa, e kind=all mistura. Depois de ingerir ou gerar vídeo, é o list kind=video que dá a capa pra mostrar miniatura + player em vez de link solto), get (1 imagem com metadados, opcionalmente base64), publish (torna a PRÓPRIA imagem pública: entra na galeria pública + feed Pinterest, e ganha página indexável /imagem/<id> se o modelo não for degen devolve publicPageUrl; publicado NÃO despublica, não existe unpublish), upload (traz pra galeria uma peça gerada FORA da casa, por sourceUrl https, filePath local ou base64; ADMIN por ora), ingest (peça que a CASA dirigiu e um motor externo só renderizou: entra NATIVA na timeline e nasce PRIVADA (isPublic false), publicável DEPOIS pelo dono como qualquer geração da casa, nunca sozinha, com custo 0 em Sinapses e o custo real guardado em externalCost; carrega o prompt inteiro, as referências da casa (referenceImageIds) e o personagem (characterId), e é idempotente por sourceUrl, então re-ingerir corrige a ficha em vez de duplicar; ADMIN), refs (acopla peças da casa como REFERÊNCIA numa peça que já existe, de upload ou de ingest; ADMIN, e só na própria peça), cast (QUEM ESTÁ EM CENA numa peça que já existe, quando é mais de uma criatura: characterIds em ordem, o protagonista primeiro, e a peça passa a contar na ficha de todos eles em vez de sumir da segunda; qualquer membro, na própria peça). Use list/get pra reusar imagem como referência (passe o imageId em sapiens_image mode=edit ou mode=variation) ou pra mostrar pro user o que ele já tem; publish quando o user quer divulgar a imagem dele. IMPORTANTE sobre upload: a peça entra PRIVADA e continua privada — o servidor recusa publicar e recusa compartilhar na comunidade, porque ela é do usuário e a responsabilidade é dele. O caminho pra ela virar coisa pública é usar como referência numa geração daqui e publicar o resultado. O refs escreve só proveniência (de que ficha/folha a peça nasceu): não promove nada, e um upload continua sem publicar depois de ganhar referência.",
79
+ description: "Browse, publicação e upload das imagens do user. Sub-actions: list (últimas N peças, com prompt/model/url + isPublic + acervoUrl; kind=image por default, kind=video traz os takes com posterUrl, a capa, e kind=all mistura. Depois de ingerir ou gerar vídeo, é o list kind=video que dá a capa pra mostrar miniatura + player em vez de link solto), get (1 imagem com metadados, opcionalmente base64), publish (torna a PRÓPRIA imagem pública: entra na galeria pública + feed Pinterest, e ganha página indexável /imagem/<id> se o modelo não for degen devolve publicPageUrl; publicado NÃO despublica, não existe unpublish), upload (traz pra galeria uma peça gerada FORA da casa, por sourceUrl https, filePath local ou base64; ADMIN por ora), ingest (A PONTE, qualquer conta logada: peça que a PESSOA dirigiu com a ficha do personagem daqui e um motor de fora renderizou, no crédito dela (Kie, fal, Magnific, Sogni, a própria placa): entra NATIVA na timeline e nasce PRIVADA (isPublic false), publicável DEPOIS por ela como qualquer geração da casa, nunca sozinha, com custo 0 em Sinapses e o custo real guardado em externalCost; carrega o prompt VERBATIM, as referências da casa (referenceImageIds), o personagem (characterId) e o motor (externalEngine 'provedor-motor'); a peça chega por filePath (local, só no MCP instalado), sourceUrl (o render, baixado pelo MCP na máquina da pessoa, nunca pelo servidor) ou base64; o carimbo +18 nasce na entrada pela CLASSE do provedor (peso aberto como Sogni, Replicate, HF, fal, WaveSpeed, Civitai e a própria placa entram carimbados, só a curadoria tira; serviço com moderação como Kie, Magnific, Freepik, Krea entra sem) ou por unfiltered=true dito pela pessoa (carimbo dela, ela tira depois); idempotente por sourceUrl ou pelo hash do arquivo, então re-ingerir corrige a ficha em vez de duplicar; tetos 12 MB imagem, 60 MB vídeo, 40 por dia. A chave do provedor NUNCA passa por aqui: quem gerou foi o harness da pessoa. Passo a passo na skill 'pontes'), refs (acopla peças da casa como REFERÊNCIA numa peça que já existe, de upload ou de ingest; ADMIN, e só na própria peça), cast (QUEM ESTÁ EM CENA numa peça que já existe, quando é mais de uma criatura: characterIds em ordem, o protagonista primeiro, e a peça passa a contar na ficha de todos eles em vez de sumir da segunda; qualquer membro, na própria peça). Use list/get pra reusar imagem como referência (passe o imageId em sapiens_image mode=edit ou mode=variation) ou pra mostrar pro user o que ele já tem; publish quando o user quer divulgar a imagem dele. IMPORTANTE sobre upload: a peça entra PRIVADA e continua privada: o servidor recusa publicar e recusa compartilhar na comunidade, porque ela é do usuário e a responsabilidade é dele. O caminho pra ela virar coisa pública é usar como referência numa geração daqui e publicar o resultado. O refs escreve só proveniência (de que ficha/folha a peça nasceu): não promove nada, e um upload continua sem publicar depois de ganhar referência. COMO A PEÇA FOI FEITA: list aceita skill=<slug> e devolve só o que saiu daquela skill (o mesmo slug que sapiens_image gravou em skills), com a busca varrendo as últimas 200 peças antes de cortar no limit; cada item volta com o campo skills, e null ali quer dizer que ninguém marcou, não que foi feita sem skill.",
80
80
  schema: gallerySchema,
81
81
  handler: gallery,
82
82
  },
@@ -236,26 +236,27 @@ export const TOOLS = {
236
236
  // É a "skill que anda junto com o pacote": escrevo uma vez, vale pra todos os clients,
237
237
  // sem instalar nada. Cobre os tropeços reais (fluxo do musicator, model no vídeo, o
238
238
  // disjuntor anti-loop). Mantém curto de propósito: viaja em todo handshake.
239
- export const SAPIENS_INSTRUCTIONS = `Você opera o Sapiens Sintéticos (sapiensinteticos.com) NA CONTA de um usuário logado. Cada tool age de verdade na conta dele e muitas COBRAM Sinapses (o crédito da casa). Aja como operador, não no chute.
240
-
241
- AS SKILLS DA CASA (leia ANTES de operar, não improvise o fluxo):
242
- O passo a passo travado de cada fluxo mora na tool sapiens_skill, servida por este mesmo servidor: sem login, sem custo, sem rede. Ler a skill é mais barato que errar uma geração que cobra.
243
- - sapiens_skill action=list -> o índice (slug + quando usar cada uma).
244
- - sapiens_skill action=get name=<slug> -> a skill inteira.
245
- Slugs: ${skillMenuLine()}
246
- Puxe a skill ANTES de: gerar música ou efeito sonoro (musica), escolher modelo de vídeo (video), gerar imagem (imagem), criar personagem ou prompt de Midjourney (personagem), criar na identidade do usuário (studio), postar no Fórum ou no chat (forum-comunidade), redigir qualquer texto publicável (voz-da-casa), incorporar o Sintético (companhia), missão de Desafio (trilhas), montar currículo (curriculo), guardar obra ou ferramenta no acervo (repertorio). Perdido no começo: primeiros-passos.
247
- Cliente que lê resources MCP acha o MESMO conteúdo em skill://sapiens/<slug>/SKILL.md.
248
-
249
- REGRA DE OURO:
250
- - PRIMEIRO CONTATO ou "o que você faz?"/"como começo?"/"o que dá pra fazer?": chame sapiens_meta action=start e MOSTRE o resultado na sua voz. Sem login, ele ensina a conectar; logado, traz saldo + primeiros poderes com exemplos. É a porta de entrada: não despeje a lista inteira de tools, deixe o start guiar.
251
- - LOGO APÓS UM LOGIN BEM-SUCEDIDO (action=login retornou ok): chame action=start na sequência e mostre a porta de entrada. O recém-chegado não sabe o que pedir; não o deixe na tela em branco, guie a primeira jogada sem ele precisar perguntar.
252
- - Antes de gerar algo caro (imagem/música/vídeo), cheque saldo: sapiens_meta action=credits (ou subscription). Saldo baixo, avise o usuário antes. Vídeo é o mais caro da casa: confirme com ele antes de disparar.
253
- - REGRA DO TIMEOUT: geração SÍNCRONA pode estourar o teto de ~120s do cliente e voltar 'Timeout' MESMO tendo gerado e COBRADO. Nunca repita às cegas: confira antes onde o resultado cairia (a tabela de onde conferir está na skill 'primeiros-passos').
254
- - "sessionToken expirado" = refaça login: sapiens_meta action=login com o código de sapiensinteticos.com/conectar-claude.
255
- - sapiens_meta action=formats devolve os schemas por formato; action=whoami diz tier (user/admin) + saldo. sapiens_image e sapiens_video action=models trazem o catálogo com o preço ATUAL: consulte em vez de chutar custo.
256
-
257
- MODO COMPANHIA: sapiens_meta action=start e action=whoami podem trazer um bloco 'companion'. Quando vier companion.active=true, você INCORPORA aquele Sintético (a voz dele, o avatar, o oi dele) continuando a operar na conta e nas Sinapses do USUÁRIO. Puxe a skill 'companhia' antes de fazer isso. Se vier 'characterOffer', siga o que ele manda: são personagens que o usuário escreveu e que podem entrar em cena, e quem OFERECE é você, na hora em que a conversa encostar num deles. Só personagem de autoria dele; a alma de personagem alheio não sai por aqui.
258
-
239
+ export const SAPIENS_INSTRUCTIONS = `Você opera o Sapiens Sintéticos (sapiensinteticos.com) NA CONTA de um usuário logado. Cada tool age de verdade na conta dele e muitas COBRAM Sinapses (o crédito da casa). Aja como operador, não no chute.
240
+
241
+ AS SKILLS DA CASA (leia ANTES de operar, não improvise o fluxo):
242
+ O passo a passo travado de cada fluxo mora na tool sapiens_skill, servida por este mesmo servidor: sem login, sem custo, sem rede. Ler a skill é mais barato que errar uma geração que cobra.
243
+ - sapiens_skill action=list -> o índice (slug + quando usar cada uma).
244
+ - sapiens_skill action=get name=<slug> -> a skill inteira.
245
+ Slugs: ${skillMenuLine()}
246
+ Puxe a skill ANTES de: gerar música ou efeito sonoro (musica), escolher modelo de vídeo (video), gerar imagem (imagem), criar personagem ou prompt de Midjourney (personagem), criar na identidade do usuário (studio), postar no Fórum ou no chat (forum-comunidade), redigir qualquer texto publicável (voz-da-casa), incorporar o Sintético (companhia), missão de Desafio (trilhas), montar currículo (curriculo), guardar obra ou ferramenta no acervo (repertorio), gerar em motor de fora com crédito próprio e trazer a peça pro acervo (pontes). Perdido no começo: primeiros-passos.
247
+ Cliente que lê resources MCP acha o MESMO conteúdo em skill://sapiens/<slug>/SKILL.md.
248
+
249
+ REGRA DE OURO:
250
+ - PRIMEIRO CONTATO ou "o que você faz?"/"como começo?"/"o que dá pra fazer?": chame sapiens_meta action=start e MOSTRE o resultado na sua voz. Sem login, ele ensina a conectar; logado, traz saldo + primeiros poderes com exemplos. É a porta de entrada: não despeje a lista inteira de tools, deixe o start guiar.
251
+ - LOGO APÓS UM LOGIN BEM-SUCEDIDO (action=login retornou ok): chame action=start na sequência e mostre a porta de entrada. O recém-chegado não sabe o que pedir; não o deixe na tela em branco, guie a primeira jogada sem ele precisar perguntar.
252
+ - Antes de gerar algo caro (imagem/música/vídeo), cheque saldo: sapiens_meta action=credits (ou subscription). Saldo baixo, avise o usuário antes. Vídeo é o mais caro da casa: confirme com ele antes de disparar.
253
+ - SEM SALDO, ou motor que a casa não tem: a casa é ponte, não muro. Se a pessoa tem crédito na Kie, na fal, na Magnific, na Sogni ou na própria placa, ela gera LÁ com a ficha do personagem daqui (sapiens_character action=get) e a peça volta pro acervo por sapiens_gallery action=ingest. A chave dela NUNCA passa pelo Sapiens (nem em arg, nem no chat). sapiens_meta action=pontes diz o que ela já tem configurado; skill 'pontes' tem o passo a passo.
254
+ - REGRA DO TIMEOUT: geração SÍNCRONA pode estourar o teto de ~120s do cliente e voltar 'Timeout' MESMO tendo gerado e COBRADO. Nunca repita às cegas: confira antes onde o resultado cairia (a tabela de onde conferir está na skill 'primeiros-passos').
255
+ - "sessionToken expirado" = refaça login: sapiens_meta action=login com o código de sapiensinteticos.com/conectar-claude.
256
+ - sapiens_meta action=formats devolve os schemas por formato; action=whoami diz tier (user/admin) + saldo. sapiens_image e sapiens_video action=models trazem o catálogo com o preço ATUAL: consulte em vez de chutar custo.
257
+
258
+ MODO COMPANHIA: sapiens_meta action=start e action=whoami podem trazer um bloco 'companion'. Quando vier companion.active=true, você INCORPORA aquele Sintético (a voz dele, o avatar, o oi dele) continuando a operar na conta e nas Sinapses do USUÁRIO. Puxe a skill 'companhia' antes de fazer isso. Se vier 'characterOffer', siga o que ele manda: são personagens que o usuário escreveu e que podem entrar em cena, e quem OFERECE é você, na hora em que a conversa encostar num deles. Só personagem de autoria dele; a alma de personagem alheio não sai por aqui.
259
+
259
260
  Voz da casa: 1ª pessoa, direto, anti-corporate, sem travessão. Pra bom entendedor, meia palavra basta. Texto que vai ser publicado pede a skill 'voz-da-casa' antes.`;
260
261
  // Annotations MCP: título humano + dica read-only. São HINTS (não-confiáveis por
261
262
  // spec): quem gateia de verdade continua o servidor (saldo, gate de admin,
package/dist/remote.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createMcpHandler, withMcpAuth } from "mcp-handler";
2
+ import { runWithClientHint } from "./clientIdentity.js";
2
3
  import { CallToolRequestSchema, GetPromptRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ListToolsRequestSchema, ReadResourceRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
3
4
  import { buildToolList, callTool, SAPIENS_INSTRUCTIONS, } from "./registry.js";
4
5
  import { getPrompt, listPrompts } from "./prompts.js";
@@ -263,7 +264,10 @@ export function createSapiensRemoteHandler(opts) {
263
264
  if (!tokenLimiter.hit(token)) {
264
265
  return tooMany("Muitas requisições dessa sessão num minuto. Espere um pouco e tente de novo.");
265
266
  }
266
- return runWithSessionToken(token, () => base(req));
267
+ // Stateless: o initialize não fica entre requests, então a pista do client
268
+ // aqui é o User-Agent (prefixo "ua:" avisa o backend que é pista, não nome).
269
+ const ua = req.headers.get("user-agent") ?? undefined;
270
+ return runWithSessionToken(token, () => runWithClientHint(ua ? `ua:${ua}` : undefined, () => base(req)));
267
271
  };
268
272
  const authed = withMcpAuth(inner, (_req, bearerToken) => {
269
273
  // Shape-check só (sem rede): a validação de VERDADE acontece por request
package/dist/skills.js CHANGED
@@ -729,6 +729,76 @@ A foto NUNCA vai pra banco nenhum, nem da casa nem de terceiro:
729
729
  - Idiomas com \`pct\` honesto (nativo ~100, fluente ~85, intermédio ~55).
730
730
  - Visto/disponibilidade/mudança entram em \`highlight\` (o box perto do contato), não no resumo.`,
731
731
  },
732
+ {
733
+ name: "pontes",
734
+ title: "Pontes: gerar fora com crédito próprio e trazer pro acervo",
735
+ description: "A pessoa tem crédito na Kie, na fal, na Magnific, na Sogni ou na própria placa e quer usar o personagem dela lá, ou está sem Sinapse pra vídeo. Como levar a ficha, gerar no balcão dela (a chave nunca passa pelo Sapiens) e trazer a peça de volta por sapiens_gallery action=ingest, com a ficha inteira. Puxe quando ouvir 'tem Kie aí?', 'uso a fal', 'gastei minhas Sinapses', 'gero na Magnific', 'tenho ComfyUI'.",
736
+ body: `## O que é uma ponte
737
+
738
+ O Sapiens é uma das pontes, não um muro. A pessoa monta o personagem aqui (ficha, passaporte, referências) e gera a peça ONDE TEM CRÉDITO: Kie, fal, Magnific, Sogni, Krea, Replicate, a própria placa. A peça volta pro acervo dela como NATIVA, com motor, prompt, custo real e personagem na ficha, custo 0 em Sinapses. O portfólio dela cresce aqui; o dinheiro dela sai de onde ela já pôs.
739
+
740
+ **A regra da chave, sem exceção:** a chave do provedor NUNCA passa pelo Sapiens. Não vai em arg de tool, não vai em prompt, não vai colada no chat. Ela mora no ambiente da pessoa (variável \`FAL_KEY\`, \`KIE_API_KEY\`, um conector MCP do provedor ligado na conversa, o SDK dela) e quem chama o motor é o harness dela, na máquina dela. O Sapiens entra ANTES (a ficha) e DEPOIS (o ingest). Se a pessoa colar a chave no chat por engano, diga pra ela rotacionar a chave no provedor, e siga sem usar o valor.
741
+
742
+ ## Passo 1: descobrir onde a pessoa tem crédito
743
+
744
+ \`sapiens_meta action=pontes\` (sem login, sem custo) devolve o catálogo de balcões e, no MCP instalado, o que já está configurado no ambiente dela, lido pelo NOME da variável (o valor nunca é lido nem devolvido). No remoto (claude.ai, ChatGPT) ele não olha nada: pergunte.
745
+
746
+ Ordem de preferência quando há mais de uma opção, pelo que custa DE VERDADE pra pessoa:
747
+ 1. O que ela já paga fixo (assinatura Sogni, Magnific com crédito parado, a própria placa): custo marginal zero.
748
+ 2. O balcão onde ela tem crédito pré-pago (Kie, fal, WaveSpeed, Krea, Replicate): custo por peça, em dólar.
749
+ 3. \`sapiens_video\` / \`sapiens_image\` em Sinapse: o único que serve motor exclusivo da casa e o único com a régua de qualidade da casa por trás. Não é o último por ser pior: é o último por ser o mais caro por peça.
750
+
751
+ Confirme com ela ANTES de disparar em qualquer balcão pago. Geração de fora também custa dinheiro real, só que dela.
752
+
753
+ ## Passo 2: a ficha portátil
754
+
755
+ \`sapiens_character action=get characterId=<id>\` (ou \`list_mine\` pra achar o id). O que viaja:
756
+
757
+ - \`passportPrompt\`: o bloco já montado pra colar no prompt do motor (descritor em inglês + negative travado + locks). Cole VERBATIM no começo do prompt; a cena vem depois.
758
+ - \`imageUrls\` e \`mainImageUrl\`: as referências. São URLs públicas do CDN da casa, aceitas direto pelos motores que recebem referência por URL (Kie, fal, Krea). Motor que quer arquivo (ComfyUI, alguns SDKs): baixe a imagem antes.
759
+ - \`passport.recipes\`: as receitas medidas por tipo de peça (\`kind\`, \`engine\`, \`prompt\`, \`note\`). Se existe receita pro motor que ela vai usar, é ELA que vai, verbatim.
760
+ - \`passport.refs\` por papel (rosto, proporção, tatuagem): ref sem papel ninguém sabe usar. Use a de rosto como referência de identidade, a de proporção como frame inicial quando o motor é image-to-video.
761
+
762
+ Regras que valem fora igual dentro: prompt SEM idade em número; personagem nunca menor (a casa trabalha com 23+ no foco e 18 é piso absoluto); motor citado pelo nome que ele tem no provedor. Prompt novo que funcionar lá fora: grave de volta com \`sapiens_character action=set_passport\` (campo \`recipes\`, prompt verbatim + note com o que provou). É isso que faz a próxima rodada não redescobrir.
763
+
764
+ ## Passo 3: gerar lá fora
765
+
766
+ O que a casa MEDIU em cada balcão (o resto está na doc do provedor; não invente parâmetro):
767
+
768
+ | Balcão | Onde a chave mora | O que roda bem | Armadilha medida |
769
+ |---|---|---|---|
770
+ | **Kie** | \`KIE_API_KEY\` | Kling 3.0, Kling Motion, MiniMax/Hailuo, WAN; imagem também | Um endpoint pra tudo: \`POST createTask\` com \`model\` + \`input\`, depois \`recordInfo\` até estado final. HTTP 200 NÃO é sucesso: o \`code\` vem no corpo (422 modelo inexistente, 500 campo faltando, 401 chave). O \`resultJson\` é STRING com JSON dentro (\`resultUrls\` está lá). A URL do resultado expira em 24h e a mídia some em 14 dias: ingira no mesmo turno. Rate limit de 20 criações por 10s por conta. |
771
+ | **fal.ai** | \`FAL_KEY\` | Krea 2 (inclusive sem freio), Kling, WAN, Seedance, Flux | \`POST https://fal.run/<endpoint>\` síncrono com \`Authorization: Key <FAL_KEY>\`, ou a fila em \`queue.fal.run\` pra vídeo. O modelo vai no SLUG do endpoint, não no corpo. Entrega em \`fal.media\`. Slug \`fal-\` na casa entra como classe +18 (sai da vitrine anônima, fica no perfil). |
772
+ | **WaveSpeed** | \`WAVESPEED_API_KEY\` | Flux.2 Klein, Krea 2 Livre, WAN 2.2, Kling, Shot Mimic | É o balcão que a casa mais usa por trás do \`sapiens_video\`; o que a casa serve em Sinapse a pessoa pode rodar direto com a chave dela. Slug \`wavespeed-\` é classe +18 na casa. |
773
+ | **Sogni** | \`SOGNI_API_KEY\` (SDK \`@sogni-ai/sogni-client\`) | MiniMax H3 inteira, WAN 2.2 com animate, LTX 2.3 e 2.5, Krea 2 Turbo, Identity Edit | Assinatura Unlimited cobre o catálogo aberto (\`billingMode: "subscription"\`, custo marginal zero); Seedance e GPT Image saem em Spark comprado. UM socket por conta: um job por vez. Vídeo de 10s+ leva 20 min de parede; rode em background e espere. Matar o processo local NÃO cancela o job (cancele pela API). |
774
+ | **Magnific / Freepik** | conector MCP na conversa (sem chave) | Seedance 2.0 e 2.5 (até 30s e 1080p, mais que a casa), MiniMax H3, upscale, relight | A URL assinada do render expira no mesmo dia: ingira na hora. \`multi_prompt\` junto com \`references\` quebra no Seedance 2.0: plano único com prompt longo. A frase "preserve their identity" é recusada pelo H3 de lá: escreva "keep the same face, body proportions, outfit". |
775
+ | **Krea API** | \`KREA_API_KEY\` | Krea 2 Turbo/Medium/Large, MiniMax H3 Max Turbo, Seedream, Veo, Seedance | Preço fixo por geração, saldo pré-pago separado do app. Filtra NSFW e job que falha (inclusive por moderação) não cobra. Sem img2img no Krea 2 e sem LoRA de fora. |
776
+ | **A própria máquina** | nada (ComfyUI, SD local, runner) | o que a placa aguenta: LTX, Klein, Krea 2 com LoRA, H3 destilado | Custo zero em dinheiro, pago em tempo. A peça entra por \`filePath\`, slug \`bancada-<motor>\` (ex: \`bancada-ltx-2.5\`). Guarde o \`.json\` da geração ao lado do arquivo: é de lá que sai o prompt verbatim e a seed pra ficha. |
777
+
778
+ Não repita geração paga às cegas: se a chamada deu timeout, consulte o job no provedor antes de criar outro. Peça grande (master de 30s em 1080p) reencoda em peso de web ANTES de trazer: \`ffmpeg -i in.mp4 -c:v libx264 -crf 23 -preset slow -movflags +faststart out.mp4\`. Guarde o master local.
779
+
780
+ ## Passo 4: trazer pra casa
781
+
782
+ \`sapiens_gallery action=ingest\`, no MESMO turno do render (URL de render expira):
783
+
784
+ - A peça: \`filePath\` (arquivo local; só no MCP instalado, é a porta de quem gerou na máquina ou baixou antes), ou \`sourceUrl\` (o link do render; quem baixa é o MCP na máquina da pessoa, não o servidor), ou \`base64\` (imagem).
785
+ - \`externalEngine\`: \`provedor-motor\`, sempre. \`kie-kling-3.0\`, \`fal-krea-2\`, \`sogni-minimax-h3\`, \`magnific-seedance-2.5\`, \`bancada-ltx-2.5\`. Marca sozinha é recusada: é este campo que a ficha mostra em "Motor".
786
+ - \`prompt\`: o texto EXATO mandado ao motor. Não é resumo, não é título. É o que permite regerar.
787
+ - \`externalCost\`: na moeda de lá ("420 créditos Kie", "US$ 0,35 na fal", "0, assinatura Sogni", "0, 17 min na RTX 5060 Ti").
788
+ - \`characterId\` (ou \`characterIds\` em ordem de cena quando há mais de uma criatura) e \`referenceImageIds\` (as peças da casa que serviram de ref). Sem os dois a peça entra órfã: publica, mas não conta no personagem.
789
+ - \`aspectRatio\` e \`size\` como saíram do motor.
790
+ - O carimbo +18 nasce na entrada pela CLASSE do provedor, porque a peça já chega renderizada e nenhuma guarda de geração da casa alcança os bytes. Peso aberto (Sogni, Replicate, Hugging Face, fal, WaveSpeed, Civitai, a própria placa) ou provedor que a casa não conhece: a peça entra COM o carimbo, que só a curadoria tira (igual ao motor sem freio da casa). Serviço com moderação (Kie, Magnific, Freepik, faceless, Krea, Midjourney, Runway, Higgsfield): entra SEM carimbo. Nome de motor com \`-spicy\`, \`-nsfw\`, \`-uncensored\` carimba por classe também. \`unfiltered: true\` é a declaração da pessoa pra motor de nome neutro em modo livre num serviço que modera: carimba em nome dela, e ela tira depois na ficha. Carimbo é alcance, não existência: publica, fica no perfil e no link, sai da vitrine anônima. Avise a pessoa quando a peça entrar carimbada por classe: ela precisa saber que a vitrine anônima não vai mostrar.
791
+
792
+ Tetos: 12 MB imagem, 60 MB vídeo por peça; 40 peças por dia e 300 no estoque por conta. Formatos: mp4, mov, png, jpg, webp.
793
+
794
+ O que a peça vira: NATIVA, PRIVADA, publicável depois por \`action=publish\` (ato da pessoa, nunca seu), custo 0 em Sinapses, sem assinatura C2PA (o motor rodou fora). Idempotente: repetir a mesma \`sourceUrl\` (ou o mesmo arquivo) corrige a ficha em vez de duplicar. Depois de ingerir, \`action=list kind=video\` traz a capa e a ficha: mostre a peça, não o link solto. Não apague o arquivo local antes de ver a peça no acervo.
795
+
796
+ ## Quando a ponte NÃO é o caminho
797
+
798
+ - Motor exclusivo da casa: Helen TTS, Musicator, os templates de take (\`templateSlug\`), Sombras, o carimbo assinado. Isso só existe em Sinapse.
799
+ - A pessoa tem Sinapse e o take é curto: \`sapiens_video\` é um comando só, com a régua de qualidade da casa. Menos atrito vence quando o custo cabe.
800
+ - Peça achada pronta na internet, sem direção dela: é \`action=upload\` (privada pra sempre), não ingest.`,
801
+ },
732
802
  ];
733
803
  export const SKILLS = Object.fromEntries(SKILL_LIST.map((s) => [s.name, s]));
734
804
  /** Monta o SKILL.md completo (frontmatter do formato aberto + corpo). */
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { httpUrl } from "../schema.js";
3
3
  import { convexQuery, convexMutation, convexAction, getSessionToken } from "../convexClient.js";
4
+ import { getClientName } from "../clientIdentity.js";
4
5
  /**
5
6
  * sapiens_character — personagens (character sheets) do Sapiens.
6
7
  *
@@ -333,6 +334,7 @@ export async function character(args) {
333
334
  const sessionToken = getSessionToken();
334
335
  return await convexMutation("influencers:mcpCreateCharacter", {
335
336
  sessionToken,
337
+ client: getClientName(),
336
338
  name: args.name,
337
339
  fullName: args.fullName,
338
340
  gender: args.gender,
@@ -441,6 +443,7 @@ export async function character(args) {
441
443
  const sessionToken = getSessionToken();
442
444
  return await convexMutation("influencers:mcpActivateCharacter", {
443
445
  sessionToken,
446
+ client: getClientName(),
444
447
  characterId: args.characterId,
445
448
  });
446
449
  }
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { readFile } from "node:fs/promises";
3
- import { convexAction, convexMutation, getSessionToken, isRemoteContext, } from "../convexClient.js";
3
+ import { createHash } from "node:crypto";
4
+ import { convexAction, convexMutation, describeConvexError, getSessionToken, isRemoteContext, } from "../convexClient.js";
4
5
  /**
5
6
  * Galeria do usuário: browse, publicação e UPLOAD de peça de fora.
6
7
  * Wrapper sobre `desktopMcp.galleryList` / `galleryGet` / `gallerySetPublic` e
@@ -13,18 +14,21 @@ import { convexAction, convexMutation, getSessionToken, isRemoteContext, } from
13
14
  * - Publicar/despublicar a própria imagem (galeria pública + feed Pinterest)
14
15
  * - Subir peça gerada FORA da casa (action=upload), que entra privada e serve
15
16
  * de referência pras gerações daqui
16
- * - Ingerir peça gerada em motor EXTERNO com receita da casa (action=ingest,
17
- * ADMIN), que entra como peça nativa: timeline, publicação, comunidade
17
+ * - Ingerir peça gerada em motor EXTERNO com a receita da pessoa
18
+ * (action=ingest, qualquer conta desde set/2026: a PONTE), que entra como
19
+ * peça nativa: timeline, publicação, comunidade
18
20
  *
19
21
  * O upload resolve URL/arquivo pra base64 AQUI, no cliente, e manda os bytes
20
22
  * prontos pro Convex. O servidor não busca URL nenhuma (sem SSRF novo lá).
21
23
  *
22
- * O INGEST é o oposto e de propósito: quem baixa é o servidor, de uma allowlist
23
- * fechada de hosts (convex/externalIngest.ts), porque a peça vai nascer nativa e
24
- * a origem precisa ser verificável do lado de cá. A diferença entre as duas não
25
- * é de onde vieram os bytes, é de quem foi a DIREÇÃO: peça achada pronta é
26
- * upload; peça que a casa dirigiu (nossa folha, nosso personagem, nosso prompt)
27
- * e um motor terceiro só renderizou é ingest.
24
+ * O INGEST tem duas portas no servidor com o mesmo contrato de peça: a do dono
25
+ * (o servidor baixa de allowlist fechada de hosts, convex/externalIngest.ts) e
26
+ * a ponte (os bytes são resolvidos AQUI, na máquina da pessoa, sobem pro
27
+ * storage do Convex e o servidor só move pro Bunny, convex/externalIngestNode.ts).
28
+ * A diferença entre upload e ingest não é de onde vieram os bytes, é de quem
29
+ * foi a DIREÇÃO: peça achada pronta é upload; peça que a pessoa dirigiu (a
30
+ * ficha do personagem dela, o prompt dela) e um motor terceiro só renderizou é
31
+ * ingest. A chave do provedor nunca passa por aqui nem pelo servidor.
28
32
  */
29
33
  export const gallerySchema = z.object({
30
34
  action: z.enum([
@@ -47,6 +51,10 @@ export const gallerySchema = z.object({
47
51
  .string()
48
52
  .optional()
49
53
  .describe("Filtro de model (action=list). Ex: 'nano-banana-max' pra ver só Pro."),
54
+ skill: z
55
+ .string()
56
+ .optional()
57
+ .describe("Filtro por SKILL que dirigiu a peça (action=list): devolve só o que saiu daquela skill, pelo slug exato que a geração gravou (ex: 'camarim', 'escala-gigantismo'). É o que responde 'me mostra tudo que saiu do camarim'. Com filtro, a busca varre as últimas 200 peças antes de cortar no limit, então acha coisa antiga. Peça sem marcação nenhuma volta com skills null e nunca casa com filtro."),
50
58
  kind: z
51
59
  .enum(["image", "video", "all"])
52
60
  .optional()
@@ -59,23 +67,23 @@ export const gallerySchema = z.object({
59
67
  .boolean()
60
68
  .optional()
61
69
  .describe("Default false. Quando true, action=get devolve os bytes em base64 (pesado, evite em listagens)."),
62
- // --- action=upload (peça de fora) ---
70
+ // --- action=upload e action=ingest: de onde vêm os bytes ---
63
71
  sourceUrl: z
64
72
  .string()
65
73
  .optional()
66
- .describe("action=upload: URL https direta da imagem gerada em outro lugar (ex: o link do render da Magnific). Alternativa a filePath/base64."),
74
+ .describe("action=upload/ingest: URL https direta da peça pronta no motor de fora (o link do render da Kie, da fal, da Magnific). No ingest ela também é o RASTRO e a chave de idempotência: repetir a mesma sourceUrl corrige a ficha em vez de duplicar. Quem baixa é o cliente MCP (na sua máquina, no stdio; no remoto, o servidor do site): URL de render expira (Kie em 24h, Magnific no mesmo dia), então ingira no mesmo turno do render. Alternativa a filePath/base64."),
67
75
  filePath: z
68
76
  .string()
69
77
  .optional()
70
- .describe("action=upload: caminho ABSOLUTO de um arquivo de imagem local. SÓ no MCP instalado (stdio); no remoto use sourceUrl."),
78
+ .describe("action=upload/ingest: caminho ABSOLUTO de um arquivo local (png, jpg, webp, mp4, mov). SÓ no MCP instalado (stdio): é a porta de quem gerou na própria máquina ou baixou o render antes; no remoto use sourceUrl. No ingest, o sha256 do arquivo vira a chave de idempotência."),
71
79
  base64: z
72
80
  .string()
73
81
  .optional()
74
- .describe("action=upload: os bytes já em base64, se você mesmo os tem em mãos."),
82
+ .describe("action=upload/ingest: os bytes já em base64, se você mesmo os tem em mãos (imagem; vídeo vai por filePath ou sourceUrl)."),
75
83
  mimeType: z
76
84
  .string()
77
85
  .optional()
78
- .describe("action=upload: tipo do arquivo (image/png, image/webp...). Detectado sozinho a partir da URL/extensão/resposta HTTP quando omitido."),
86
+ .describe("action=upload/ingest: tipo do arquivo (image/png, video/mp4...). Detectado sozinho a partir da URL/extensão/resposta HTTP quando omitido."),
79
87
  note: z
80
88
  .string()
81
89
  .optional()
@@ -84,7 +92,7 @@ export const gallerySchema = z.object({
84
92
  externalEngine: z
85
93
  .string()
86
94
  .optional()
87
- .describe("action=ingest (ADMIN): qual motor renderizou, sempre no formato provedor-motor ('freepik-seedance-2.0', 'magnific-minimax-h3', 'faceless-seedance-2.0'). É ESTE campo que a ficha da peça mostra em 'Motor', então a marca sozinha ('magnific') é recusada pelo servidor: provedor é a loja, não o motor."),
95
+ .describe("action=ingest: qual motor renderizou, sempre no formato provedor-motor ('kie-kling-3.0', 'fal-krea-2', 'magnific-minimax-h3', 'sogni-minimax-h3', 'bancada-ltx-2.5'). É ESTE campo que a ficha da peça mostra em 'Motor', então a marca sozinha ('kie', 'magnific') é recusada pelo servidor: provedor é a loja, não o motor. O provedor decide o carimbo +18 na entrada: peso aberto (sogni-, replicate-, hf-, fal-, wavespeed-, civitai-, bancada-, comfy-, local-) ou provedor que a casa não conhece entra com o carimbo por classe (publica e fica no perfil, só não vai pra vitrine anônima); serviço com moderação (kie-, magnific-, freepik-, faceless-, krea-, midjourney-, runway-, higgsfield-, modelark-) entra sem."),
88
96
  externalCost: z
89
97
  .string()
90
98
  .optional()
@@ -117,6 +125,10 @@ export const gallerySchema = z.object({
117
125
  .union([z.array(z.string()), z.string()])
118
126
  .optional()
119
127
  .describe("QUEM ESTÁ EM CENA, quando é mais de uma criatura (action=ingest e action=cast). Em ordem de cena: o PRIMEIRO é o protagonista (é ele que a ficha e o cartão do vídeo mostram) e o resto vira elenco, então a peça conta na ficha de TODOS eles em vez de sumir da segunda. Teto de 6. Use quando a fita foi dirigida com duas folhas de personagem: sem isto, ela aparece só na ficha de um. Aceita lista ou os ids separados por vírgula, personagem seu ou público. Pra peça de uma criatura só continue usando characterId."),
128
+ unfiltered: z
129
+ .boolean()
130
+ .optional()
131
+ .describe("action=ingest: true quando o motor rodou SEM filtro de conteúdo (modo livre, spicy, uncensored) num serviço que normalmente modera. A peça nasce com o carimbo +18 da casa DECLARADO por você (você pode tirar depois na ficha): publica, fica no seu perfil e no link direto, e sai da vitrine anônima e dos feeds. Não precisa dizer pra provedor de peso aberto (Sogni, Replicate, Hugging Face, fal, WaveSpeed, Civitai, a própria placa): esses já entram com o carimbo por CLASSE, que só a curadoria tira, igual ao motor sem freio da casa. Serviço com moderação (Kie, Magnific, Freepik, faceless, Krea, Midjourney, Runway, Higgsfield) entra sem carimbo, a menos que você diga isto ou o nome do motor tenha '-spicy', '-nsfw', '-uncensored'."),
120
132
  replace: z
121
133
  .boolean()
122
134
  .optional()
@@ -130,6 +142,13 @@ export const gallerySchema = z.object({
130
142
  // que falhar depois de trafegar o base64 inteiro.
131
143
  const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
132
144
  const FETCH_TIMEOUT_MS = 30_000;
145
+ // Tetos da PONTE (action=ingest pela porta do membro), espelho de
146
+ // convex/shared/ingestRules.ts. O servidor devolve os dele na uploadUrl e é o
147
+ // que manda; estes só recusam antes de trafegar o arquivo.
148
+ const MAX_INGEST_VIDEO_BYTES = 60 * 1024 * 1024;
149
+ const MAX_INGEST_IMAGE_BYTES = 12 * 1024 * 1024;
150
+ // Render de vídeo demora mais pra baixar que uma imagem.
151
+ const INGEST_FETCH_TIMEOUT_MS = 120_000;
133
152
  const EXT_MIME = {
134
153
  png: "image/png",
135
154
  jpg: "image/jpeg",
@@ -137,6 +156,9 @@ const EXT_MIME = {
137
156
  webp: "image/webp",
138
157
  gif: "image/gif",
139
158
  avif: "image/avif",
159
+ // Vídeo entra pela ponte (action=ingest); o upload continua só imagem.
160
+ mp4: "video/mp4",
161
+ mov: "video/quicktime",
140
162
  };
141
163
  function mimeFromPath(p) {
142
164
  const ext = p.split("?")[0].split("#")[0].split(".").pop()?.toLowerCase();
@@ -217,6 +239,101 @@ async function resolveUploadBytes(args) {
217
239
  }
218
240
  throw new Error("action=upload exige a peça: sourceUrl (https), filePath (local, stdio) ou base64.");
219
241
  }
242
+ const AVISO_INGEST = "Peça NATIVA: entra na timeline PRIVADA (isPublic false), igual a qualquer geração da casa. Ela NÃO publica sozinha e NÃO vai pra comunidade: publicar é ato separado do dono, por action=publish. Custo em Sinapses é 0 e ela NÃO leva assinatura C2PA, porque o motor rodou fora. Use ingest só quando a receita (ficha do personagem, prompt, folha) foi sua; peça achada pronta é action=upload. Mostre a peça com action=list kind=video (capa + player) em vez de devolver link solto.";
243
+ /**
244
+ * Os bytes da PONTE, resolvidos aqui (na máquina da pessoa no stdio; no
245
+ * servidor do site no remoto, com o mesmo guard de host público do upload).
246
+ * Devolve também a chave de idempotência: a própria URL quando veio de URL, ou
247
+ * `sha256:<hash>` do conteúdo pra arquivo local e base64, pra o retry corrigir
248
+ * a ficha em vez de duplicar a peça.
249
+ */
250
+ async function resolveIngestBytes(args) {
251
+ const tetoDe = (mime) => mime.startsWith("video/") ? MAX_INGEST_VIDEO_BYTES : MAX_INGEST_IMAGE_BYTES;
252
+ const confereTeto = (bytes, mime, oQue) => {
253
+ const teto = tetoDe(mime);
254
+ if (bytes.byteLength > teto) {
255
+ throw new Error(`${oQue} grande demais: ${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB (teto ${Math.round(teto / 1024 / 1024)} MB). ` +
256
+ (mime.startsWith("video/")
257
+ ? "Reencode em peso de web antes de trazer: ffmpeg -i in.mp4 -c:v libx264 -crf 23 -preset slow -movflags +faststart out.mp4 (um take de 15s em 1080p fica abaixo de 15 MB)."
258
+ : "Exporte em webp ou jpg com qualidade 85."));
259
+ }
260
+ };
261
+ const hexDe = (bytes) => createHash("sha256").update(bytes).digest("hex");
262
+ if (args.base64?.trim()) {
263
+ const mimeType = args.mimeType ||
264
+ args.base64.match(/^data:([^;]+);base64,/)?.[1] ||
265
+ "image/png";
266
+ const bytes = Buffer.from(args.base64.trim().replace(/^data:[^;]+;base64,/, ""), "base64");
267
+ confereTeto(bytes, mimeType, "Peça");
268
+ const hex = hexDe(bytes);
269
+ return { bytes, mimeType, sourceKey: `sha256:${hex}`, sha256: hex };
270
+ }
271
+ if (args.filePath) {
272
+ if (isRemoteContext()) {
273
+ throw new Error("filePath só funciona no MCP instalado (stdio). No remoto, mande sourceUrl.");
274
+ }
275
+ const bytes = await readFile(args.filePath);
276
+ const mimeType = args.mimeType || mimeFromPath(args.filePath) || "image/png";
277
+ confereTeto(bytes, mimeType, "Arquivo");
278
+ const hex = hexDe(bytes);
279
+ return { bytes, mimeType, sourceKey: `sha256:${hex}`, sha256: hex };
280
+ }
281
+ if (args.sourceUrl) {
282
+ const u = assertPublicHttpsUrl(args.sourceUrl);
283
+ const res = await fetch(u, {
284
+ redirect: "follow",
285
+ signal: AbortSignal.timeout(INGEST_FETCH_TIMEOUT_MS),
286
+ });
287
+ if (!res.ok) {
288
+ throw new Error(`Baixar o render falhou: HTTP ${res.status} ${res.statusText}. URL de render expira (Kie em 24h, Magnific no mesmo dia): se passou do prazo, baixe de novo no provedor ou mande o arquivo por filePath.`);
289
+ }
290
+ // Revalida o destino final: um 3xx pra host privado seria o buraco que o
291
+ // assertPublicHttpsUrl da URL inicial não vê.
292
+ if (res.url)
293
+ assertPublicHttpsUrl(res.url);
294
+ const headerMime = res.headers.get("content-type")?.split(";")[0]?.trim();
295
+ const mimeType = args.mimeType || mimeFromPath(u.pathname) || headerMime || "image/png";
296
+ const bytes = Buffer.from(await res.arrayBuffer());
297
+ confereTeto(bytes, mimeType, "Render");
298
+ return { bytes, mimeType, sourceKey: args.sourceUrl, sha256: hexDe(bytes) };
299
+ }
300
+ throw new Error("action=ingest exige a peça: sourceUrl (https), filePath (local, stdio) ou base64.");
301
+ }
302
+ /**
303
+ * A ponte, de ponta a ponta: bytes -> URL assinada do storage -> action Node
304
+ * que move pro Bunny e grava a ficha. Três chamadas, e a chave de provedor não
305
+ * aparece em nenhuma delas, porque o motor já rodou antes, no harness da pessoa.
306
+ */
307
+ async function ingestPelaPonte(sessionToken, args, ficha) {
308
+ const { bytes, mimeType, sourceKey, sha256 } = await resolveIngestBytes(args);
309
+ const porta = await convexMutation("externalIngest:mcpIngestUploadUrl", { sessionToken });
310
+ const teto = mimeType.startsWith("video/")
311
+ ? porta.maxVideoBytes
312
+ : porta.maxImageBytes;
313
+ if (bytes.byteLength > teto) {
314
+ throw new Error(`Peça grande demais pro servidor: ${(bytes.byteLength / 1024 / 1024).toFixed(1)} MB (teto ${Math.round(teto / 1024 / 1024)} MB). Reencode em peso de web e tente de novo.`);
315
+ }
316
+ const subida = await fetch(porta.uploadUrl, {
317
+ method: "POST",
318
+ headers: { "Content-Type": mimeType },
319
+ body: bytes,
320
+ });
321
+ if (!subida.ok) {
322
+ throw new Error(`Subir a peça pro storage falhou: HTTP ${subida.status}. Tente de novo; se repetir, peça outra uploadUrl (ela vale 1h).`);
323
+ }
324
+ const { storageId } = (await subida.json());
325
+ return await convexAction("externalIngestNode:mcpIngestFromStorage", {
326
+ sessionToken,
327
+ storageId,
328
+ mimeType,
329
+ sourceUrl: sourceKey,
330
+ // Prova de posse: o servidor confere contra o sha256 do storage antes de
331
+ // mover ou apagar qualquer byte.
332
+ contentSha256: sha256,
333
+ unfiltered: args.unfiltered,
334
+ ...ficha,
335
+ });
336
+ }
220
337
  /**
221
338
  * Normaliza `referenceImageIds` pro formato que o Convex espera (lista).
222
339
  *
@@ -255,6 +372,7 @@ export async function gallery(args) {
255
372
  sessionToken,
256
373
  limit: args.limit ?? 20,
257
374
  model: args.model,
375
+ skill: args.skill,
258
376
  kind: args.kind,
259
377
  });
260
378
  return {
@@ -303,16 +421,32 @@ export async function gallery(args) {
303
421
  aviso: "Peça enviada de fora: fica privada, é sua e a responsabilidade é sua. Não publica nem vai pra comunidade. Use como referência (sourceImageIds em sapiens_image) e publique o que gerar a partir dela.",
304
422
  };
305
423
  }
424
+ // ingest: a peça que a pessoa DIRIGIU (ficha do personagem, prompt dela) e um
425
+ // motor de fora renderizou entra como NATIVA. Duas portas no servidor, o mesmo
426
+ // contrato de peça:
427
+ //
428
+ // 1. A do dono (`externalIngest:mcpIngestExternal`): o SERVIDOR baixa a URL,
429
+ // só de host liberado, e fita grande que já mora no CDN da casa entra sem
430
+ // trafegar byte. Continua como sempre foi.
431
+ // 2. A ponte (`externalIngestNode:mcpIngestFromStorage`): qualquer conta.
432
+ // Os bytes são resolvidos AQUI, na máquina da pessoa (URL do render,
433
+ // arquivo local ou base64), sobem pro storage do Convex pela URL assinada
434
+ // e o servidor só move pro Bunny. Sem fetch de URL do membro no servidor,
435
+ // sem chave de provedor em lugar nenhum: quem gerou lá fora foi o harness
436
+ // da pessoa, com o crédito dela.
437
+ //
438
+ // Com só a URL em mãos, tenta a porta do dono primeiro (é a que aceita a fita
439
+ // grande do CDN); se o servidor recusar por cargo ou por host, cai pra ponte
440
+ // sem a pessoa precisar saber que existem duas.
306
441
  if (args.action === "ingest") {
307
- if (!args.sourceUrl) {
308
- throw new Error("action=ingest exige sourceUrl: o link https da peça pronta no motor externo. Diferente do upload, aqui é o SERVIDOR que baixa, e só de host liberado.");
309
- }
310
442
  if (!args.externalEngine) {
311
- throw new Error("action=ingest exige externalEngine: qual motor renderizou, no formato provedor-motor ('freepik-seedance-2.0', 'magnific-minimax-h3', 'faceless-seedance-2.0'). É o que a ficha mostra em 'Motor' e o que explica depois por que a peça não tem custo em Sinapses. Marca sozinha ('magnific') não vale.");
443
+ throw new Error("action=ingest exige externalEngine: qual motor renderizou, no formato provedor-motor ('kie-kling-3.0', 'fal-krea-2', 'magnific-minimax-h3', 'sogni-minimax-h3'). É o que a ficha mostra em 'Motor' e o que explica depois por que a peça não tem custo em Sinapses. Marca sozinha ('kie', 'magnific') não vale.");
312
444
  }
313
- const result = await convexAction("externalIngest:mcpIngestExternal", {
314
- sessionToken,
315
- sourceUrl: args.sourceUrl,
445
+ const temBytesLocais = Boolean(args.filePath || args.base64?.trim());
446
+ if (!args.sourceUrl && !temBytesLocais) {
447
+ throw new Error("action=ingest exige a peça: sourceUrl (o link https do render no motor de fora), filePath (arquivo local, só no MCP instalado) ou base64.");
448
+ }
449
+ const ficha = {
316
450
  externalEngine: args.externalEngine,
317
451
  prompt: args.prompt ?? args.note,
318
452
  externalCost: args.externalCost,
@@ -323,10 +457,33 @@ export async function gallery(args) {
323
457
  characterIds: normalizeCastIds(args.characterIds).length
324
458
  ? normalizeCastIds(args.characterIds)
325
459
  : undefined,
326
- });
460
+ };
461
+ if (args.sourceUrl && !temBytesLocais) {
462
+ try {
463
+ const result = await convexAction("externalIngest:mcpIngestExternal", {
464
+ sessionToken,
465
+ sourceUrl: args.sourceUrl,
466
+ ...ficha,
467
+ });
468
+ return { ...result, aviso: AVISO_INGEST };
469
+ }
470
+ catch (e) {
471
+ // Só as duas recusas que a ponte resolve caem pra ela. O resto (motor
472
+ // sem nome, personagem errado, tipo recusado) é erro de verdade e sobe.
473
+ const msg = describeConvexError(e);
474
+ if (!/restrita ao dono|Host não liberado/i.test(msg))
475
+ throw e;
476
+ }
477
+ }
478
+ const result = await ingestPelaPonte(sessionToken, args, ficha);
327
479
  return {
328
480
  ...result,
329
- aviso: "Peça NATIVA: entra na timeline PRIVADA (isPublic false), igual a qualquer geração da casa. Ela NÃO publica sozinha e NÃO vai pra comunidade: publicar é ato separado do dono, por action=publish. Custo em Sinapses é 0 e ela NÃO leva assinatura C2PA, porque o motor rodou fora. Use ingest só quando a receita (folha, personagem, prompt) foi da casa; peça achada pronta é action=upload.",
481
+ aviso: AVISO_INGEST +
482
+ (result.adulta
483
+ ? result.adultaPor === "declaracao"
484
+ ? " Esta nasceu com o carimbo +18 que você declarou (unfiltered): publica e fica no seu perfil e no link, só não vai pra vitrine anônima. Você tira o carimbo na ficha se mudar de ideia."
485
+ : " Esta nasceu com o carimbo +18 por CLASSE (provedor de peso aberto ou motor sem freio): publica e fica no seu perfil e no link, só não vai pra vitrine anônima. Esse carimbo só a curadoria tira, igual ao motor sem freio da casa."
486
+ : ""),
330
487
  };
331
488
  }
332
489
  // cast: quem está EM CENA numa peça que já existe.
@@ -73,6 +73,39 @@ const MODELS = [
73
73
  // assim, listados em action=models e recusados no generate). Hoje vazio: todo
74
74
  // motor ativo de imagem entra.
75
75
  export const IMAGE_FORA_DE_PROPOSITO = {};
76
+ /**
77
+ * Motores que NASCEM em 2K quando quem chama não diz o tamanho. Padrão do MCP
78
+ * desde 20/09/2026, decisão do dono depois do take de aferição.
79
+ *
80
+ * Por que só estes dois grupos: na família Krea 2 o 2K é nativo do endpoint
81
+ * (2720x1536 em 16:9, o dobro do lado do 1K) e a peça de teste segurou fio de
82
+ * cabelo, poro e trama de tricô sem virar cera. No Gemini o 2K não custa NADA a
83
+ * mais pro provider (mesma conta de tokens de saída do 1K; o salto de custo só
84
+ * aparece no 4K), então ali é detalhe de graça.
85
+ *
86
+ * Quem fica de fora e por quê, pra ninguém tentar de novo: no gpt-image-2 e nos
87
+ * 2.5 o size é vestigial, quem decide o tamanho é o aspect ratio
88
+ * (mapAspectRatioToImageSize no backend, teto de 1536x1024), então não existe 2K
89
+ * pra pedir ali. O Seedream já entrega 2K nativo sem escada. Grok e MAI aceitam,
90
+ * mas ficaram de fora por decisão: neles o 2K custa mais pro provider.
91
+ *
92
+ * É padrão do MCP, não da casa: o gerador web continua abrindo em 1K, porque lá
93
+ * quem escolhe vê o preço mudar na tela. O size explícito manda em tudo,
94
+ * inclusive pra baixo (size="1K" volta ao barato).
95
+ */
96
+ const DEFAULT_2K_MODELS = new Set([
97
+ "wavespeed-krea2",
98
+ "wavespeed-krea2-realism",
99
+ "wavespeed-krea2-base",
100
+ "wavespeed-krea2-transparencia",
101
+ "wavespeed-krea2-broken-tears",
102
+ "nano-banana-max",
103
+ "nano-banana-2",
104
+ ]);
105
+ /** Tamanho padrão do motor, quando quem chama não passou o size. */
106
+ function defaultSizeFor(model) {
107
+ return model && DEFAULT_2K_MODELS.has(model) ? "2K" : undefined;
108
+ }
76
109
  export const imageSchema = z.object({
77
110
  action: z.enum(["generate", "request_generation", "compose", "models"]),
78
111
  prompt: z
@@ -90,7 +123,7 @@ export const imageSchema = z.object({
90
123
  size: z
91
124
  .enum(["1K", "2K", "4K"])
92
125
  .optional()
93
- .describe("Default '1K'. Resolução acima de 1K SOMA um adder ao preço do modelo, e só nos modelos com hasResolutionAdder (nano-banana-max e nano-banana-2 vão até 4K; grok-2-image e grok-2-image-quality param em 2K, e pedir 4K neles entrega 2K e cobra o adder de 2K). Nos demais modelos o size é ignorado (fica em 1K). O valor VIVO do adder por modelo sai em action=models (campo resolutionAdders, já clampado ao teto do motor): não escreva o número de cabeça, ele muda quando o provider reajusta."),
126
+ .describe("PADRÃO DO MCP: quem NÃO passa size nasce em 2K no Gemini (nano-banana-max, nano-banana-2) e na família Krea 2 da WaveSpeed, e em 1K no resto. Nesses dois grupos o 2K soma o adder ao preço (+100 hoje), e é decisão da casa pagar por ele: no Gemini o 2K não custa nada a mais pro provider, e na Krea 2 ele dobra o lado da peça (2720x1536 em 16:9) sem perder pele. Passe size='1K' explícito quando o barato importar mais que o detalhe. Resolução acima de 1K só existe nos modelos com hasResolutionAdder (o Gemini vai até 4K; grok-2-image, grok-2-image-quality e a família Krea 2 param em 2K, e pedir 4K neles entrega 2K e cobra o adder de 2K). Nos demais modelos o size é ignorado (fica em 1K): no gpt-image-2 e nos 2.5, por exemplo, quem decide o tamanho é o aspect ratio. O valor VIVO do adder por modelo sai em action=models (campo resolutionAdders, já clampado ao teto do motor): não escreva o número de cabeça, ele muda quando o provider reajusta."),
94
127
  styleId: z
95
128
  .string()
96
129
  .optional()
@@ -112,6 +145,10 @@ export const imageSchema = z.object({
112
145
  .enum(["persona", "logo", "none"])
113
146
  .optional()
114
147
  .describe("Marca na imagem do brand: 'persona' (personagem do brand via character sheets, ex: Helen), 'logo' (carimba a logo no canto), 'none' (só o estilo). Default 'none'. Só aplica se brandSlug setado e o brand oferecer a marca."),
148
+ skills: z
149
+ .array(z.string())
150
+ .optional()
151
+ .describe("COMO A PEÇA FOI FEITA: os slugs das skills que dirigiram esta geração (ex: ['camarim'], ['dop','escala-gigantismo']). Metadado puro: não muda preço, motor nem resultado, e serve pra ACHAR depois tudo que saiu de uma skill. Passe sempre que a geração estiver saindo de uma skill da casa, na ordem em que elas entraram (quem escreveu o prompt primeiro). Fica no registro da peça e volta em sapiens_gallery."),
115
152
  templateSlug: z
116
153
  .string()
117
154
  .optional()
@@ -259,16 +296,18 @@ export async function image(args) {
259
296
  // exigem imageId pré-existente). Pra modelos de imagem (nano-banana-*,
260
297
  // gpt-image-2-*), prefira action="generate" que já faz tudo num call.
261
298
  if (args.action === "request_generation") {
299
+ const reqModel = args.model ?? "nano-banana-2";
262
300
  return await import("../convexClient.js").then(({ convexMutation }) => convexMutation("mcpExtras:mcpRequestGeneration", {
263
301
  sessionToken,
264
- model: args.model ?? "nano-banana-2",
302
+ model: reqModel,
265
303
  styleId: args.styleId,
266
304
  prompt: args.prompt,
267
305
  negativePrompt: args.negativePrompt,
268
306
  aspectRatio: args.aspectRatio ?? "16:9",
269
- size: args.size ?? "1K",
307
+ size: args.size ?? defaultSizeFor(reqModel) ?? "1K",
270
308
  brandSlug: args.brandSlug,
271
309
  brandMark: args.brandMark,
310
+ skills: args.skills,
272
311
  }));
273
312
  }
274
313
  // Edit/variation usam desktopMcp.generateImageOnline (suporta sourceImageId
@@ -289,14 +328,19 @@ export async function image(args) {
289
328
  // (estilo + persona via refs server-side + logo) aplica dentro do
290
329
  // generateImageAction, igual ao gerador web. Substitui o antigo branch
291
330
  // referenceImageUrls→pipelineMcpImage (que era admin-only).
331
+ // Com templateSlug e sem model, fica undefined DE PROPÓSITO: quem manda no
332
+ // motor (e no tamanho) é o default do template.
333
+ const genModel = args.model ?? (args.templateSlug ? undefined : "nano-banana-2");
292
334
  const result = await convexAction("desktopMcp:generateImageOnline", {
293
335
  sessionToken,
294
336
  prompt: args.prompt,
295
337
  aspectRatio: args.aspectRatio,
296
- size: args.size,
338
+ // Sem size explícito, o motor decide (ver DEFAULT_2K_MODELS). Quando nem ele
339
+ // manda, o campo vai undefined e o backend fica no 1K de sempre.
340
+ size: args.size ?? defaultSizeFor(genModel),
297
341
  // Com templateSlug, deixa o model em branco pro default do template valer
298
342
  // (sem template, mantém o default nano-banana-2 da tool).
299
- model: args.model ?? (args.templateSlug ? undefined : "nano-banana-2"),
343
+ model: genModel,
300
344
  negativePrompt: args.negativePrompt,
301
345
  mode: args.mode ?? "create",
302
346
  sourceImageId: args.sourceImageId,
@@ -304,6 +348,7 @@ export async function image(args) {
304
348
  referenceImageUrls: args.referenceImageUrls,
305
349
  brandSlug: args.brandSlug,
306
350
  brandMark: args.brandMark,
351
+ skills: args.skills,
307
352
  templateSlug: args.templateSlug,
308
353
  registro: args.registro,
309
354
  influencerId: args.influencerId,
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { convexQuery, convexMutation, convexAction, getSessionToken, saveSessionToken, clearSessionToken, describeConvexError, } from "../convexClient.js";
2
+ import { convexQuery, convexMutation, convexAction, getSessionToken, saveSessionToken, clearSessionToken, describeConvexError, isRemoteContext, } from "../convexClient.js";
3
3
  import { getMcpVersion } from "../version.js";
4
4
  import { setTierFromIsAdmin } from "../tier.js";
5
5
  /**
@@ -50,6 +50,7 @@ export const metaSchema = z.object({
50
50
  "app_url",
51
51
  "subscription",
52
52
  "version",
53
+ "pontes",
53
54
  ]),
54
55
  code: z
55
56
  .string()
@@ -135,6 +136,77 @@ const FORMAT_GUIDE = {
135
136
  // porque o MCP é pacote separado e não importa o examples.ts do app Next).
136
137
  // NÃO menciona slash command (/sapiens:login etc.): isso só existe no Claude
137
138
  // Code; Helen/Gemini/Cursor falam só MCP em linguagem natural.
139
+ /**
140
+ * AS PONTES: onde a pessoa pode gerar com crédito PRÓPRIO, com a ficha do
141
+ * personagem daqui, e trazer pro acervo por sapiens_gallery action=ingest.
142
+ *
143
+ * A chave do provedor NUNCA passa pelo Sapiens. O que este catálogo faz é dizer
144
+ * ao harness da pessoa o NOME da variável onde a chave costuma morar, pra ele
145
+ * descobrir sozinho o que ela já tem (`action=pontes` detecta por nome, no
146
+ * stdio, sem ler o valor) e o que cada balcão roda. O passo a passo vive na
147
+ * skill 'pontes'; aqui é só o índice.
148
+ *
149
+ * Só entra balcão que a casa mediu (motor que rodou de verdade por lá). Preço
150
+ * não entra: muda toda semana e a skill diz onde conferir.
151
+ */
152
+ const PONTES = [
153
+ {
154
+ key: "kie",
155
+ nome: "Kie",
156
+ envVars: ["KIE_API_KEY"],
157
+ roda: "Kling 3.0, Kling Motion, MiniMax/Hailuo, WAN, Seedance; imagem também",
158
+ nota: "Um endpoint pra tudo (createTask + recordInfo). A URL do render expira em 24h: ingira no mesmo turno.",
159
+ },
160
+ {
161
+ key: "fal",
162
+ nome: "fal.ai",
163
+ envVars: ["FAL_KEY"],
164
+ roda: "Krea 2, Kling, WAN, Seedance, Flux; Krea 2 sem freio existe lá",
165
+ nota: "fal.run síncrono ou queue.fal.run. Slug 'fal-' na casa é classe +18 (sai da vitrine anônima, fica no perfil).",
166
+ },
167
+ {
168
+ key: "wavespeed",
169
+ nome: "WaveSpeed",
170
+ envVars: ["WAVESPEED_API_KEY"],
171
+ roda: "Flux.2 Klein, Krea 2 Livre, WAN 2.2, Kling, Shot Mimic",
172
+ nota: "É o balcão que a casa mais usa por trás do sapiens_video. Slug 'wavespeed-' é classe +18.",
173
+ },
174
+ {
175
+ key: "sogni",
176
+ nome: "Sogni",
177
+ envVars: ["SOGNI_API_KEY"],
178
+ roda: "MiniMax H3 inteira, WAN 2.2 (com animate), LTX 2.3 e 2.5, Krea 2 Turbo, Identity Edit",
179
+ nota: "Assinatura Unlimited cobre o catálogo aberto (custo marginal zero). Um socket por conta: um job por vez.",
180
+ },
181
+ {
182
+ key: "magnific",
183
+ nome: "Magnific / Freepik",
184
+ envVars: [],
185
+ roda: "Seedance 2.0 e 2.5 (até 30s e 1080p), MiniMax H3, upscale, relight",
186
+ nota: "Sem chave: é o conector MCP da Magnific na sessão da pessoa. A URL assinada do render expira no mesmo dia.",
187
+ },
188
+ {
189
+ key: "krea",
190
+ nome: "Krea API",
191
+ envVars: ["KREA_API_KEY"],
192
+ roda: "Krea 2 Turbo/Medium/Large, MiniMax H3 Max Turbo, Seedream, Veo, Seedance",
193
+ nota: "Filtra NSFW; job que falha não cobra. Saldo pré-pago separado do app.",
194
+ },
195
+ {
196
+ key: "replicate",
197
+ nome: "Replicate",
198
+ envVars: ["REPLICATE_API_TOKEN"],
199
+ roda: "catálogo aberto (Flux, WAN, LTX e o que a comunidade sobe)",
200
+ nota: "A casa não mediu motor por lá; o contrato de ingest é o mesmo.",
201
+ },
202
+ {
203
+ key: "bancada",
204
+ nome: "A própria máquina (ComfyUI, SD local)",
205
+ envVars: [],
206
+ roda: "o que a placa aguenta: LTX, Klein, Krea 2 com LoRA, H3 destilado",
207
+ nota: "Custo zero em dinheiro, pago em tempo. Entra por filePath, slug 'bancada-<motor>'.",
208
+ },
209
+ ];
138
210
  const FIRST_POWERS = [
139
211
  {
140
212
  icon: "📚",
@@ -319,7 +391,7 @@ export async function meta(args) {
319
391
  balance,
320
392
  ...(lowBalance
321
393
  ? {
322
- balanceWarning: "Saldo abaixo de 500 Sinapses: o que é grátis roda tranquilo, mas pra imagem/música/vídeo talvez precise recarregar.",
394
+ balanceWarning: "Saldo abaixo de 500 Sinapses: o que é grátis roda tranquilo, mas pra imagem/música/vídeo talvez precise recarregar. Outra saída é a ponte: se a pessoa tem crédito na Kie, na fal, na Magnific ou na Sogni, gera lá com a ficha do personagem daqui e traz a peça pro acervo por sapiens_gallery action=ingest (skill 'pontes'; sapiens_meta action=pontes diz o que ela já tem configurado).",
323
395
  }
324
396
  : {}),
325
397
  firstPowers: FIRST_POWERS,
@@ -471,6 +543,38 @@ export async function meta(args) {
471
543
  : "Nenhuma sessão local encontrada.",
472
544
  };
473
545
  }
546
+ // pontes: o que a pessoa já tem de crédito FORA da casa, e como cada balcão
547
+ // entra. Não exige login e não fala com o backend: é leitura do catálogo
548
+ // acima mais uma olhada nos NOMES das variáveis do ambiente do MCP instalado
549
+ // (o valor nunca é lido nem devolvido; no remoto o processo é o servidor do
550
+ // site, então lá não se olha nada e a resposta manda perguntar).
551
+ if (args.action === "pontes") {
552
+ const remoto = isRemoteContext();
553
+ const detectadas = remoto
554
+ ? []
555
+ : PONTES.filter((p) => p.envVars.some((nome) => {
556
+ const valor = process.env[nome];
557
+ return typeof valor === "string" && valor.trim().length >= 8;
558
+ })).map((p) => p.key);
559
+ return {
560
+ regra: "A chave de provedor NUNCA passa pelo Sapiens: quem chama o motor é o seu harness, na sua máquina, com o seu crédito. A casa entra com a ficha do personagem (sapiens_character action=get: passportPrompt + imageUrls) e recebe a peça de volta por sapiens_gallery action=ingest, que grava motor, prompt verbatim, custo real e personagem. Passo a passo: sapiens_skill action=get name=pontes.",
561
+ detectadas,
562
+ detectadasNota: remoto
563
+ ? "No MCP remoto não dá pra olhar o ambiente da pessoa: pergunte a ela onde tem crédito (Kie, fal, Magnific, Sogni, Krea...) ou olhe os conectores que já estão nesta conversa."
564
+ : detectadas.length
565
+ ? `Achei chave configurada (pelo NOME da variável, sem ler o valor) pra: ${detectadas.join(", ")}. Proponha gerar por aí antes de gastar Sinapse.`
566
+ : "Nenhuma variável de provedor no ambiente deste MCP. Pergunte onde a pessoa tem crédito, ou olhe os conectores já ligados nesta conversa (Magnific, Krea, Sogni).",
567
+ pontes: PONTES.map((p) => ({
568
+ key: p.key,
569
+ nome: p.nome,
570
+ roda: p.roda,
571
+ envVars: p.envVars,
572
+ nota: p.nota,
573
+ ingestSlug: `${p.key}-<motor>`,
574
+ })),
575
+ voltaPraCasa: "sapiens_gallery action=ingest com externalEngine='<provedor>-<motor>', prompt VERBATIM, externalCost na moeda de lá, characterId e referenceImageIds. filePath (arquivo local, só no MCP instalado) ou sourceUrl (o render, que expira: ingira no mesmo turno). unfiltered=true quando o motor rodou sem filtro. Tetos: 12 MB imagem, 60 MB vídeo, 40 peças por dia.",
576
+ };
577
+ }
474
578
  const sessionToken = getSessionToken();
475
579
  if (args.action === "whoami") {
476
580
  // mcpGetMySubscription usa requireMcpUser (qualquer logado), então whoami
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapiens-mcp",
3
- "version": "1.69.2",
3
+ "version": "1.71.0",
4
4
  "mcpName": "com.sapiensinteticos/sapiens",
5
5
  "description": "MCP server pra operar o Sapiens Sintéticos (sapiensinteticos.com) pelo Claude Code: gerar imagem, escrever artigo, voz, música e mais, na sua conta. Login pelo código de sapiensinteticos.com/conectar-claude.",
6
6
  "type": "module",
@@ -1,114 +0,0 @@
1
- import { z } from "zod";
2
- import { convexQuery, convexMutation, getSessionToken } from "../convexClient.js";
3
- /**
4
- * sapiens_referencia — o acervo de publicidade da casa, pela porta do agente.
5
- *
6
- * Existe pra resolver um gargalo de curadoria, não pra expor mais um catálogo:
7
- * achar um comercial bom e precisar de terminal pra guardar significa que o
8
- * acervo só cresce quando o dono está na máquina. Aqui ele guarda de onde
9
- * estiver, com o link e uma frase.
10
- *
11
- * O acervo tem duas trilhas e as duas ensinam coisas opostas: `referencia` é
12
- * campanha premiada (o teto do ofício) e `replicavel` é anúncio veiculando
13
- * agora (o degrau). O membro vê tudo em /dashboard/acervo?tab=referencias.
14
- *
15
- * ADMIN-ONLY de ponta a ponta: o Convex recusa quem não é dono em toda action,
16
- * e a tool some do tools/list de membro. A curadoria é o valor deste acervo, e
17
- * acervo que qualquer agente alimenta vira lista.
18
- *
19
- * O que esta tool NÃO faz: guardar peça de biblioteca de anúncio (Meta,
20
- * TikTok). Aquelas servem por URL assinada que expira, então o vídeo precisa
21
- * ser espelhado no CDN da casa antes de virar ficha, e isso é trabalho do
22
- * script `apps/sapiens/scripts/ingest-ad-references.mjs`, com ffmpeg e
23
- * browser. Link do YouTube e do Vimeo, que têm embed oficial, entram aqui.
24
- */
25
- export const referenciaSchema = z.object({
26
- action: z.enum(["list", "save", "note"]),
27
- url: z
28
- .string()
29
- .optional()
30
- .describe("Pra save: link do YouTube ou do Vimeo. Só essas duas, porque têm embed " +
31
- "oficial que não expira. Peça de Meta Ad Library ou TikTok entra pelo " +
32
- "script de ingestão da casa, não por aqui."),
33
- titulo: z.string().optional().describe("Pra save: o nome da campanha."),
34
- marca: z.string().optional().describe("Pra save: quem anuncia. Resolve o setor sozinho quando o setor não vem."),
35
- trilha: z
36
- .enum(["referencia", "replicavel"])
37
- .optional()
38
- .describe("Pra save: 'referencia' (campanha premiada, o teto do ofício) ou " +
39
- "'replicavel' (anúncio veiculando agora). Default: referencia."),
40
- setor: z
41
- .string()
42
- .optional()
43
- .describe("Pra save/list: o setor do catálogo (Alimentação e bebida, IA e software, " +
44
- "Moda e beleza...). Em save, sem isto o servidor deduz pela marca."),
45
- ano: z.number().optional().describe("Pra save: ano da campanha."),
46
- leitura: z
47
- .string()
48
- .optional()
49
- .describe("Pra save/note: a leitura editorial. O que a peça faz que dá pra roubar, " +
50
- "onde está o gancho, por que ela segura. É o que o acervo tem e uma " +
51
- "busca no YouTube não tem."),
52
- fonte: z
53
- .enum(["youtube", "vimeo", "tiktok-library", "tiktok-creative-center", "meta-ad-library"])
54
- .optional()
55
- .describe("Pra note: a fonte da peça já guardada (vem do list)."),
56
- externalId: z.string().optional().describe("Pra note: o id da peça na fonte (vem do list)."),
57
- limite: z.number().optional().describe("Pra list: teto de fichas (default 60, máx 200)."),
58
- });
59
- export async function referencia(args) {
60
- const sessionToken = getSessionToken();
61
- if (args.action === "list") {
62
- const fichas = await convexQuery("adReferences:mcpListar", {
63
- sessionToken,
64
- ...(args.trilha ? { trilha: args.trilha } : {}),
65
- ...(args.setor ? { setor: args.setor } : {}),
66
- ...(args.limite ? { limite: args.limite } : {}),
67
- });
68
- const n = Array.isArray(fichas) ? fichas.length : 0;
69
- const semLeitura = Array.isArray(fichas) ? fichas.filter((f) => !f.temLeitura).length : 0;
70
- return {
71
- count: n,
72
- fichas,
73
- note: n === 0
74
- ? "Nada nesse recorte ainda."
75
- : `${semLeitura} sem leitura escrita. A leitura é o que o acervo tem e uma busca no YouTube não tem: action=note fonte=<f> externalId=<id> leitura="...".`,
76
- };
77
- }
78
- if (args.action === "save") {
79
- if (!args.url)
80
- throw new Error("sapiens_referencia save: exige url.");
81
- if (!args.titulo)
82
- throw new Error("sapiens_referencia save: exige titulo.");
83
- const r = await convexMutation("adReferences:mcpGuardar", {
84
- sessionToken,
85
- url: args.url,
86
- titulo: args.titulo,
87
- ...(args.marca ? { marca: args.marca } : {}),
88
- ...(args.trilha ? { trilha: args.trilha } : {}),
89
- ...(args.setor ? { setor: args.setor } : {}),
90
- ...(args.ano ? { ano: args.ano } : {}),
91
- ...(args.leitura ? { leitura: args.leitura } : {}),
92
- });
93
- return {
94
- ...r,
95
- note: r?.novo
96
- ? "Guardada. Aparece pro membro em /dashboard/acervo?tab=referencias."
97
- : "Já estava no acervo; a ficha foi atualizada em vez de duplicar.",
98
- };
99
- }
100
- if (args.action === "note") {
101
- if (!args.fonte || !args.externalId) {
102
- throw new Error("sapiens_referencia note: exige fonte e externalId (vêm do list).");
103
- }
104
- if (!args.leitura)
105
- throw new Error("sapiens_referencia note: exige leitura.");
106
- return await convexMutation("adReferences:mcpAnotar", {
107
- sessionToken,
108
- fonte: args.fonte,
109
- externalId: args.externalId,
110
- leitura: args.leitura,
111
- });
112
- }
113
- throw new Error(`sapiens_referencia: action desconhecida "${args.action}".`);
114
- }
@@ -1,81 +0,0 @@
1
- import { z } from "zod";
2
- import { convexAction, getSessionToken } from "../convexClient.js";
3
- /**
4
- * Sapiens Shorts — vídeo vertical 9:16 via VEO (v1.5).
5
- *
6
- * Sub-action:
7
- * - render: dispara render com brief structured + imageId persona pré-existente.
8
- *
9
- * Pré-requisitos pro caller:
10
- * 1. Gerar imagem persona via sapiens_image (ou escolher do gallery via sapiens_gallery)
11
- * 2. Ter créditos suficientes (custo varia por modelo)
12
- * 3. Brief montado segundo schema (product/hook/shots/vibe)
13
- *
14
- * Estilos:
15
- * - ugc: real-life talking-head (mais comum)
16
- * - unboxing: close em produto físico
17
- * - app-demo: persona + tela
18
- * - reflexao: atmosfera mais lenta, contemplativa
19
- *
20
- * Retorna `{ success:true, imageId, status:'rendering', url:null }` (ASSÍNCRONO):
21
- * o render VEO roda fora da chamada. Acompanhe com sapiens_video action=status
22
- * imageId=<id> até status='completed' (traz a url VEO, expiração curta, baixe
23
- * logo) ou 'error'/'blocked'.
24
- */
25
- export const shortsSchema = z.object({
26
- action: z.enum(["render"]),
27
- imageId: z.string().describe("generatedImages:_id da persona base. Use sapiens_gallery action=list pra descobrir."),
28
- styleId: z.enum(["ugc", "unboxing", "app-demo", "reflexao"]),
29
- brief: z.object({
30
- product: z.object({
31
- name: z.string(),
32
- type: z.enum(["app", "physical", "saas"]),
33
- uvps: z.array(z.string()).describe("Unique Value Propositions, 2-4 bullets curtos"),
34
- persona: z.string().describe("Descrição da persona-protagonista (idade/contexto/estilo)"),
35
- }),
36
- hook: z.object({
37
- line: z.string().describe("Frase de abertura punchy, 5-10 palavras"),
38
- emotion: z.string().describe("Emoção alvo (ex: 'curiosity', 'frustration', 'awe')"),
39
- }),
40
- shots: z
41
- .array(z.object({
42
- sec: z.number().describe("Duração em segundos (cap 8 por shot)"),
43
- role: z.enum(["hook", "problem", "solution", "cta"]),
44
- camera: z.enum(["close-up", "medium", "over-shoulder", "product-pov"]),
45
- action: z.string().describe("Ação visual descrita em 1 frase"),
46
- emotion: z.string(),
47
- voiceLine: z.string().optional().describe("Linha falada na cena (PT-BR)"),
48
- propVisible: z.string().optional(),
49
- }))
50
- .min(2)
51
- .max(6)
52
- .describe("2-6 shots. Total 15-60s. Geralmente: hook → problem → solution → cta."),
53
- vibe: z.object({
54
- energy: z.enum(["calm", "high"]).describe("Energia geral do edit"),
55
- language: z.enum(["pt-BR", "en"]).optional().describe("Default 'pt-BR'"),
56
- }),
57
- }),
58
- references: z
59
- .array(z.object({
60
- mimeType: z.string(),
61
- data: z.string(),
62
- role: z.string().optional(),
63
- }))
64
- .optional()
65
- .describe("References opcionais (start/end frames) em base64. Se omitido, persona é o start frame default."),
66
- });
67
- export async function shorts(args) {
68
- if (args.action === "render") {
69
- const sessionToken = getSessionToken();
70
- if (args.brief.shots.length === 0) {
71
- throw new Error("brief.shots vazio — passe 2-6 shots descrevendo a sequência.");
72
- }
73
- return await convexAction("mcpExtrasActions:mcpShortsRender", {
74
- sessionToken,
75
- imageId: args.imageId,
76
- styleId: args.styleId,
77
- brief: args.brief,
78
- references: args.references,
79
- });
80
- }
81
- }