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 +29 -1
- package/dist/pisoDeIdade.js +145 -0
- package/dist/registry.js +18 -3
- package/dist/remote.js +1 -1
- package/dist/skills.js +19 -5
- package/dist/tools/distribution.js +1 -1
- package/dist/tools/helen.js +8 -2
- package/dist/tools/meta.js +16 -5
- package/dist/tools/pontes.js +783 -0
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|

|
|
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
|
|
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).
|
|
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
|
-
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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,
|
|
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()
|
package/dist/tools/helen.js
CHANGED
|
@@ -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("
|
|
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
|
}
|
package/dist/tools/meta.js
CHANGED
|
@@ -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
|
|
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 é
|
|
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
|
|
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(", ")}.
|
|
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.
|
|
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": {
|