sapiens-mcp 1.78.1 → 1.79.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.
package/README.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ![Helen Ailith numa mesa escura, antebracos vestidos por um exoesqueleto de luz verde, movendo placas de luz com as maos abertas](https://sapiensinteticos.b-cdn.net/borderlessprotocol/2026/07/1785430717302_e8p3i4.webp)
4
4
 
5
+ <p align="center">
6
+ <a href="https://sapiensinteticos.b-cdn.net/videos/films/abre-alas/sapiens-mcp-intro-pt-3e96346c.mp4"><img src="https://sapiensinteticos.b-cdn.net/videos/films/abre-alas/sapiens-mcp-intro-pt-497bacfa.jpg" width="720" alt="O sapiens-mcp em 15 segundos: você fala, sua IA executa"></a><br>
7
+ <a href="https://sapiensinteticos.b-cdn.net/videos/films/abre-alas/sapiens-mcp-intro-pt-3e96346c.mp4"><b>Assiste em 15 segundos, com som</b></a> · <a href="https://sapiensinteticos.b-cdn.net/videos/films/abre-alas/sapiens-mcp-intro-9x16-pt-b5b02542.mp4">corte vertical</a> · <a href="https://sapiensinteticos.b-cdn.net/videos/films/abre-alas/sapiens-mcp-intro-en-e17d0673.mp4">in English</a>
8
+ </p>
9
+
5
10
  Servidor MCP pra operar o [Sapiens Sintéticos](https://sapiensinteticos.com) direto do Claude Code, **na sua própria conta**. Você pede no Claude ("gera uma imagem disso", "escreve um artigo sobre aquilo") e ele faz, gastando as **suas Sinapses**, salvando no **seu perfil**.
6
11
 
7
12
  ## Pré-requisito
@@ -42,6 +47,29 @@ O token vale 90 dias e renova sozinha a cada uso; fica salvo em `~/.sapiens-mcp/
42
47
 
43
48
  O Claude avisa o custo antes de gastar, e geração que falha é estornada. Publicar no blog editorial, Coluna Sapiens e o pipeline são exclusivos do dono da plataforma.
44
49
 
50
+ ## Gerar com a sua chave (0 Sinapse)
51
+
52
+ Tem crédito na fal ou na Kie? Ponha a chave no `env` do servidor, no config do seu cliente, e a `sapiens_pontes` gera no provedor com ela e traz a peça pro seu acervo. Você paga o preço do provedor; a casa cobra zero em cima.
53
+
54
+ ```json
55
+ {
56
+ "mcpServers": {
57
+ "sapiens": {
58
+ "command": "npx",
59
+ "args": ["-y", "sapiens-mcp"],
60
+ "env": {
61
+ "FAL_KEY": "cole-aqui-a-chave-da-fal",
62
+ "KIE_API_KEY": "cole-aqui-a-chave-da-kie"
63
+ }
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ No Claude Code: `claude mcp add sapiens --env FAL_KEY=sua-chave -- npx -y sapiens-mcp`. Depois é só pedir: "gera um retrato da Vera na fal com a minha chave".
70
+
71
+ A chave fica na sua máquina e sai dela só pro provedor: não vai em argumento, não volta em resultado, não passa pelo servidor do Sapiens. Por isso a `sapiens_pontes` existe só aqui, no servidor instalado; o conector remoto (claude.ai, ChatGPT) não recebe chave. Os jobs ficam em `~/.sapiens-mcp/pontes-jobs.json`, e o mesmo pedido repetido em 30 minutos devolve o job que já existe em vez de cobrar de novo.
72
+
45
73
  ## Troubleshooting
46
74
 
47
75
  - **"sessionToken expirado" / "Conta Sapiens não conectada".** A chave venceu (90 dias sem uso) ou nunca foi salva. Abra [sapiensinteticos.com/conectar-claude](https://www.sapiensinteticos.com/conectar-claude) logado, gere um código novo e rode `sapiens_meta action=login code=XXXX-XXXX`.
@@ -51,7 +79,7 @@ O Claude avisa o custo antes de gastar, e geração que falha é estornada. Publ
51
79
 
52
80
  ## Privacidade
53
81
 
54
- O servidor só conversa com o backend público do Sapiens (Convex). Sua identidade vem sempre do token de login, nunca de parâmetros soltos. Cada conta só mexe no que é dela.
82
+ O servidor conversa com o backend público do Sapiens (Convex) e, só quando você gera com a sua chave, com o provedor dela (fal ou Kie), direto da sua máquina. Sua identidade vem sempre do token de login, nunca de parâmetros soltos. Cada conta só mexe no que é dela.
55
83
 
56
84
  ## Sobre o Sapiens Sintéticos
57
85
 
@@ -0,0 +1,145 @@
1
+ /**
2
+ * PISO DE IDADE da casa, na porta local das Pontes (sapiens_pontes).
3
+ *
4
+ * CÓPIA dos padrões de apps/sapiens/convex/shared/characterSoulGuard.ts. O MCP
5
+ * é pacote separado e não importa o app, então os padrões moram duplicados; a
6
+ * paridade é travada por apps/sapiens/convex/shared/pisoDeIdade.parity.test.ts,
7
+ * que quebra se um lado mudar sem o outro. Mexeu num, mexa no outro.
8
+ *
9
+ * Por que existe aqui: na porta local, quem chama o motor é o código da casa,
10
+ * inclusive em peso aberto sem freio (fal). O prompt passa por esta peneira
11
+ * ANTES de sair da máquina, com a mesma régua da alma do personagem: 18 é o
12
+ * piso, e a palavra que o gerador lê como criança não sai daqui.
13
+ *
14
+ * Módulo PURO: sem import, sem rede, sem disco.
15
+ */
16
+ // Abaixo de 18 em número (1 a 17) e por extenso (pt-BR, pt-PT e inglês).
17
+ // Zero e "um/uma" ficam fora: "tem um ano de casa" é do projeto, não de gente.
18
+ const PT_MINOR_WORDS = "dois|duas|tr[êe]s|quatro|cinco|seis|sete|oito|nove|" +
19
+ "dez|onze|doze|treze|catorze|quatorze|quinze|dezesseis|dezasseis|dezessete|dezassete";
20
+ const EN_MINOR_WORDS = "one|two|three|four|five|six|seven|eight|nine|" +
21
+ "ten|eleven|twelve|thirteen|fourteen|fifteen|sixteen|seventeen";
22
+ const NUM_MINOR_CORE = `(?:1[0-7]|[1-9]|${PT_MINOR_WORDS}|${EN_MINOR_WORDS})`;
23
+ // Sempre entre \b: "25 anos" não casa "5 anos", "18" não casa "1".
24
+ const NUM_MINOR = `\\b${NUM_MINOR_CORE}\\b`;
25
+ // Substantivo que, ligado a "de N anos", fala de uma PESSOA (o que torna o
26
+ // "de" inequívoco). Sem ele, "de 15 anos" também casa "há mais de 15 anos".
27
+ const PT_PERSON = "menin[oa]s?|garot[oa]s?|jovem|jovens|mo[çc][ao]s?|rapaz(?:es)?|mulher(?:es)?|homens?|" +
28
+ "estudantes?|alun[oa]s?|filh[oa]s?|irm[ãa]o?s?|irm[ãa]s?|prim[oa]s?|sobrinh[oa]s?|" +
29
+ "namorad[oa]s?|noiv[oa]s?|person(?:agem|agens)|ele|ela";
30
+ // Sujeito que torna "is N" idade de alguém: pronome, substantivo de pessoa ou
31
+ // nome próprio (palavra capitalizada). "the list is 12 items" não tem sujeito
32
+ // de gente, então passa.
33
+ const EN_PERSON = "she|he|who|girl|boy|woman|man|kid|character|daughter|son|sister|brother|" +
34
+ "niece|nephew|cousin|friend|student|[A-ZÀ-Ý][a-zà-ÿ]+";
35
+ // "N anos" depois de vírgula é idade ("Tavi, 17 anos"), salvo quando o resto
36
+ // da frase diz que é intervalo: "voltou, 15 anos depois", "15 anos de estrada".
37
+ const PT_INTERVAL_TAIL = "(?!\\s+(?:de\\s+(?!idade\\b)|depois|antes|atr[áa]s|ap[óo]s|mais\\s+tarde|se\\s+passaram))";
38
+ // Nome próprio antes da vírgula: palavra capitalizada que NÃO é numeral
39
+ // ("Três, quatro, sete palavras" é contagem, não "Nome, N"). A lista é
40
+ // case-sensitive de propósito, é só a inicial maiúscula que interessa.
41
+ const CAPITALIZED_NUMERALS = `${PT_MINOR_WORDS}|${EN_MINOR_WORDS}`
42
+ .split("|")
43
+ .map((w) => w[0].toUpperCase() + w.slice(1))
44
+ .join("|");
45
+ const PT_NAME_BEFORE_COMMA = `\\b(?!(?:${CAPITALIZED_NUMERALS})\\b)[A-ZÀ-Ý][^\\s,;.()]{1,30},\\s*`;
46
+ // O que fecha "Nome, N" como idade: fim de oração, ou vírgula que NÃO abre
47
+ // outro número ("Gamma, 2,1 m" é medida; "Três, quatro, sete" é contagem).
48
+ const PT_BARE_TAIL = `(?![,.]\\d)(?=\\s*(?:[.;!?)\\n]|$|,\\s*(?!\\d|(?:${PT_MINOR_WORDS}|${EN_MINOR_WORDS})\\b)))`;
49
+ // O que vem depois de "is N" pra ser idade: fim de oração ou a própria palavra
50
+ // idade. "is 12 by 14", "was 16 strong", "is 15,000 lines" ficam de fora.
51
+ const EN_AGE_TAIL = "(?!,\\d)(?=\\s*(?:[.,;!?)\\n]|$|years?[- ]old\\b|yo\\b|y\\.o\\.|and\\b|now\\b|today\\b))";
52
+ // Unidade que desfaz "aged/turned N" como idade: "turned 15 years ago",
53
+ // "only 15 minutes", "is 15%".
54
+ const EN_UNIT_TAIL = "(?!\\s*(?:%|:|h\\b|am\\b|pm\\b|st\\b|nd\\b|rd\\b|th\\b|minutes?|mins?|seconds?|hours?|days?|weeks?|months?|km|kg|cm|percent|years?\\s+(?:ago|later|of|in|on|since)))";
55
+ /** Os padrões, idênticos aos do servidor (ver o cabeçalho). */
56
+ export const MINOR_PATTERNS = [
57
+ // pt-BR: a palavra que o gerador lê como criança.
58
+ { re: /\bcrian[çc]as?\b/i, label: "criança" },
59
+ { re: /\badolescentes?\b/i, label: "adolescente" },
60
+ { re: /\bmenor(?:es)?\s+de\s+idade\b/i, label: "menor de idade" },
61
+ { re: /\bmenin[oa]s?\b/i, label: "menino/menina" },
62
+ // pt-BR: idade dita em número, só com o verbo/preposição que a torna idade
63
+ // de alguém. "há 15 anos" e "faz 15 anos que mora em Lisboa" passam
64
+ // ("faz" fica fora de propósito: é o "há" falado).
65
+ {
66
+ re: new RegExp(
67
+ // "tem 10 anos de estrada" passa; "tem 16 anos de idade" cai.
68
+ `\\b(?:tem|tenho|tinha|tinham|t[êe]m|aos|fez|completou|completa|completa[rn])\\s+(?:apenas\\s+|s[óo]\\s+)?${NUM_MINOR}\\s+anos?\\b(?!\\s+de\\s+(?!idade\\b))`, "i"),
69
+ label: "tem/aos N anos (abaixo de 18)",
70
+ },
71
+ {
72
+ // "ela tem 17." (sem "anos"): só quando a oração acaba no número.
73
+ // "tem 15 tatuagens" passa.
74
+ re: new RegExp(`\\b(?:tem|tenho|tinha|tinham|t[êe]m)\\s+(?:apenas\\s+|s[óo]\\s+)?${NUM_MINOR}(?=\\s*(?:[.,;!?)\\n]|$))`, "i"),
75
+ label: "tem N (abaixo de 18)",
76
+ },
77
+ {
78
+ re: new RegExp(`\\bcom\\s+${NUM_MINOR}\\s+anos?\\b(?!\\s+de\\s+\\w)`, "i"),
79
+ label: "com N anos (abaixo de 18)",
80
+ },
81
+ { re: new RegExp(`${NUM_MINOR}\\s+anos?\\s+de\\s+idade\\b`, "i"), label: "N anos de idade (abaixo de 18)" },
82
+ {
83
+ re: new RegExp(`\\b(?:${PT_PERSON})\\s+de\\s+${NUM_MINOR}\\s+anos?\\b`, "i"),
84
+ label: "pessoa de N anos (abaixo de 18)",
85
+ },
86
+ {
87
+ // O formato canônico da primeira linha: "Você é Tavi, 17 anos". Pede o
88
+ // nome antes da vírgula: "financiada, dez anos ainda pra pagar" passa.
89
+ re: new RegExp(`${PT_NAME_BEFORE_COMMA}${NUM_MINOR}\\s+anos?\\b${PT_INTERVAL_TAIL}`),
90
+ label: "'Nome, N anos' (abaixo de 18)",
91
+ },
92
+ {
93
+ // Nome seguido só do número: "Rin, 17." / "Tavi, 17, estudante".
94
+ re: new RegExp(`${PT_NAME_BEFORE_COMMA}${NUM_MINOR}${PT_BARE_TAIL}`),
95
+ label: "'Nome, N' (abaixo de 18)",
96
+ },
97
+ {
98
+ // Nome com a idade entre parênteses: "Rin (16)".
99
+ re: new RegExp(`\\b[A-ZÀ-Ý][^\\s(]{1,30}\\s*\\(\\s*${NUM_MINOR}\\s*(?:anos?|years?\\s+old|yo)?\\s*\\)`),
100
+ label: "'Nome (N)' (abaixo de 18)",
101
+ },
102
+ {
103
+ // Rótulo de ficha: "Idade: 17", "Age: 16", "idade = 15".
104
+ re: new RegExp(`\\b(?:idade|age)\\s*[:=]\\s*(?:apenas\\s+|s[óo]\\s+|only\\s+|just\\s+)?${NUM_MINOR}`, "i"),
105
+ label: "Idade: N (abaixo de 18)",
106
+ },
107
+ // inglês: mesma régua.
108
+ { re: /\bteen(?:s|age|aged|ager|agers)?\b/i, label: "teen" },
109
+ { re: /\bpre-?teens?\b/i, label: "preteen" },
110
+ { re: /\bunderage\b/i, label: "underage" },
111
+ { re: /\bchild(?:ren)?\b/i, label: "child" },
112
+ // "minor" fora da acepção musical/adjetiva ("minor key", "minor detail").
113
+ {
114
+ re: /\bminors?\b(?!\s+(?:key|chord|scale|detail|details|role|injur\w*|issue|issues|setback|note|notes|tweak|tweaks|fix|fixes|thing|things|point|points|character|characters|deity|deities|arcana|league|leagues|surgery|change|changes|glitch|scratch))/i,
115
+ label: "minor",
116
+ },
117
+ { re: /\b(?:loli|lolita|shota)\b/i, label: "loli/shota" },
118
+ { re: new RegExp(`${NUM_MINOR}[- ]years?[- ]old\\b`, "i"), label: "N years old (under 18)" },
119
+ // "17yo" não tem fronteira entre o 7 e o y, então o núcleo entra sem o \b final.
120
+ { re: new RegExp(`\\b${NUM_MINOR_CORE}\\s*(?:yo\\b|y\\.o\\.?)`, "i"), label: "Nyo (under 18)" },
121
+ {
122
+ // "aged 14", "age of 12", "turned 16"; "turned 15 years ago" e
123
+ // "only 15 minutes" passam pelo lookahead de unidade.
124
+ re: new RegExp(`\\b(?:aged?|age\\s+of|turned|turning)\\s+(?:only\\s+|just\\s+)?${NUM_MINOR}${EN_UNIT_TAIL}`, "i"),
125
+ label: "aged/turned N (under 18)",
126
+ },
127
+ {
128
+ // "she is 16", "Tavi was 15.", "he's 14": só com sujeito de gente e
129
+ // com a oração acabando no número. "the list is 12 items" passa.
130
+ re: new RegExp(`\\b(?:${EN_PERSON})(?:'s|\\s+is|\\s+was|\\s+turns)\\s+(?:only\\s+|just\\s+)?${NUM_MINOR}${EN_AGE_TAIL}`),
131
+ label: "she is N (under 18)",
132
+ },
133
+ ];
134
+ /** Devolve todos os trechos que caem na peneira (vazio = passou). */
135
+ export function findMinorMarkers(text) {
136
+ if (!text)
137
+ return [];
138
+ const out = [];
139
+ for (const { re, label } of MINOR_PATTERNS) {
140
+ const m = text.match(re);
141
+ if (m)
142
+ out.push({ match: m[0], label });
143
+ }
144
+ return out;
145
+ }
package/dist/registry.js CHANGED
@@ -5,6 +5,7 @@ import { image, imageSchema } from "./tools/image.js";
5
5
  import { meta, metaSchema } from "./tools/meta.js";
6
6
  import { repertorio, repertorioSchema } from "./tools/repertorio.js";
7
7
  import { gallery, gallerySchema } from "./tools/gallery.js";
8
+ import { pontes, pontesSchema } from "./tools/pontes.js";
8
9
  import { community, communitySchema } from "./tools/community.js";
9
10
  import { article, articleSchema } from "./tools/article.js";
10
11
  import { quotePop, quotePopSchema } from "./tools/quotePop.js";
@@ -86,6 +87,11 @@ export const TOOLS = {
86
87
  schema: gallerySchema,
87
88
  handler: gallery,
88
89
  },
90
+ sapiens_pontes: {
91
+ description: "GERAR COM A CHAVE DA PESSOA (só no MCP instalado): a outra porta ao lado da Sinapse. Chama o provedor com a chave que mora no ambiente DESTA máquina (o env do config do cliente MCP: FAL_KEY, KIE_API_KEY) e traz a peça pro acervo como nativa, custo 0 em Sinapses: a pessoa paga o preço do provedor, sem margem da casa. A chave NUNCA vai em arg, prompt ou chat, nunca volta no resultado e nunca passa pelo Sapiens: a tool lê do ambiente e o pedido sai daqui direto pro provedor. No conector remoto (claude.ai, ChatGPT) esta tool não existe; lá é Sinapse (sapiens_image, sapiens_video) ou o harness da pessoa + sapiens_gallery action=ingest. Sub-actions: 'balcoes' (quais chaves estão configuradas, pelo NOME, e como configurar; grátis, sem rede), 'gerar' (provedor 'fal' ou 'kie' + modelo = o slug NO PROVEDOR, igual à página do modelo (fal: o id do endpoint, ex 'fal-ai/flux/dev'; Kie: o 'model' do Market) + input = o corpo que a doc do provedor descreve pra esse modelo, com o prompt DENTRO. COBRA NA CONTA DA PESSOA no provedor: confirme modelo e custo com ela antes. O job fica gravado nesta máquina ANTES de esperar o render, e o pedido idêntico em 30 minutos devolve o job que já existe em vez de cobrar de novo (forcar=true repete de propósito). Espera até aguardarSegundos (padrão 45) e, pronto, ingere sozinho com characterId, referenceImageIds, prompt verbatim e o custo quando o provedor diz), 'status' (jobId: consulta o provedor e, se terminou, traz pro acervo; é o que se chama depois de um gerar que voltou 'rodando', NUNCA gerar de novo), 'jobs' (os jobs desta máquina, sem rede). A FICHA DO PERSONAGEM entra no input: pegue passportPrompt e mainImageUrl/imageUrls em sapiens_character action=get, ponha o descritor no começo do prompt e as imagens no campo de referência que o modelo aceita (image_url, image_urls, conforme a doc dele); o bloco NEGATIVE do passaporte vai no campo negative_prompt quando o modelo tem um. O prompt passa pelo piso de idade da casa antes de sair (18 é o piso). Peça de fal entra com o carimbo +18 por classe (peso aberto), igual ao ingest manual; Kie entra sem. Passo a passo na skill 'pontes'.",
92
+ schema: pontesSchema,
93
+ handler: pontes,
94
+ },
89
95
  sapiens_community: {
90
96
  description: "Chat da comunidade Sapiens (assinantes + alumni). Sub-actions: list (últimas N mensagens da sala 'geral' por default), send (Claude posta como intercessor do user; sufixo '· via Claude' é adicionado pelo servidor), react (toggle emoji numa mensagem — allowlist 👍🔥❤️🚀🤯), participants (quem está na sala: username/name/isBot + o `mention` pronto pra usar — use pra saber com quem falar), search_users (acha alguém por parte do nome/@username, autocomplete de menção). MENÇÃO: escreva '@username' no content do send e o servidor NOTIFICA a pessoa citada (sino + Telegram); descubra o username certo via participants/search_users antes. ANEXO do PRÓPRIO acervo no send: mediaAssetKind (track|video|film|comic) + mediaAssetId monta um card da peça (posse conferida no servidor); asTese=true posta a fala como TESE (card de marca, pingável pro Fórum). Voz nas postagens deve seguir o DNA editorial Sapiens (anti-corporate, primeira pessoa, sem em-dash).",
91
97
  schema: communitySchema,
@@ -122,7 +128,7 @@ export const TOOLS = {
122
128
  handler: persona,
123
129
  },
124
130
  sapiens_helen: {
125
- description: "Helen Voice TTS via ElevenLabs, Google Gemini ou Fish Audio (qualquer logado; cobra Sinapses). Sub-actions: list_presets (catálogo de voiceIds/voiceNames recomendados + stylePreambles), speak (sintetiza, retorna audioBase64+mimeType+sizeBytes). provider=fish é o S2.1 Pro, o mais forte em japonês, mandarim e coreano: fala de personagem e cena de anime pedem ele. `language` escolhe o idioma (auto|pt|en|ja|es|ko|zh|fr|it|de). BYOK suportado via clientApiKey, senão usa env do deploy. Custo: ElevenLabs ~$0.30/500c, Google ~$0.01/500c, Fish grátis via API até 31/08/2026. Max 5000 chars (quebra antes em chunks). text pré-processado pelo caller (sem em-dash, sem markdown).",
131
+ description: "Helen Voice TTS via ElevenLabs, Google Gemini ou Fish Audio (qualquer logado; cobra Sinapses). Sub-actions: list_presets (catálogo de voiceIds/voiceNames recomendados + stylePreambles), speak (sintetiza, retorna audioBase64+mimeType+sizeBytes). provider=fish é o S2.1 Pro, o mais forte em japonês, mandarim e coreano: fala de personagem e cena de anime pedem ele. `language` escolhe o idioma (auto|pt|en|ja|es|ko|zh|fr|it|de). A fala sai sempre em Sinapses: a casa não recebe chave de provedor, e clientApiKey é recusado sem cobrar. Custo: ElevenLabs ~$0.30/500c, Google ~$0.01/500c, Fish grátis via API até 31/08/2026. Max 5000 chars (quebra antes em chunks). text pré-processado pelo caller (sem em-dash, sem markdown).",
126
132
  schema: helenSchema,
127
133
  handler: helen,
128
134
  },
@@ -256,7 +262,7 @@ REGRA DE OURO:
256
262
  - 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.
257
263
  - 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.
258
264
  - 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.
259
- - 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.
265
+ - DUAS PORTAS, lado a lado: Sinapse (sapiens_image, sapiens_video: nada pra configurar) ou a CHAVE DA PESSOA num provedor (fal, Kie, Sogni, a própria placa), que custa zero aqui. No MCP instalado, sapiens_pontes gera com a chave que mora no config dela e traz a peça pro acervo; no remoto, ela gera no harness dela e traz por sapiens_gallery action=ingest. sapiens_meta action=pontes diz o que ela já tem configurado: se tiver chave, ofereça as duas portas antes de gastar Sinapse. A chave NUNCA passa pelo Sapiens (nem em arg, nem no chat). Skill 'pontes'.
260
266
  - 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').
261
267
  - "sessionToken expirado" = refaça login: sapiens_meta action=login com o código de sapiensinteticos.com/conectar-claude.
262
268
  - 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.
@@ -292,6 +298,7 @@ const TOOL_TITLES = {
292
298
  sapiens_skill: "Skills da Casa",
293
299
  sapiens_repertorio: "Repertório",
294
300
  sapiens_gallery: "Galeria de Imagens",
301
+ sapiens_pontes: "Pontes: gerar com a sua chave",
295
302
  sapiens_community: "Chat da Comunidade",
296
303
  sapiens_article: "Blog Editorial",
297
304
  sapiens_write: "Artigos do Perfil",
@@ -343,14 +350,22 @@ const ADMIN_ONLY_TOOLS = new Set([
343
350
  // (requireAdmin em convex/voiceJp.ts): sai daqui e do gate de la juntos.
344
351
  "sapiens_voice_jp",
345
352
  ]);
353
+ // Tools que só existem no processo LOCAL (stdio), na máquina da pessoa: somem
354
+ // do tools/list do remoto. sapiens_pontes lê a chave de provedor do ambiente da
355
+ // máquina; no remoto o processo é o servidor do site, e chave de membro não
356
+ // sobe (docs/infra/pontes-traga-seu-motor.md). O handler também recusa no
357
+ // remoto, pra chamada de cliente com a lista velha em cache.
358
+ const LOCAL_ONLY_TOOLS = new Set(["sapiens_pontes"]);
346
359
  /**
347
360
  * Monta o payload do tools/list pro tier dado. Tier "user" esconde as
348
361
  * admin-only; desconhecido (null) = lista cheia (fail-open, retrocompat).
362
+ * `transport` "remote" esconde as que só rodam na máquina da pessoa.
349
363
  */
350
- export function buildToolList(tier) {
364
+ export function buildToolList(tier, transport = "stdio") {
351
365
  const filtered = tier === "user";
352
366
  return Object.entries(TOOLS)
353
367
  .filter(([name]) => !filtered || !ADMIN_ONLY_TOOLS.has(name))
368
+ .filter(([name]) => transport === "stdio" || !LOCAL_ONLY_TOOLS.has(name))
354
369
  .map(([name, t]) => ({
355
370
  name,
356
371
  description: t.description,
package/dist/remote.js CHANGED
@@ -170,7 +170,7 @@ function initServer(mcp) {
170
170
  }
171
171
  // Transiente: serve o catálogo cheio (o gate de verdade é no tools/call).
172
172
  const tier = session.status === "valid" ? session.tier : null;
173
- return { tools: buildToolList(tier) };
173
+ return { tools: buildToolList(tier, "remote") };
174
174
  });
175
175
  s.setRequestHandler(ListPromptsRequestSchema, async () => ({
176
176
  prompts: listPrompts(),
package/dist/skills.js CHANGED
@@ -734,13 +734,15 @@ A foto NUNCA vai pra banco nenhum, nem da casa nem de terceiro:
734
734
  },
735
735
  {
736
736
  name: "pontes",
737
- title: "Pontes: gerar fora com crédito próprio e trazer pro acervo",
738
- 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'.",
737
+ title: "Pontes: gerar com a sua chave e trazer pro acervo",
738
+ 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á, quer gerar com a chave dela em vez de Sinapse, ou está sem Sinapse pra vídeo. Como levar a ficha, gerar no balcão dela (no MCP instalado, fal e Kie saem pela sapiens_pontes; a chave nunca passa pelo Sapiens) e a peça voltar pro acervo com a ficha inteira. Puxe quando ouvir 'gera com a minha chave', 'tenho FAL_KEY', 'tem Kie aí?', 'uso a fal', 'gastei minhas Sinapses', 'gero na Magnific', 'tenho ComfyUI'.",
739
739
  body: `## O que é uma ponte
740
740
 
741
741
  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.
742
742
 
743
- **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.
743
+ São duas portas lado a lado, e nenhuma é a reserva da outra: Sinapse (sapiens_image, sapiens_video) é pra quem não quer configurar nada; a chave própria é pra quem já tem crédito num provedor, e aí a casa não cobra nada em cima: ela paga o preço do provedor, sem margem nossa. Quando a pessoa tem chave configurada, ofereça as duas.
744
+
745
+ **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 máquina da pessoa (o env do config do cliente MCP com \`FAL_KEY\`, \`KIE_API_KEY\`; um conector MCP do provedor ligado na conversa; o SDK dela) e quem chama o motor é um processo DA MÁQUINA DELA: o \`sapiens-mcp\` instalado (sapiens_pontes) ou o harness dela. O Sapiens entra ANTES (a ficha) e DEPOIS (o ingest). Se a pessoa colar a chave no chat por engano, diga pra ela gerar uma chave nova no provedor e pôr a nova no config, e siga sem usar o valor.
744
746
 
745
747
  ## Passo 1: descobrir onde a pessoa tem crédito
746
748
 
@@ -764,7 +766,19 @@ Confirme com ela ANTES de disparar em qualquer balcão pago. Geração de fora t
764
766
 
765
767
  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.
766
768
 
767
- ## Passo 3: gerar lá fora
769
+ ## Passo 3a: gerar pela porta local (fal e Kie, MCP instalado)
770
+
771
+ Com o \`sapiens-mcp\` instalado e a chave no env do config, é a porta mais curta, e funciona até no Claude Desktop, que não tem terminal:
772
+
773
+ 1. \`sapiens_pontes action=balcoes\` diz quais chaves estão configuradas (pelo nome, sem ler o valor). Faltando, mande a pessoa pôr a chave no config: sapiensinteticos.com/conectar-claude#suas-chaves.
774
+ 2. Confirme com ela o modelo e o custo no provedor. É dinheiro dela.
775
+ 3. \`sapiens_pontes action=gerar\` com \`provedor\` ('fal' ou 'kie'), \`modelo\` (o slug como está na página do modelo no provedor) e \`input\` (o corpo que a doc do modelo descreve, com o prompt DENTRO), mais \`characterId\` e \`referenceImageIds\` pra ficha. O descritor do passaporte vai no começo do prompt; o bloco NEGATIVE vai no campo \`negative_prompt\` quando o modelo tem um (no corpo do prompt, negação acende o que proíbe, e o piso de idade da casa recusa palavra de menor ali). As imagens da ficha vão no campo de referência do modelo (\`image_url\`, \`image_urls\`).
776
+ 4. Voltou \`rodando\`? Chame \`sapiens_pontes action=status jobId=...\`, nunca \`gerar\` de novo: o job está gravado na máquina dela, e o pedido idêntico em 30 minutos devolve o job que existe em vez de cobrar outra vez.
777
+ 5. Terminado, a peça entra sozinha no acervo, privada, custo 0 em Sinapses, com motor, prompt verbatim e custo na ficha. Mostre com \`sapiens_gallery action=list\`.
778
+
779
+ No remoto (claude.ai, ChatGPT) a porta local não existe, porque a chave não sobe: ali é o Passo 3b.
780
+
781
+ ## Passo 3b: gerar pelo harness (os outros balcões, ou no remoto)
768
782
 
769
783
  O que a casa MEDIU em cada balcão (o resto está na doc do provedor; não invente parâmetro):
770
784
 
@@ -782,7 +796,7 @@ Não repita geração paga às cegas: se a chamada deu timeout, consulte o job n
782
796
 
783
797
  ## Passo 4: trazer pra casa
784
798
 
785
- \`sapiens_gallery action=ingest\`, no MESMO turno do render (URL de render expira):
799
+ Pela porta local (3a) isto já aconteceu sozinho. Pelo harness (3b), \`sapiens_gallery action=ingest\`, no MESMO turno do render (URL de render expira):
786
800
 
787
801
  - 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).
788
802
  - \`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".
@@ -193,7 +193,7 @@ export const distributionSchema = z.object({
193
193
  lang: z
194
194
  .enum(["pt", "en"])
195
195
  .optional()
196
- .describe("lineage-new/lineage-save: a língua em que a linhagem fala. queue: em que LÍNGUA a copy JÁ ESTÁ. Declare sempre que escrever a copy você mesmo. Sem este campo, canal de rede internacional (bluesky, x, linkedin, farcaster, civitai, pinterest, reddit, medium, tumblr, deviantart) presume que a copy chegou em português e agenda uma vertida automática que SOBRESCREVE o que você mandou, dois segundos depois de enfileirar. Copy que já estava em inglês volta reescrita, e na voz da CASA em vez da voz de quem assina a peça, o que estraga copy de personagem. Declarar o idioma desliga a vertida e o texto fica exatamente como você mandou."),
196
+ .describe("lineage-new/lineage-save: a língua em que a linhagem fala. queue: em que LÍNGUA a copy JÁ ESTÁ. Declare sempre que escrever a copy você mesmo. Sem este campo, canal de rede internacional (bluesky, linkedin, youtube, tiktok, threads, farcaster, civitai, pinterest, reddit, medium, tumblr, deviantart, e os X que não são o x e o x-buffer do dono) presume que a copy chegou em português e agenda uma vertida automática que SOBRESCREVE o que você mandou, dois segundos depois de enfileirar. Copy que já estava em inglês volta reescrita, e na voz da CASA em vez da voz de quem assina a peça, o que estraga copy de personagem. Declarar o idioma desliga a vertida e o texto fica exatamente como você mandou."),
197
197
  voice: z
198
198
  .string()
199
199
  .optional()
@@ -63,10 +63,14 @@ export const helenSchema = z.object({
63
63
  .string()
64
64
  .optional()
65
65
  .describe("ElevenLabs ex 'mp3_44100_128'. Default da provider."),
66
+ // Fica no schema de propósito, pra RECUSAR: sem o campo, o Zod descartaria a
67
+ // chave calado e a fala sairia cobrando Sinapses de quem achou que pagava com
68
+ // a própria chave. Desde 27/09/2026 a casa não recebe chave de provedor por
69
+ // caminho nenhum (docs/infra/pontes-traga-seu-motor.md).
66
70
  clientApiKey: z
67
71
  .string()
68
72
  .optional()
69
- .describe("BYOK: chave do user. Se vazia, usa env default do deploy (ELEVENLABS_API_KEY ou GEMINI_API_KEY)."),
73
+ .describe("DESLIGADO desde 27/09/2026: a casa não recebe chave de provedor. Não mande: a chamada é recusada sem cobrar. A fala sai sempre em Sinapses."),
70
74
  });
71
75
  const PRESETS = {
72
76
  elevenlabs: {
@@ -112,6 +116,9 @@ export async function helen(args) {
112
116
  if (!args.text || !args.text.trim()) {
113
117
  throw new Error("action=speak exige text (não vazio).");
114
118
  }
119
+ if (args.clientApiKey?.trim()) {
120
+ throw new Error("A voz com a sua chave saiu: a casa não recebe chave de provedor por caminho nenhum, ela fica na sua máquina. Esta fala não foi feita e nada foi cobrado. Mande de novo sem clientApiKey e ela sai em Sinapses. Se a chave foi colada no chat, gere uma nova no provedor.");
121
+ }
115
122
  const provider = args.provider ?? "elevenlabs";
116
123
  const modelId = args.modelId ??
117
124
  (provider === "elevenlabs"
@@ -140,7 +147,6 @@ export async function helen(args) {
140
147
  stylePreamble: args.stylePreamble,
141
148
  voiceSettings: args.voiceSettings,
142
149
  outputFormat: args.outputFormat,
143
- clientApiKey: args.clientApiKey,
144
150
  });
145
151
  }
146
152
  }
@@ -207,6 +207,8 @@ const PONTES = [
207
207
  nota: "Custo zero em dinheiro, pago em tempo. Entra por filePath, slug 'bancada-<motor>'.",
208
208
  },
209
209
  ];
210
+ /** Os balcões que a sapiens_pontes dirige daqui (os outros vão pelo harness + ingest). */
211
+ const PORTA_LOCAL = ["fal", "kie"];
210
212
  const FIRST_POWERS = [
211
213
  {
212
214
  icon: "📚",
@@ -391,7 +393,7 @@ export async function meta(args) {
391
393
  balance,
392
394
  ...(lowBalance
393
395
  ? {
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).",
396
+ 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 outra porta: com a chave dela num provedor (Kie, fal, Magnific, Sogni), gera lá com a ficha do personagem daqui e a peça volta pro acervo, custo zero em Sinapses. No MCP instalado, fal e Kie saem daqui mesmo por sapiens_pontes action=gerar; o resto volta por sapiens_gallery action=ingest (skill 'pontes'; sapiens_meta action=pontes diz o que ela já tem configurado).",
395
397
  }
396
398
  : {}),
397
399
  firstPowers: FIRST_POWERS,
@@ -556,14 +558,23 @@ export async function meta(args) {
556
558
  const valor = process.env[nome];
557
559
  return typeof valor === "string" && valor.trim().length >= 8;
558
560
  })).map((p) => p.key);
561
+ const naPortaLocal = detectadas.filter((k) => PORTA_LOCAL.includes(k));
559
562
  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.",
563
+ regra: "A chave de provedor NUNCA passa pelo Sapiens: ela mora no ambiente da máquina da pessoa e quem chama o motor é um processo de lá (o harness dela, ou o sapiens-mcp instalado pela sapiens_pontes). A casa entra com a ficha do personagem (sapiens_character action=get: passportPrompt + imageUrls) e recebe a peça de volta pelo ingest, que grava motor, prompt verbatim, custo real e personagem. Com a chave dela, gerar custa zero em Sinapses: ela paga o preço do provedor, sem margem da casa. Passo a passo: sapiens_skill action=get name=pontes.",
561
564
  detectadas,
562
565
  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."
566
+ ? "No MCP remoto não dá pra olhar o ambiente da pessoa, e gerar com a chave dela pede o MCP instalado (a chave não sobe pro conector remoto). Pergunte onde ela tem crédito (Kie, fal, Magnific, Sogni, Krea...) ou olhe os conectores que já estão nesta conversa."
564
567
  : 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).",
568
+ ? `Achei chave configurada (pelo NOME da variável, sem ler o valor) pra: ${detectadas.join(", ")}. Ofereça as duas portas antes de gastar Sinapse.${naPortaLocal.length ? ` ${naPortaLocal.join(" e ")} a casa gera daqui mesmo: sapiens_pontes action=gerar.` : ""}`
569
+ : "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). Pra fal e Kie, a chave vai no env do config do cliente MCP: sapiensinteticos.com/conectar-claude#suas-chaves.",
570
+ portaLocal: {
571
+ tool: "sapiens_pontes",
572
+ balcoes: PORTA_LOCAL,
573
+ disponivel: !remoto,
574
+ nota: remoto
575
+ ? "Só no MCP instalado: no remoto a tool não existe."
576
+ : "Gera no provedor com a chave desta máquina e traz a peça pro acervo sozinha. Os outros balcões seguem pelo harness + ingest.",
577
+ },
567
578
  pontes: PONTES.map((p) => ({
568
579
  key: p.key,
569
580
  nome: p.nome,
@@ -0,0 +1,783 @@
1
+ import { z } from "zod";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { createHash, randomBytes } from "node:crypto";
6
+ import { describeConvexError, getSessionToken, isRemoteContext, } from "../convexClient.js";
7
+ import { findMinorMarkers } from "../pisoDeIdade.js";
8
+ import { gallery } from "./gallery.js";
9
+ /**
10
+ * A PORTA LOCAL DAS PONTES (27/09/2026): gerar no provedor com a chave da
11
+ * PESSOA e trazer a peça pro acervo como nativa, custo 0 em Sinapses.
12
+ *
13
+ * Existe porque Sinapse e chave própria andam lado a lado (decisão do dono):
14
+ * Sinapse é pra quem não quer configurar nada; a chave própria é pra quem já tem
15
+ * crédito num provedor, e aí a casa não cobra nada em cima. Até esta tool, quem
16
+ * chamava o provedor era o harness da pessoa, então chave própria era coisa de
17
+ * quem tem terminal. O Claude Desktop não tem.
18
+ *
19
+ * A regra da chave (doc docs/infra/pontes-traga-seu-motor.md): ela mora no
20
+ * ambiente DESTA máquina (o "env" do config do cliente MCP) e sai daqui só pro
21
+ * provedor. Não entra em arg, não volta em resultado, não vai pro Convex, não
22
+ * fica no arquivo de jobs. No transporte remoto a tool nem aparece no
23
+ * tools/list, e a chamada é recusada antes de ler qualquer coisa: lá o processo
24
+ * é o servidor do site, e chave de membro não sobe.
25
+ *
26
+ * As quatro travas, uma por risco:
27
+ * 1. Bug nosso gastar o dinheiro dela: o job é gravado em disco ANTES de
28
+ * esperar o render, e o pedido idêntico em 30 min devolve o job que existe.
29
+ * 2. A chave vazar num erro: toda saída passa pelo `semChave`.
30
+ * 3. Provedor mudar a API: porta genérica, o `input` é o corpo da doc dele.
31
+ * 4. A casa passar a ser quem gera: o prompt passa pelo piso de idade antes
32
+ * de sair, e a peça entra pelo ingest com o carimbo da classe do provedor.
33
+ */
34
+ const BALCOES = {
35
+ fal: { nome: "fal.ai", envVar: "FAL_KEY" },
36
+ kie: { nome: "Kie", envVar: "KIE_API_KEY" },
37
+ };
38
+ /**
39
+ * Toda variável de chave de provedor que a casa conhece, dirigida por esta tool
40
+ * ou não: é a lista que o `semChave` limpa. Chave de balcão que a tool não usa
41
+ * também não pode vazar num erro que ecoe o ambiente.
42
+ */
43
+ const NOMES_DE_CHAVE = [
44
+ "FAL_KEY",
45
+ "KIE_API_KEY",
46
+ "WAVESPEED_API_KEY",
47
+ "SOGNI_API_KEY",
48
+ "KREA_API_KEY",
49
+ "REPLICATE_API_TOKEN",
50
+ ];
51
+ const JANELA_REPETIDO_MS = 30 * 60 * 1000;
52
+ const MAX_JOBS_GUARDADOS = 200;
53
+ const MAX_PECAS_POR_JOB = 4;
54
+ const ESPERA_PADRAO_GERAR_S = 45;
55
+ const ESPERA_PADRAO_STATUS_S = 20;
56
+ const ESPERA_MAX_S = 75;
57
+ // O cliente MCP desiste em ~120s. Trazer um vídeo pro acervo (baixar + subir)
58
+ // leva segundos a dezenas de segundos, então o ingest só começa na mesma
59
+ // chamada se ainda sobra folga; senão o job volta "pronto" e o status traz.
60
+ const INGEST_SO_ATE_MS = 60_000;
61
+ const ENDERECO_SUAS_CHAVES = "https://www.sapiensinteticos.com/conectar-claude#suas-chaves";
62
+ export const pontesSchema = z.object({
63
+ action: z.enum(["balcoes", "gerar", "status", "jobs"]),
64
+ provedor: z
65
+ .enum(["fal", "kie"])
66
+ .optional()
67
+ .describe("action=gerar: o balcão. 'fal' lê FAL_KEY; 'kie' lê KIE_API_KEY. As duas moram no env do config do cliente MCP, nesta máquina."),
68
+ modelo: z
69
+ .string()
70
+ .max(200)
71
+ .optional()
72
+ .describe("action=gerar: o slug do modelo NO PROVEDOR, igual à página dele. fal: o id do endpoint (ex: 'fal-ai/flux/dev'), que vai no caminho de queue.fal.run. Kie: o campo 'model' do Market (ex: 'kling/v2-1-standard'), que vai no corpo do createTask. Não invente: confira na página do modelo."),
73
+ input: z
74
+ .union([z.record(z.any()), z.string()])
75
+ .optional()
76
+ .describe("action=gerar: o corpo que a doc do provedor descreve pra esse modelo, com o prompt DENTRO (ex: {\"prompt\": \"...\", \"image_urls\": [\"...\"]}). A ficha do personagem entra aqui: passportPrompt no começo do prompt e mainImageUrl/imageUrls no campo de referência que o modelo aceita. A chave NUNCA vai aqui."),
77
+ prompt: z
78
+ .string()
79
+ .max(20000)
80
+ .optional()
81
+ .describe("action=gerar: o prompt verbatim pra ficha da peça, quando ele não mora em input.prompt. Omitido, a ficha usa input.prompt."),
82
+ jobId: z
83
+ .string()
84
+ .optional()
85
+ .describe("action=status: o id do job local (pj_...), devolvido pelo gerar e listado em action=jobs."),
86
+ ingerir: z
87
+ .boolean()
88
+ .optional()
89
+ .describe("action=gerar/status: true (padrão) traz a peça pro acervo quando o render termina. false só gera e devolve as URLs do provedor (que expiram)."),
90
+ aguardarSegundos: z
91
+ .number()
92
+ .int()
93
+ .min(0)
94
+ .max(ESPERA_MAX_S)
95
+ .optional()
96
+ .describe(`action=gerar/status: quanto esperar pelo render nesta chamada (padrão ${ESPERA_PADRAO_GERAR_S} no gerar, ${ESPERA_PADRAO_STATUS_S} no status, teto ${ESPERA_MAX_S}). Imagem costuma sair dentro; vídeo quase nunca: volta 'rodando' e o status termina.`),
97
+ forcar: z
98
+ .boolean()
99
+ .optional()
100
+ .describe("action=gerar: true gera de novo MESMO com um pedido idêntico nos últimos 30 minutos. Sem isto, o pedido repetido devolve o job que já existe, pra não cobrar duas vezes."),
101
+ externalEngine: z
102
+ .string()
103
+ .max(100)
104
+ .optional()
105
+ .describe("action=gerar: o nome do motor na ficha, formato provedor-motor ('fal-krea-2'). Omitido, sai do slug do modelo. O prefixo do provedor é sempre o de verdade: é ele que decide o carimbo +18 da classe."),
106
+ externalCost: z
107
+ .string()
108
+ .max(200)
109
+ .optional()
110
+ .describe("action=gerar: o custo pra ficha, quando você sabe ('US$ 0,35 na fal'). Omitido, a Kie informa os créditos consumidos e a fal fica como 'pago na fal com a sua chave'."),
111
+ characterId: z
112
+ .string()
113
+ .optional()
114
+ .describe("action=gerar: o personagem de quem é a peça (influencers:_id). A peça nasce ligada à ficha dele."),
115
+ characterIds: z
116
+ .union([z.array(z.string()), z.string()])
117
+ .optional()
118
+ .describe("action=gerar: quem está em cena, em ordem (o protagonista primeiro), quando é mais de uma criatura."),
119
+ referenceImageIds: z
120
+ .union([z.array(z.string()), z.string()])
121
+ .optional()
122
+ .describe("action=gerar: generatedImages:_id das peças da casa que serviram de referência."),
123
+ aspectRatio: z.string().max(20).optional().describe("action=gerar: proporção pra ficha ('9:16'). Omitido, lê input.aspect_ratio."),
124
+ size: z.string().max(20).optional().describe("action=gerar: resolução pra ficha ('720p'). Omitido, lê input.resolution."),
125
+ unfiltered: z
126
+ .boolean()
127
+ .optional()
128
+ .describe("action=gerar: true quando o modelo rodou sem filtro num balcão que modera (Kie): a peça nasce com o carimbo +18 declarado."),
129
+ skills: z
130
+ .union([z.array(z.string()), z.string()])
131
+ .optional()
132
+ .describe("action=gerar: os slugs das skills da casa que dirigiram a peça."),
133
+ limit: z.number().int().positive().max(50).optional().describe("action=jobs: quantos (padrão 10)."),
134
+ });
135
+ // ---------------------------------------------------------------------------
136
+ // A chave: lida do ambiente, nunca devolvida.
137
+ // ---------------------------------------------------------------------------
138
+ function segredosConfigurados() {
139
+ const out = new Set();
140
+ for (const nome of NOMES_DE_CHAVE) {
141
+ const valor = process.env[nome]?.trim();
142
+ if (!valor || valor.length < 8)
143
+ continue;
144
+ out.add(valor);
145
+ // A chave da fal é "id:segredo": um erro que ecoa só o id ainda identifica
146
+ // a conta dela, então cada metade também sai.
147
+ for (const parte of valor.split(":"))
148
+ if (parte.length >= 8)
149
+ out.add(parte);
150
+ }
151
+ // Maior primeiro: trocar o pedaço antes do inteiro deixaria sobra do inteiro.
152
+ return [...out].sort((a, b) => b.length - a.length);
153
+ }
154
+ /** Troca qualquer valor de chave configurada por "[chave]". Toda saída passa aqui. */
155
+ export function semChave(texto) {
156
+ let out = texto;
157
+ for (const segredo of segredosConfigurados())
158
+ out = out.split(segredo).join("[chave]");
159
+ return out;
160
+ }
161
+ function limpo(valor) {
162
+ return JSON.parse(semChave(JSON.stringify(valor)));
163
+ }
164
+ function chaveDo(provedor) {
165
+ const { nome, envVar } = BALCOES[provedor];
166
+ const valor = process.env[envVar]?.trim();
167
+ if (!valor || valor.length < 8) {
168
+ throw new Error(`Não achei a ${envVar} nesta máquina. Ponha a chave da ${nome} no "env" do sapiens, no config do seu cliente MCP, e reinicie o cliente: ${ENDERECO_SUAS_CHAVES}. A chave fica no seu computador; nunca mande ela no chat.`);
169
+ }
170
+ return valor;
171
+ }
172
+ const CAMPO_DE_CHAVE = /^(api[_-]?key|apikey|x[_-]api[_-]key|secret|client[_-]?secret|access[_-]?token|authorization|fal[_-]?key|kie[_-]?api[_-]?key)$/i;
173
+ function campoDeChave(v, fundo = 0) {
174
+ if (fundo > 6 || !v || typeof v !== "object")
175
+ return null;
176
+ for (const [k, x] of Object.entries(v)) {
177
+ if (CAMPO_DE_CHAVE.test(k))
178
+ return k;
179
+ const achou = campoDeChave(x, fundo + 1);
180
+ if (achou)
181
+ return achou;
182
+ }
183
+ return null;
184
+ }
185
+ /**
186
+ * A chave não viaja no pedido. Se ela aparece no input ou no prompt, o pedido
187
+ * morre aqui, sem sair da máquina, e a mensagem não repete o valor.
188
+ */
189
+ function confereSemChaveNoPedido(input, prompt) {
190
+ const corpo = `${JSON.stringify(input)}\n${prompt ?? ""}`;
191
+ const vazou = segredosConfigurados().some((s) => corpo.includes(s));
192
+ const campo = campoDeChave(input);
193
+ if (vazou || campo) {
194
+ throw new Error(`${campo ? `O input tem um campo de chave ("${campo}")` : "Tem uma chave de API dentro do input ou do prompt"}. A chave não vai no pedido: ela mora no env do config do seu cliente MCP e esta tool lê de lá. Tire do input e, se a chave foi colada no chat, gere uma nova no provedor.`);
195
+ }
196
+ }
197
+ // ---------------------------------------------------------------------------
198
+ // O pedido: input, prompt, piso de idade, assinatura pro repetido.
199
+ // ---------------------------------------------------------------------------
200
+ function lerInput(raw) {
201
+ if (raw == null) {
202
+ throw new Error("action=gerar exige input: o corpo que a doc do provedor descreve pra esse modelo, com o prompt dentro (ex: {\"prompt\": \"...\"}).");
203
+ }
204
+ let obj = raw;
205
+ if (typeof raw === "string") {
206
+ try {
207
+ obj = JSON.parse(raw);
208
+ }
209
+ catch {
210
+ throw new Error("input veio como texto e não é JSON válido. Mande o objeto (ex: {\"prompt\": \"...\"}).");
211
+ }
212
+ }
213
+ if (!obj || typeof obj !== "object" || Array.isArray(obj)) {
214
+ throw new Error("input precisa ser um objeto JSON (ex: {\"prompt\": \"...\"}).");
215
+ }
216
+ return obj;
217
+ }
218
+ /** Os textos que o motor lê como cena: prompt e parentes, fora o negativo. */
219
+ function textosDeCena(v, chave = "", out = [], fundo = 0) {
220
+ if (fundo > 6)
221
+ return out;
222
+ if (typeof v === "string") {
223
+ if (/prompt|text|caption|description|scene/i.test(chave) && !/negative/i.test(chave))
224
+ out.push(v);
225
+ return out;
226
+ }
227
+ if (Array.isArray(v)) {
228
+ for (const x of v)
229
+ textosDeCena(x, chave, out, fundo + 1);
230
+ return out;
231
+ }
232
+ if (v && typeof v === "object") {
233
+ for (const [k, x] of Object.entries(v))
234
+ textosDeCena(x, k, out, fundo + 1);
235
+ }
236
+ return out;
237
+ }
238
+ function conferePisoDeIdade(textos) {
239
+ const marcas = textos.flatMap((t) => findMinorMarkers(t));
240
+ if (!marcas.length)
241
+ return;
242
+ const citados = [...new Set(marcas.map((m) => `"${m.match}"`))].slice(0, 3).join(", ");
243
+ throw new Error(`O prompt cita menor de idade (${citados}). A porta da casa não gera isso em motor nenhum: 18 é o piso, 21+ é o padrão. Reescreva com a idade em absoluto ("adult woman, 24") e sem criança, adolescente, teen ou child no texto, mesmo falando de terceiros ou do passado. Se a palavra estava num "NEGATIVE:" dentro do prompt (o passportPrompt da ficha termina com um), mova pro campo negative_prompt do modelo ou tire: no corpo do prompt a maioria dos motores lê o negativo como cena.`);
244
+ }
245
+ function canonico(v) {
246
+ if (Array.isArray(v))
247
+ return `[${v.map(canonico).join(",")}]`;
248
+ if (v && typeof v === "object") {
249
+ const o = v;
250
+ return `{${Object.keys(o)
251
+ .sort()
252
+ .map((k) => `${JSON.stringify(k)}:${canonico(o[k])}`)
253
+ .join(",")}}`;
254
+ }
255
+ return JSON.stringify(v) ?? "null";
256
+ }
257
+ function hashDoPedido(provedor, modelo, input) {
258
+ return createHash("sha256").update(`${provedor}\n${modelo}\n${canonico(input)}`).digest("hex");
259
+ }
260
+ // Slug que vai no caminho da URL (fal) ou no corpo (Kie): letras, números,
261
+ // ponto, hífen, sublinhado e barra entre partes. Sem "..", sem esquema, sem
262
+ // barra na frente: o host é fixo e o caminho não passeia.
263
+ const MODELO_VALIDO = /^[a-z0-9][a-z0-9._-]*(\/[a-z0-9][a-z0-9._-]*)*$/i;
264
+ function confereModelo(modelo) {
265
+ if (!MODELO_VALIDO.test(modelo) || modelo.includes("..")) {
266
+ throw new Error(`modelo inválido: "${modelo.slice(0, 80)}". Use o slug como está na página do modelo no provedor (ex: 'fal-ai/flux/dev', 'kling/v2-1-standard').`);
267
+ }
268
+ }
269
+ /** O nome do motor na ficha: sempre com o prefixo do provedor de verdade. */
270
+ function motorDaFicha(provedor, modelo, pedido) {
271
+ const limpa = (s) => s
272
+ .toLowerCase()
273
+ .replace(/^fal-ai\//, "")
274
+ .replace(/[\s/]+/g, "-")
275
+ .replace(/[^a-z0-9._-]/g, "")
276
+ .replace(/-{2,}/g, "-")
277
+ .replace(/^-|-$/g, "")
278
+ .slice(0, 80);
279
+ const base = pedido?.trim() ? limpa(pedido) : limpa(modelo);
280
+ if (base.startsWith(`${provedor}-`))
281
+ return base;
282
+ return `${provedor}-${base || "modelo"}`;
283
+ }
284
+ // ---------------------------------------------------------------------------
285
+ // Os jobs, gravados nesta máquina (nunca no servidor).
286
+ // ---------------------------------------------------------------------------
287
+ function pastaLocal() {
288
+ return process.env.SAPIENS_MCP_HOME || path.resolve(os.homedir(), ".sapiens-mcp");
289
+ }
290
+ function arquivoDeJobs() {
291
+ return path.join(pastaLocal(), "pontes-jobs.json");
292
+ }
293
+ function lerJobs() {
294
+ try {
295
+ const raw = JSON.parse(fs.readFileSync(arquivoDeJobs(), "utf8"));
296
+ return Array.isArray(raw?.jobs) ? raw.jobs : [];
297
+ }
298
+ catch {
299
+ return [];
300
+ }
301
+ }
302
+ function gravarJobs(jobs) {
303
+ const file = arquivoDeJobs();
304
+ fs.mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
305
+ const corpo = semChave(JSON.stringify({ versao: 1, jobs: jobs.slice(-MAX_JOBS_GUARDADOS) }, null, 2));
306
+ const tmp = `${file}.${process.pid}.tmp`;
307
+ fs.writeFileSync(tmp, corpo, { encoding: "utf8", mode: 0o600 });
308
+ fs.renameSync(tmp, file);
309
+ try {
310
+ fs.chmodSync(file, 0o600);
311
+ }
312
+ catch {
313
+ // Windows/ACL: chmod pode não aplicar.
314
+ }
315
+ }
316
+ // Fila de escrita do processo: duas chamadas em paralelo não se atropelam no
317
+ // arquivo (ler-mudar-gravar sem trava perde o job de uma delas).
318
+ let filaDeEscrita = Promise.resolve();
319
+ function comTrava(fn) {
320
+ const vez = filaDeEscrita.then(fn, fn);
321
+ filaDeEscrita = vez.catch(() => undefined);
322
+ return vez;
323
+ }
324
+ function salvarJob(job) {
325
+ return comTrava(() => {
326
+ const jobs = lerJobs().filter((j) => j.id !== job.id);
327
+ jobs.push({ ...job, atualizadoEm: Date.now() });
328
+ gravarJobs(jobs);
329
+ });
330
+ }
331
+ function novoId() {
332
+ return `pj_${randomBytes(6).toString("hex")}`;
333
+ }
334
+ const EXT_ACEITA = /\.(png|jpe?g|webp|mp4|mov)(\?|#|$)/i;
335
+ function detalheDoErro(json) {
336
+ const d = json?.detail ?? json?.error?.message ?? json?.error ?? json?.message ?? json?.msg;
337
+ if (typeof d === "string")
338
+ return d.slice(0, 400);
339
+ if (Array.isArray(d))
340
+ return d.map((x) => x?.msg ?? JSON.stringify(x)).join("; ").slice(0, 400);
341
+ if (d)
342
+ return JSON.stringify(d).slice(0, 400);
343
+ return "";
344
+ }
345
+ /** As URLs de mídia que o provedor devolveu, na ordem, sem repetir. */
346
+ function urlsDeMidia(v, out = [], fundo = 0) {
347
+ if (fundo > 6 || out.length >= MAX_PECAS_POR_JOB * 2)
348
+ return out;
349
+ if (Array.isArray(v)) {
350
+ for (const x of v)
351
+ urlsDeMidia(x, out, fundo + 1);
352
+ return out;
353
+ }
354
+ if (v && typeof v === "object") {
355
+ const o = v;
356
+ if (typeof o.url === "string" && o.url.startsWith("https://")) {
357
+ const tipo = typeof o.content_type === "string" ? o.content_type : "";
358
+ if ((/^(image|video)\//.test(tipo) || EXT_ACEITA.test(o.url)) && !out.includes(o.url))
359
+ out.push(o.url);
360
+ }
361
+ for (const [k, x] of Object.entries(o))
362
+ if (k !== "url")
363
+ urlsDeMidia(x, out, fundo + 1);
364
+ }
365
+ return out;
366
+ }
367
+ // --- fal: fila em queue.fal.run ---
368
+ const FAL_FILA = "https://queue.fal.run";
369
+ /** Só manda a chave de volta pro host da fila da fal, nunca pra URL de outro host. */
370
+ function urlDaFilaFal(u) {
371
+ if (typeof u !== "string")
372
+ return null;
373
+ try {
374
+ const url = new URL(u);
375
+ return url.protocol === "https:" && url.hostname === "queue.fal.run" ? url.toString() : null;
376
+ }
377
+ catch {
378
+ return null;
379
+ }
380
+ }
381
+ async function falEnviar(chave, modelo, input) {
382
+ const res = await fetch(`${FAL_FILA}/${modelo}`, {
383
+ method: "POST",
384
+ headers: { Authorization: `Key ${chave}`, "Content-Type": "application/json" },
385
+ body: JSON.stringify(input),
386
+ signal: AbortSignal.timeout(30_000),
387
+ });
388
+ const json = await res.json().catch(() => null);
389
+ if (!res.ok || typeof json?.request_id !== "string") {
390
+ throw new Error(`A fal recusou o pedido (HTTP ${res.status}): ${detalheDoErro(json) || "resposta sem detalhe"}. ` +
391
+ (res.ok
392
+ ? "Confira no painel da fal se o pedido entrou antes de repetir."
393
+ : "Nada entrou na fila."));
394
+ }
395
+ // A fila da fal responde com as URLs de status e resultado; quando faltam,
396
+ // o caminho é <dono>/<app>/requests/<id> (o resto do slug não entra).
397
+ const app = modelo.split("/").slice(0, 2).join("/");
398
+ const base = `${FAL_FILA}/${app}/requests/${json.request_id}`;
399
+ return {
400
+ requestId: json.request_id,
401
+ statusUrl: urlDaFilaFal(json.status_url) ?? `${base}/status`,
402
+ responseUrl: urlDaFilaFal(json.response_url) ?? base,
403
+ };
404
+ }
405
+ async function falLer(chave, job) {
406
+ const aindaNao = { estado: job.estado === "rodando" ? "rodando" : "fila" };
407
+ const statusUrl = urlDaFilaFal(job.statusUrl);
408
+ const responseUrl = urlDaFilaFal(job.responseUrl);
409
+ if (!statusUrl || !responseUrl)
410
+ return { estado: "falhou", erro: "job sem endereço de consulta da fal" };
411
+ let res;
412
+ try {
413
+ res = await fetch(statusUrl, {
414
+ headers: { Authorization: `Key ${chave}` },
415
+ signal: AbortSignal.timeout(20_000),
416
+ });
417
+ }
418
+ catch {
419
+ return aindaNao; // consulta que falha não é geração que falhou
420
+ }
421
+ if (!res.ok)
422
+ return aindaNao;
423
+ const st = await res.json().catch(() => null);
424
+ if (st?.status === "IN_QUEUE")
425
+ return { estado: "fila" };
426
+ if (st?.status !== "COMPLETED")
427
+ return { estado: "rodando" };
428
+ if (st?.error) {
429
+ return { estado: "falhou", erro: `${st.error_type ? `${st.error_type}: ` : ""}${String(st.error).slice(0, 400)}` };
430
+ }
431
+ let out;
432
+ try {
433
+ out = await fetch(responseUrl, {
434
+ headers: { Authorization: `Key ${chave}` },
435
+ signal: AbortSignal.timeout(30_000),
436
+ });
437
+ }
438
+ catch {
439
+ return { estado: "rodando" };
440
+ }
441
+ const json = await out.json().catch(() => null);
442
+ if (out.status >= 500)
443
+ return { estado: "rodando" };
444
+ if (!out.ok) {
445
+ return { estado: "falhou", erro: `HTTP ${out.status}: ${detalheDoErro(json) || "sem detalhe"}` };
446
+ }
447
+ const urls = urlsDeMidia(json);
448
+ if (!urls.length)
449
+ return { estado: "falhou", erro: "a fal terminou mas não devolveu arquivo de imagem ou vídeo" };
450
+ return { estado: "pronto", urls, custo: "pago na fal com a sua chave" };
451
+ }
452
+ // --- Kie: createTask + recordInfo, o mesmo par pro Market inteiro ---
453
+ const KIE_JOBS = "https://api.kie.ai/api/v1/jobs";
454
+ /** 1 crédito Kie = US$ 0,005 (a unidade da tabela deles). */
455
+ const KIE_CREDITO_USD = 0.005;
456
+ async function kieEnviar(chave, modelo, input) {
457
+ const res = await fetch(`${KIE_JOBS}/createTask`, {
458
+ method: "POST",
459
+ headers: { Authorization: `Bearer ${chave}`, "Content-Type": "application/json" },
460
+ body: JSON.stringify({ model: modelo, input }),
461
+ signal: AbortSignal.timeout(30_000),
462
+ });
463
+ const json = await res.json().catch(() => null);
464
+ // A armadilha da Kie: HTTP 200 não é sucesso, o veredito é o `code` do corpo
465
+ // (422 modelo inexistente, 500 campo faltando, 401 chave).
466
+ if (json?.code !== 200 || typeof json?.data?.taskId !== "string") {
467
+ throw new Error(`A Kie recusou o pedido (código ${json?.code ?? res.status}): ${json?.msg || "resposta sem detalhe"}. Nada entrou na fila.`);
468
+ }
469
+ return { requestId: json.data.taskId };
470
+ }
471
+ async function kieLer(chave, job) {
472
+ const aindaNao = { estado: job.estado === "rodando" ? "rodando" : "fila" };
473
+ let res;
474
+ try {
475
+ res = await fetch(`${KIE_JOBS}/recordInfo?taskId=${encodeURIComponent(job.requestId)}`, {
476
+ headers: { Authorization: `Bearer ${chave}` },
477
+ signal: AbortSignal.timeout(20_000),
478
+ });
479
+ }
480
+ catch {
481
+ return aindaNao;
482
+ }
483
+ if (!res.ok)
484
+ return aindaNao;
485
+ const json = await res.json().catch(() => null);
486
+ const d = json?.data;
487
+ if (!d)
488
+ return aindaNao;
489
+ if (d.state === "success") {
490
+ // `resultJson` é STRING com JSON dentro.
491
+ let urls = [];
492
+ try {
493
+ const parsed = typeof d.resultJson === "string" ? JSON.parse(d.resultJson) : d.resultJson;
494
+ if (Array.isArray(parsed?.resultUrls)) {
495
+ urls = parsed.resultUrls.filter((u) => typeof u === "string" && u.startsWith("https://"));
496
+ }
497
+ }
498
+ catch {
499
+ // cai no "sem arquivo" abaixo
500
+ }
501
+ if (!urls.length)
502
+ return { estado: "falhou", erro: "a Kie terminou mas não devolveu arquivo" };
503
+ const creditos = typeof d.creditsConsumed === "number" ? d.creditsConsumed : undefined;
504
+ return {
505
+ estado: "pronto",
506
+ urls,
507
+ custo: creditos != null
508
+ ? `${creditos} créditos Kie (~US$ ${(creditos * KIE_CREDITO_USD).toFixed(2)})`
509
+ : "pago na Kie com a sua chave",
510
+ };
511
+ }
512
+ if (d.state === "fail") {
513
+ return { estado: "falhou", erro: String(d.failMsg || d.failCode || "a Kie marcou falha sem motivo").slice(0, 400) };
514
+ }
515
+ return { estado: d.state === "generating" ? "rodando" : "fila" };
516
+ }
517
+ // ---------------------------------------------------------------------------
518
+ // O laço: consultar, e quando pronto, trazer pro acervo.
519
+ // ---------------------------------------------------------------------------
520
+ function dormir(ms) {
521
+ return new Promise((r) => setTimeout(r, ms));
522
+ }
523
+ function aplicar(job, leitura) {
524
+ if (leitura.estado === "pronto") {
525
+ return {
526
+ ...job,
527
+ estado: "pronto",
528
+ resultUrls: leitura.urls.slice(0, MAX_PECAS_POR_JOB),
529
+ custo: job.ficha.externalCost ?? leitura.custo,
530
+ erro: undefined,
531
+ };
532
+ }
533
+ if (leitura.estado === "falhou")
534
+ return { ...job, estado: "falhou", erro: leitura.erro };
535
+ return { ...job, estado: leitura.estado };
536
+ }
537
+ async function ingerirPecas(job) {
538
+ const pecas = [...(job.pecas ?? [])];
539
+ for (const url of job.resultUrls ?? []) {
540
+ const ja = pecas.find((p) => p.url === url);
541
+ if (ja?.imageId)
542
+ continue;
543
+ try {
544
+ const r = await gallery({
545
+ action: "ingest",
546
+ sourceUrl: url,
547
+ externalEngine: job.ficha.externalEngine,
548
+ prompt: job.ficha.prompt,
549
+ externalCost: job.custo,
550
+ characterId: job.ficha.characterId,
551
+ characterIds: job.ficha.characterIds,
552
+ referenceImageIds: job.ficha.referenceImageIds,
553
+ aspectRatio: job.ficha.aspectRatio,
554
+ size: job.ficha.size,
555
+ unfiltered: job.ficha.unfiltered,
556
+ skills: job.ficha.skills,
557
+ });
558
+ const peca = {
559
+ url,
560
+ imageId: r?.imageId ? String(r.imageId) : undefined,
561
+ acervoUrl: r?.acervoUrl,
562
+ adulta: r?.adulta ? true : undefined,
563
+ };
564
+ if (ja)
565
+ Object.assign(ja, peca, { erro: undefined });
566
+ else
567
+ pecas.push(peca);
568
+ }
569
+ catch (e) {
570
+ // O ingest é idempotente pela URL: o próximo status tenta de novo sem duplicar.
571
+ const erro = describeConvexError(e);
572
+ if (ja)
573
+ ja.erro = erro;
574
+ else
575
+ pecas.push({ url, erro });
576
+ }
577
+ }
578
+ const todas = (job.resultUrls ?? []).every((u) => pecas.some((p) => p.url === u && p.imageId));
579
+ return { ...job, pecas, estado: todas ? "ingerido" : "pronto" };
580
+ }
581
+ async function avancar(job, segundos) {
582
+ const inicio = Date.now();
583
+ const prazo = inicio + Math.max(0, Math.min(segundos, ESPERA_MAX_S)) * 1000;
584
+ const intervalo = job.provedor === "kie" ? 3000 : 2500;
585
+ let atual = job;
586
+ for (;;) {
587
+ if (atual.estado === "fila" || atual.estado === "rodando") {
588
+ const chave = chaveDo(atual.provedor);
589
+ const leitura = atual.provedor === "fal" ? await falLer(chave, atual) : await kieLer(chave, atual);
590
+ atual = aplicar(atual, leitura);
591
+ await salvarJob(atual);
592
+ }
593
+ if (atual.estado === "pronto" && atual.ingerir && Date.now() - inicio < INGEST_SO_ATE_MS) {
594
+ atual = await ingerirPecas(atual);
595
+ await salvarJob(atual);
596
+ return atual;
597
+ }
598
+ if (atual.estado !== "fila" && atual.estado !== "rodando")
599
+ return atual;
600
+ if (Date.now() + intervalo > prazo)
601
+ return atual;
602
+ await dormir(intervalo);
603
+ }
604
+ }
605
+ // ---------------------------------------------------------------------------
606
+ // O que volta pro agente.
607
+ // ---------------------------------------------------------------------------
608
+ function proximoPasso(job) {
609
+ const nome = BALCOES[job.provedor].nome;
610
+ switch (job.estado) {
611
+ case "fila":
612
+ case "rodando":
613
+ return `Ainda renderizando na ${nome}. Chame sapiens_pontes action=status jobId=${job.id} (aguardarSegundos até ${ESPERA_MAX_S}). NÃO chame gerar de novo: isso cria outro job e cobra outra vez na conta da pessoa.`;
614
+ case "pronto":
615
+ if (!job.ingerir) {
616
+ return "Pronto no provedor, fora do acervo (ingerir=false). A URL do render expira: pra trazer, chame action=status com ingerir=true.";
617
+ }
618
+ return job.pecas?.some((p) => p.erro)
619
+ ? `Gerou, mas a volta pro acervo falhou (ver pecas[].erro). Chame action=status jobId=${job.id} de novo: o ingest é idempotente, não duplica.`
620
+ : `Gerou. Chame action=status jobId=${job.id} pra trazer pro acervo (a URL do render expira).`;
621
+ case "ingerido":
622
+ return "No acervo, privada e com custo 0 em Sinapses. Mostre com sapiens_gallery action=list (kind=video pra vídeo). Publicar é ato da pessoa (sapiens_gallery action=publish).";
623
+ case "falhou":
624
+ return `A ${nome} marcou falha. Confira o painel da ${nome} antes de tentar de novo (a regra de cobrança de job que falha é deles).`;
625
+ }
626
+ }
627
+ function resumo(job, extra = {}) {
628
+ return {
629
+ jobId: job.id,
630
+ provedor: job.provedor,
631
+ modelo: job.modelo,
632
+ estado: job.estado,
633
+ motor: job.ficha.externalEngine,
634
+ pecas: job.pecas?.length ? job.pecas : undefined,
635
+ resultUrls: job.estado === "pronto" ? job.resultUrls : undefined,
636
+ custo: job.custo,
637
+ erro: job.erro,
638
+ chave: `A chave ficou nesta máquina: o pedido saiu daqui direto pra ${BALCOES[job.provedor].nome}. A casa não viu a chave.`,
639
+ proximoPasso: proximoPasso(job),
640
+ ...extra,
641
+ };
642
+ }
643
+ // ---------------------------------------------------------------------------
644
+ // As actions.
645
+ // ---------------------------------------------------------------------------
646
+ async function gerar(args) {
647
+ const provedor = args.provedor;
648
+ if (!provedor)
649
+ throw new Error("action=gerar exige provedor: 'fal' ou 'kie'.");
650
+ const modelo = args.modelo?.trim();
651
+ if (!modelo) {
652
+ throw new Error("action=gerar exige modelo: o slug do modelo no provedor, igual à página dele.");
653
+ }
654
+ confereModelo(modelo);
655
+ const chave = chaveDo(provedor);
656
+ const input = lerInput(args.input);
657
+ confereSemChaveNoPedido(input, args.prompt);
658
+ conferePisoDeIdade([...(args.prompt ? [args.prompt] : []), ...textosDeCena(input)]);
659
+ const ingerir = args.ingerir ?? true;
660
+ if (ingerir) {
661
+ try {
662
+ getSessionToken();
663
+ }
664
+ catch {
665
+ throw new Error("Pra peça voltar pro acervo, a conta Sapiens precisa estar conectada neste MCP (sapiens_meta action=login). Sem conta, mande ingerir=false: a peça fica só no provedor.");
666
+ }
667
+ }
668
+ const hash = hashDoPedido(provedor, modelo, input);
669
+ if (!args.forcar) {
670
+ const igual = lerJobs()
671
+ .reverse()
672
+ .find((j) => j.hash === hash && j.estado !== "falhou" && Date.now() - j.criadoEm < JANELA_REPETIDO_MS);
673
+ if (igual) {
674
+ if (args.ingerir === true)
675
+ igual.ingerir = true;
676
+ const atual = await avancar(igual, Math.min(args.aguardarSegundos ?? 0, ESPERA_MAX_S));
677
+ return resumo(atual, {
678
+ repetido: true,
679
+ aviso: "Esse mesmo pedido já tinha ido pro provedor há menos de 30 minutos: devolvi o job que existe em vez de criar outro, pra não cobrar duas vezes. Pra gerar de novo de propósito, mande forcar=true.",
680
+ });
681
+ }
682
+ }
683
+ const promptDaFicha = args.prompt?.trim() ||
684
+ (typeof input.prompt === "string" && input.prompt.trim() ? input.prompt : JSON.stringify(input).slice(0, 4000));
685
+ const enviado = provedor === "fal" ? await falEnviar(chave, modelo, input) : await kieEnviar(chave, modelo, input);
686
+ const agora = Date.now();
687
+ const job = {
688
+ id: novoId(),
689
+ provedor,
690
+ modelo,
691
+ hash,
692
+ criadoEm: agora,
693
+ atualizadoEm: agora,
694
+ estado: "fila",
695
+ ...enviado,
696
+ ingerir,
697
+ ficha: {
698
+ externalEngine: motorDaFicha(provedor, modelo, args.externalEngine),
699
+ prompt: promptDaFicha,
700
+ externalCost: args.externalCost,
701
+ characterId: args.characterId,
702
+ characterIds: args.characterIds,
703
+ referenceImageIds: args.referenceImageIds,
704
+ aspectRatio: args.aspectRatio ?? (typeof input.aspect_ratio === "string" ? input.aspect_ratio : undefined),
705
+ size: args.size ?? (typeof input.resolution === "string" ? input.resolution : undefined),
706
+ unfiltered: args.unfiltered,
707
+ skills: args.skills,
708
+ },
709
+ };
710
+ // Gravado ANTES de esperar: se o cliente desistir no meio, o job continua
711
+ // aqui e o status termina, em vez de alguém gerar (e pagar) de novo.
712
+ await salvarJob(job);
713
+ const atual = await avancar(job, args.aguardarSegundos ?? ESPERA_PADRAO_GERAR_S);
714
+ return resumo(atual);
715
+ }
716
+ async function status(args) {
717
+ if (!args.jobId)
718
+ throw new Error("action=status exige jobId (o pj_... que o gerar devolveu; action=jobs lista).");
719
+ const job = lerJobs().find((j) => j.id === args.jobId);
720
+ if (!job) {
721
+ throw new Error(`Job ${args.jobId} não está nesta máquina. Os jobs moram em ${arquivoDeJobs()} do computador que gerou; liste com action=jobs.`);
722
+ }
723
+ if (args.ingerir === true)
724
+ job.ingerir = true;
725
+ const atual = await avancar(job, args.aguardarSegundos ?? ESPERA_PADRAO_STATUS_S);
726
+ return resumo(atual);
727
+ }
728
+ function balcoes() {
729
+ return {
730
+ regra: "A chave mora no env do config do seu cliente MCP, nesta máquina, e sai daqui só pro provedor. A casa não recebe, não guarda e não vê. Gerar com a sua chave custa zero em Sinapses: você paga o preço do provedor, sem margem da casa.",
731
+ balcoes: Object.keys(BALCOES).map((k) => {
732
+ const v = process.env[BALCOES[k].envVar]?.trim();
733
+ return {
734
+ provedor: k,
735
+ nome: BALCOES[k].nome,
736
+ variavel: BALCOES[k].envVar,
737
+ configurada: Boolean(v && v.length >= 8),
738
+ };
739
+ }),
740
+ comoConfigurar: `No config do cliente MCP, no bloco do sapiens: "env": { "FAL_KEY": "<sua chave da fal>", "KIE_API_KEY": "<sua chave da Kie>" }. Depois reinicie o cliente. Passo a passo por cliente: ${ENDERECO_SUAS_CHAVES}`,
741
+ ondeAcharOModelo: "fal: o id do endpoint na página do modelo em fal.ai/models (ex: 'fal-ai/flux/dev'). Kie: o 'model' da página do modelo no Market da Kie. O input é o corpo que a mesma página descreve.",
742
+ outrosBalcoes: "Sogni, Krea, Magnific, WaveSpeed, Replicate e a própria placa seguem pelo seu harness + sapiens_gallery action=ingest (catálogo em sapiens_meta action=pontes).",
743
+ };
744
+ }
745
+ function jobs(args) {
746
+ const lista = lerJobs().slice(-(args.limit ?? 10)).reverse();
747
+ return {
748
+ arquivo: arquivoDeJobs(),
749
+ count: lista.length,
750
+ jobs: lista.map((j) => ({
751
+ jobId: j.id,
752
+ provedor: j.provedor,
753
+ modelo: j.modelo,
754
+ estado: j.estado,
755
+ criadoEm: new Date(j.criadoEm).toISOString(),
756
+ pecas: j.pecas?.filter((p) => p.imageId).map((p) => p.imageId),
757
+ custo: j.custo,
758
+ })),
759
+ };
760
+ }
761
+ async function pontesInterno(args) {
762
+ if (isRemoteContext()) {
763
+ throw new Error("sapiens_pontes só roda no MCP instalado (sapiens-mcp no seu computador): a chave mora na sua máquina e o conector remoto não recebe chave nenhuma. Aqui, gere em Sinapse (sapiens_image, sapiens_video) ou instale o MCP local: https://www.sapiensinteticos.com/conectar-claude");
764
+ }
765
+ switch (args.action) {
766
+ case "balcoes":
767
+ return balcoes();
768
+ case "gerar":
769
+ return gerar(args);
770
+ case "status":
771
+ return status(args);
772
+ case "jobs":
773
+ return jobs(args);
774
+ }
775
+ }
776
+ export async function pontes(args) {
777
+ try {
778
+ return limpo(await pontesInterno(args));
779
+ }
780
+ catch (e) {
781
+ throw new Error(semChave(describeConvexError(e)));
782
+ }
783
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sapiens-mcp",
3
- "version": "1.78.1",
3
+ "version": "1.79.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",
@@ -36,7 +36,7 @@
36
36
  "dev": "tsx src/index.ts",
37
37
  "start": "node dist/index.js",
38
38
  "pretest": "npm run build",
39
- "test": "node --test --test-force-exit test/unit.test.mjs test/server.test.mjs test/remote.test.mjs test/remote-protect.test.mjs",
39
+ "test": "node --test --test-force-exit test/unit.test.mjs test/server.test.mjs test/remote.test.mjs test/remote-protect.test.mjs test/pontes.test.mjs",
40
40
  "prepublishOnly": "npm run build"
41
41
  },
42
42
  "repository": {