primocode 9.8.0-beta.8 → 9.8.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.
Files changed (42) hide show
  1. package/README.md +3 -2
  2. package/bin/primocode.js +45 -18
  3. package/lib/act.js +16 -9
  4. package/lib/api.js +8 -1
  5. package/lib/catalogo.js +11 -3
  6. package/lib/claude-engine.js +25 -3
  7. package/lib/estudio/acervo-site.js +157 -0
  8. package/lib/estudio/assets.js +77 -3
  9. package/lib/estudio/cinema.js +399 -0
  10. package/lib/estudio/composicao.js +470 -0
  11. package/lib/estudio/edicao.js +450 -0
  12. package/lib/estudio/impacto.js +263 -0
  13. package/lib/estudio/index.js +854 -5
  14. package/lib/estudio/plano.js +5 -2
  15. package/lib/estudio/remotion.js +228 -23
  16. package/lib/estudio/slides.js +34 -30
  17. package/lib/local/cerebro.json +361 -180
  18. package/lib/local/entrada.js +18 -8
  19. package/lib/local/motor-cli.js +1 -1
  20. package/lib/offline.js +168 -0
  21. package/package.json +2 -3
  22. package/skills/design-de-verdade/SKILL.md +2 -0
  23. package/skills/motion-de-primeira/SKILL.md +1 -1
  24. package/skills/site-com-motion/SKILL.md +39 -0
  25. package/studio/narrar.py +111 -10
  26. package/studio/rostos.py +52 -0
  27. package/kits/acolhedor/index.html +0 -130
  28. package/kits/acolhedor/script.js +0 -25
  29. package/kits/acolhedor/style.css +0 -146
  30. package/kits/editorial/index.html +0 -115
  31. package/kits/editorial/script.js +0 -19
  32. package/kits/editorial/style.css +0 -112
  33. package/kits/landing/index.html +0 -162
  34. package/kits/landing/script.js +0 -199
  35. package/kits/landing/style.css +0 -219
  36. package/kits/premium/index.html +0 -83
  37. package/kits/premium/script.js +0 -11
  38. package/kits/premium/style.css +0 -87
  39. package/kits/vitrine/index.html +0 -108
  40. package/kits/vitrine/script.js +0 -20
  41. package/kits/vitrine/style.css +0 -117
  42. package/lib/kits.js +0 -206
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v9.8.0-beta.8
1
+ # PrimoCode v9.8.0-beta.23
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v9.8.0-beta.8
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.23
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -311,6 +311,7 @@ primocode -c # continua a última conversa salva (--co
311
311
  | `/copiar` | Copia a última resposta para a área de transferência (pbcopy, clip, wl-copy/xclip/xsel) |
312
312
  | `/historico [termo]` | Procura nos seus pedidos guardados nesta máquina (`~/.primocode/history.jsonl`), do mais recente. As conversas inteiras da conta ficam no `/chats` |
313
313
  | `/status` | Health check do servidor |
314
+ | `/offline` | Temporário: sobe o `primocode-server` no seu computador (a pasta ao lado do CLI, ou `/offline <caminho>`) com as chaves de `~/.primocode/servidor.env` e aponta a sessão pra ele. `/offline off` volta ao servidor de antes |
314
315
  | `/doctor` | Confere tudo com ✓/✗: Node, servidor, `/api`, Chrome, Python+PyObjC (cursor, macOS), disco e config. Cada ✗ diz como consertar |
315
316
  | `/custo` | Tokens enviados e recebidos e pedidos feitos nesta sessão, por provedor (estimado em caracteres ÷ 4 quando o provedor não manda o número) |
316
317
  | `/atalhos` | As teclas: Esc Esc para o agente, `!` roda no shell, Alt+Enter quebra linha… |
package/bin/primocode.js CHANGED
@@ -21,7 +21,6 @@ const api = require('../lib/api');
21
21
  const tools = require('../lib/tools');
22
22
  const cfg = require('../lib/config');
23
23
  const nomeProjeto = require('../lib/nome-projeto');
24
- const kits = require('../lib/kits');
25
24
  const provedorLocal = require('../lib/local/provedor');
26
25
  const { runAct, interromper, ehTetoDeToken, ehTransitorio, esperaDoErro } = require('../lib/act');
27
26
  const fala = require('../lib/fala');
@@ -44,6 +43,7 @@ const nuvem = require('../lib/nuvem');
44
43
  const compat = require('../lib/compatibilidade');
45
44
  const mencoesLib = require('../lib/mencoes');
46
45
  const extra = require('../lib/comandos-extra');
46
+ const offline = require('../lib/offline');
47
47
 
48
48
  /* O /custo: toda conversa com o modelo passa pelo lib/api.js, e o contador
49
49
  embrulha as funções dele uma vez, aqui. Quem atende é o provedor do /api
@@ -186,6 +186,7 @@ ${c.bold('Comandos')}
186
186
  ${c.brand('/logout')} desconecta este computador da conta
187
187
  ${c.brand('/comandos')} seus comandos (/revisar, /explicar…) · /comandos novo <nome>
188
188
  ${c.brand('/status')} checa o servidor
189
+ ${c.brand('/offline')} roda o servidor no seu computador (temporário) · /offline off volta
189
190
  ${c.brand('/doctor')} confere tudo (servidor, /api, Chrome, disco…) e diz como consertar
190
191
  ${c.brand('/custo')} tokens e pedidos desta sessão, por provedor
191
192
  ${c.brand('/atalhos')} as teclas que ajudam
@@ -251,6 +252,7 @@ const SLASH_COMANDOS = [
251
252
  ['/comandos', 'seus comandos: um .md vira /nome · /comandos novo <nome>'],
252
253
  ['/api', 'usa a SUA chave (Gemini, ChatGPT, Claude…) direto daqui, sem o limite do servidor'],
253
254
  ['/status', 'checa o servidor'],
255
+ ['/offline', 'roda o servidor aqui no seu computador, com as chaves do Render'],
254
256
  ['/doctor', 'confere se está tudo certo e diz como consertar'],
255
257
  ['/custo', 'tokens e pedidos desta sessão, por provedor'],
256
258
  ['/atalhos', 'as teclas que ajudam'],
@@ -769,16 +771,6 @@ function batizarDepois(plan, userText) {
769
771
  .then((nome) => { if (nome) plan.nomeEscolhido = nome; });
770
772
  }
771
773
 
772
- /* A inspiração visual (lib/kits.js): pedido de landing leva a ficha da
773
- direção que combina com o negócio. Nada é copiado para a pasta — o agente
774
- escreve a página dele, inspirado, e não troca texto num molde. */
775
- async function prepararKit(plan, userText) {
776
- const kit = kits.escolher(userText, { provedor: provedorLocal.atual() });
777
- if (!kit) return '';
778
- console.log(c.dim(` ⎿ inspiração visual: ${kit.titulo}`));
779
- return kit.instrucao;
780
- }
781
-
782
774
  function createPendingProject(plan) {
783
775
  const dir = cfg.createProjectDir(plan.nomeEscolhido || plan.name);
784
776
  entrarNoProjeto(dir, 'projeto');
@@ -1145,7 +1137,6 @@ async function enviarPedido(userText) {
1145
1137
  tempos.medir('espera do resumo', () => esperarResumo()),
1146
1138
  tempos.medir('plano', () => dividirTarefa(prompt)),
1147
1139
  ]);
1148
- const instrucaoKit = await prepararKit(plan, userText);
1149
1140
 
1150
1141
  let r = null;
1151
1142
  let jaFeito = '';
@@ -1194,8 +1185,7 @@ async function enviarPedido(userText) {
1194
1185
  permissoes: state.config.permissoes,
1195
1186
  // Pedido de mexer no computador leva a dica que libera as
1196
1187
  // ferramentas desktop_* no servidor (ver DICA_COMPUTADOR).
1197
- instrucao: [instrucaoKit, i === 0 && querComputador(userText) ? vozModo.DICA_COMPUTADOR : '']
1198
- .filter(Boolean).join('\n\n'),
1188
+ instrucao: i === 0 && querComputador(userText) ? vozModo.DICA_COMPUTADOR : '',
1199
1189
  });
1200
1190
 
1201
1191
  r = await rodarPasso();
@@ -2666,6 +2656,45 @@ async function handleSlash(text) {
2666
2656
  break;
2667
2657
  }
2668
2658
 
2659
+ /* O servidor aqui, no computador da pessoa, até a sessão acabar
2660
+ (lib/offline.js). "Enquanto não tenho acesso ao Railway, eu testo
2661
+ com o projeto rodando localmente." (24/09/2026) */
2662
+ case 'offline': case 'local': {
2663
+ if (/^(off|sair|desligar|parar)$/i.test(rest)) {
2664
+ if (!state.servidorAntes) { console.log(c.muted(' O /offline não está ligado.')); break; }
2665
+ offline.desligar();
2666
+ state.config.server = state.servidorAntes;
2667
+ state.servidorAntes = null;
2668
+ console.log(c.ok(' Voltei ao servidor de antes: ') + c.dim(state.config.server));
2669
+ break;
2670
+ }
2671
+ if (state.servidorAntes) { console.log(c.muted(' Já está no servidor local: ') + c.dim(state.config.server) + c.muted(' · /offline off volta')); break; }
2672
+ const chaves = offline.prepararEnv();
2673
+ if (chaves.criado || !chaves.temChave) {
2674
+ console.log(c.warn(` ${chaves.criado ? 'Criei' : 'Falta chave em'} ${chaves.arquivo}`));
2675
+ console.log(c.muted(' Cole nele as chaves do Render (primocode-server → Environment), uma por linha:'));
2676
+ console.log(c.dim(' GROQ_API_KEY=… OPENROUTER_API_KEY=… GEMINI_API_KEY=…'));
2677
+ console.log(c.muted(' Depois rode ') + c.brand('/offline') + c.muted(' de novo.'));
2678
+ break;
2679
+ }
2680
+ const dir = offline.acharServidor({ pedido: rest });
2681
+ const sp = new Spinner('subindo o servidor aqui').start();
2682
+ const r = await offline.ligar({ dir, env: chaves.env });
2683
+ sp.stop();
2684
+ if (!r.ok) {
2685
+ console.log(c.err(' ' + r.error));
2686
+ if (r.comoResolver) console.log(c.dim(' → ' + r.comoResolver));
2687
+ break;
2688
+ }
2689
+ state.servidorAntes = state.config.server;
2690
+ state.config.server = r.url;
2691
+ console.log(c.ok(` Servidor ${r.versao || ''} ${r.reaproveitado ? 'que já estava de pé' : 'rodando aqui'}: `) + c.dim(r.url));
2692
+ console.log(c.dim(` ${dir || ''}${dir ? ' · ' : ''}chaves: ${chaves.nomes.join(', ')}${r.reaproveitado ? '' : ' · log em ' + offline.ARQUIVO_LOG}`));
2693
+ if (provedorLocal.atual()) console.log(c.warn(' Atenção: o /api está ligado e fala direto com a IA. Para usar o servidor local, /api off.'));
2694
+ console.log(c.muted(' Vale até fechar o PrimoCode. ') + c.brand('/offline off') + c.muted(' volta antes.'));
2695
+ break;
2696
+ }
2697
+
2669
2698
  /* Os comandos da pessoa: lista, e explica como fazer o dela. */
2670
2699
  case 'comandos': {
2671
2700
  const novo = rest.match(/^(novo|new|criar)\s+(\S+)$/i);
@@ -2867,10 +2896,8 @@ async function main() {
2867
2896
  // Com -c, o pedido continua a última conversa em vez de chegar sozinho.
2868
2897
  if (continuar) await continuarUltima();
2869
2898
  /* O MESMO caminho do pedido digitado (sendPrompt). Este modo tinha um
2870
- runAct próprio, e por isso ficava sem tudo o que vive lá: o kit de
2871
- partida, o plano em passos, a retomada automática, o @arquivo, o
2872
- checkpoint do /desfazer. Medido em 24/09: `primo "crie uma landing…"`
2873
- não pegou o kit. */
2899
+ runAct próprio, e por isso ficava sem tudo o que vive lá: o plano em
2900
+ passos, a retomada automática, o @arquivo, o checkpoint do /desfazer. */
2874
2901
  state.semTerminal = true;
2875
2902
  await sendPrompt(promptText);
2876
2903
  process.exit(0);
package/lib/act.js CHANGED
@@ -692,15 +692,19 @@ async function runAct(userPrompt, opts) {
692
692
 
693
693
  Um modelo não chama tool fora da lista por conta própria. Só
694
694
  chamou porque este parágrafo mandou. */
695
+ /* O caminho do ROTEIRO (lib/estudio/composicao.js). Antes esta
696
+ ordem mandava chamar o diretor e depois compor "cena a cena,
697
+ com as primitivas" — o modelo escrevia posição em pixel e o
698
+ vídeo saía quebrado, além de custar três ou quatro rodadas a
699
+ mais de modelo (medido em 25/09). */
695
700
  messages.push({ role: 'system', content:
696
- 'ESTE PEDIDO É DE PRODUÇÃO AUDIOVISUAL. Se `planejar_producao` estiver na sua lista de '
697
- + `ferramentas, chame-o ANTES de criar qualquer peça (formato "${producao.formato}"): `
698
- + 'ele devolve a pauta — arco, narração falada, voz, direção de arte e a mídia a buscar — '
699
- + 'e é ela que você produz, cena a cena, compondo com as primitivas. '
700
- + 'Se ele NÃO estiver na sua lista, não o chame e não procure equivalente: siga com as '
701
- + 'ferramentas que você tem e escreva o roteiro antes das cenas, não dentro do JSON delas. '
702
- + 'Nunca chame uma ferramenta que não está na sua lista — a chamada é recusada e a tarefa '
703
- + 'se perde.' });
701
+ `ESTE PEDIDO É DE PRODUÇÃO (formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}"). Faça em três passos: `
702
+ + `estudio_projeto acao:"criar" com formato "${producao.formato === 'apresentacao' ? 'slides' : 'video'}" → `
703
+ + 'estudio_plano com "roteiro" (partes com tipo e fala; o PrimoCode faz layout, voz, ícones, música e sons) → '
704
+ + 'estudio_render. Se a peça for de uma marca de verdade, estudio_site antes, e use as cores e a logo em roteiro.marca. '
705
+ + 'NÃO escreva cenas, elementos nem posições à mão. '
706
+ + 'Se estudio_plano não aceitar "roteiro" (servidor antigo), siga com as ferramentas que você tem. '
707
+ + 'Nunca chame uma ferramenta que não está na sua lista — a chamada é recusada e a tarefa se perde.' });
704
708
  }
705
709
  }
706
710
 
@@ -1167,7 +1171,10 @@ async function runAct(userPrompt, opts) {
1167
1171
  if (checkpoint.MUDAM_ARQUIVO.has(tc.name)) {
1168
1172
  try { checkpoint.antesDeMudar(tools.resolveInProject(tc.arguments && tc.arguments.path, pastaAtual), pastaAtual); } catch {}
1169
1173
  }
1170
- const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
1174
+ /* O servidor, o token e o cliente da API vão junto: a legenda do
1175
+ estudio_video transcreve no servidor, e sem eles a ferramenta
1176
+ respondia "a transcrição precisa do servidor". */
1177
+ const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual, server, token, api });
1171
1178
  logResult(tc.name, result, profundidade);
1172
1179
  repeticao.executou(jaChamado, tc.name, tc.arguments);
1173
1180
  if (RELEITURA.has(tc.name) && result && result.ok !== false) {
package/lib/api.js CHANGED
@@ -268,6 +268,13 @@ function getConectores(serverUrl, token) {
268
268
  return pedirGet(serverUrl, '/api/conectores', token);
269
269
  }
270
270
 
271
+ /* Foto e vídeo de apoio do Pexels, pela chave do servidor (ninguém precisa
272
+ ter uma). tipo: "imagens" | "videos". */
273
+ function getMidia(serverUrl, token, tipo, { q, n, orientacao } = {}) {
274
+ const qs = new URLSearchParams({ q: q || '', n: String(n || 6), ...(orientacao ? { orientacao } : {}) });
275
+ return pedirGet(serverUrl, `/api/midia/${tipo}?${qs}`, token);
276
+ }
277
+
271
278
  function getHealth(serverUrl, token) {
272
279
  return pedirGet(serverUrl, '/health', token);
273
280
  }
@@ -300,4 +307,4 @@ function pedirGet(serverUrl, rota, token) {
300
307
  });
301
308
  }
302
309
 
303
- module.exports = { request, requestStream, requestAct, requestChat, requestResumo, requestRefino, requestPlano, requestTranscricao, getHealth, getConectores };
310
+ module.exports = { request, requestStream, requestAct, requestChat, requestResumo, requestRefino, requestPlano, requestTranscricao, getHealth, getConectores, getMidia };
package/lib/catalogo.js CHANGED
@@ -84,7 +84,11 @@ const GRUPOS = [
84
84
  + 'SEMPRE procure antes de criar quando o pedido falar de algo que já existe.' },
85
85
 
86
86
  { tool: 'estudio_plano', escreve: true, faz: 'escreve ou ajusta o que o vídeo É — cenas, elementos, animação, câmera, áudio',
87
- args: 'id. plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
87
+ args: 'id. PREFIRA roteiro: {marca:{cores:{fundo,destaque,tinta},fontes:{titulo,texto},logo}, proporcao, '
88
+ + 'partes:[{tipo, fala, …}]} — o PrimoCode faz o layout, a voz com entonação, os ícones, a música e os sons. '
89
+ + 'Tipos: abertura{titulo,sub} titulo{titulo,sub} numero{valor,rotulo,sub} lista{titulo,itens} passos{titulo,itens} '
90
+ + 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link}. '
91
+ + 'voz (thalita|antonio|francisca). Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
88
92
  + 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
89
93
  + 'motivo (opcional): fica no caderno. voltarPara (opcional): nome de uma versão. '
90
94
  + 'Sem plano nem cenas, LÊ o plano atual. '
@@ -104,7 +108,10 @@ const GRUPOS = [
104
108
  + 'Use os tempos que voltam para a duração das cenas — estimar por número de palavras erra em segundos.' },
105
109
 
106
110
  { tool: 'estudio_video', escreve: true, faz: 'o que se faz com a gravação do usuário: importar, medir, cortar silêncio, transcrever, recortar a pessoa',
107
- args: 'id. acao: importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
111
+ args: 'id. acao: editar | importar | medir | silencio | cortar-silencio | quadro | legendas | recortar-pessoa. '
112
+ + 'editar: FAZ A EDIÇÃO INTEIRA sozinho — corta o silêncio, transcreve palavra por palavra e monta o plano '
113
+ + '(legenda animada, ícone na hora da palavra, zoom de corte, pop e whoosh). estilo: viral (padrão) | limpo. '
114
+ + 'Com gravação do usuário, é o PRIMEIRO passo depois de importar; depois só estudio_render. '
108
115
  + 'arquivo: caminho (em "importar", de fora; nas outras, o relativo dentro do projeto). '
109
116
  + 'cortar-silencio: minimoParaCortar (padrão 0.6s), respiro (padrão 0.18s) — os dois existem para o corte não soar robótico. '
110
117
  + 'legendas: devolve os trechos com tempo, para virarem elemento "legenda". '
@@ -119,7 +126,8 @@ const GRUPOS = [
119
126
 
120
127
  { tool: 'estudio_site', escreve: false, faz: 'lê a identidade visual de uma marca: cores, fontes, logo e o que ela diz de si',
121
128
  args: 'url — OU marca: o nome da empresa, quando não há link ("faça um vídeo para a Paris Group"). '
122
- + 'id (opcional): anota no caderno do projeto. '
129
+ + 'id (opcional): anota no caderno do projeto — e com id traz o ACERVO da marca: as fotos e os vídeos '
130
+ + 'reais do site (que entram sozinhos no vídeo) e os FATOS das páginas (datas, local, números), para o roteiro. '
123
131
  + 'Devolve FATOS medidos, não um roteiro — o roteiro é seu. '
124
132
  + 'Use SEMPRE que o pedido citar uma empresa de verdade: inventar a cor de uma marca que existe '
125
133
  + 'é erro que sai pronto no vídeo entregue. '
@@ -61,6 +61,28 @@ function argsDoModelo() {
61
61
  return MODELO_CLAUDE ? ['--model', MODELO_CLAUDE] : [];
62
62
  }
63
63
 
64
+ /* O Claude Code ENXUTO, como motor. Sem isto cada passo do agente abria o
65
+ * Claude Code da pessoa inteiro: hooks, plugins, skills, CLAUDE.md dela e as
66
+ * ferramentas nativas — 36 mil tokens e ~6 s por chamada, e a resposta
67
+ * chegava com recado de hook pessoal ("context-sync pendente"). Medido em
68
+ * 25/09: com estes argumentos, a mesma chamada leva 473 tokens e ~3,5 s. Um
69
+ * pedido de 100 passos pagava aquilo 100 vezes — é a demora de 20 minutos.
70
+ *
71
+ * Nada disto tira o que o motor precisa: o protocolo vai inteiro no prompt
72
+ * (corpoDoProtocolo), e as ferramentas que valem são as do PrimoCode, não as
73
+ * do Claude Code. */
74
+ const ARGS_ENXUTOS = [
75
+ '--setting-sources', 'project',
76
+ '--strict-mcp-config',
77
+ '--disable-slash-commands',
78
+ '--tools', '',
79
+ '--no-session-persistence',
80
+ '--system-prompt', 'Você é o motor de decisão do PrimoCode. Siga à risca o protocolo que vem na mensagem e responda só com o bloco pedido.',
81
+ ];
82
+ function argsDoMotor() {
83
+ return [...ARGS_ENXUTOS, ...argsDoModelo()];
84
+ }
85
+
64
86
  /**
65
87
  * Acha o ÚLTIMO bloco marcado no texto (pode vir ruído de terminal — prompt
66
88
  * ANSI, texto solto — antes e depois) e devolve o JSON de dentro dele.
@@ -567,7 +589,7 @@ async function requestActViaHeadless(payload, onEvent) {
567
589
  '-p',
568
590
  '--output-format', 'json',
569
591
  '--dangerously-skip-permissions',
570
- ...argsDoModelo(),
592
+ ...argsDoMotor(),
571
593
  ], { encoding: 'utf-8', timeout: 180000, input: prompt, maxBuffer: 32 * 1024 * 1024,
572
594
  env: process.env, ...SEM_APARECER });
573
595
  const r = JSON.parse(saida);
@@ -577,7 +599,7 @@ async function requestActViaHeadless(payload, onEvent) {
577
599
  const pedirCorrecao = (aviso) => {
578
600
  const s = execFileSync('claude', [
579
601
  '-p', '--output-format', 'json', '--dangerously-skip-permissions',
580
- ...argsDoModelo(),
602
+ ...argsDoMotor(),
581
603
  ], {
582
604
  encoding: 'utf-8', timeout: 120000, maxBuffer: 32 * 1024 * 1024,
583
605
  env: process.env,
@@ -622,7 +644,7 @@ module.exports = {
622
644
  // o que veio torto. Reimplementar aquilo no outro motor seria manter dois
623
645
  // protocolos que divergem na primeira correção de um deles.
624
646
  linhaDoHistorico, nomesDasTools, promptDoProtocolo, promptDeCorrecao,
625
- MODELO_CLAUDE, argsDoModelo,
647
+ MODELO_CLAUDE, argsDoModelo, argsDoMotor,
626
648
  SKILL_DIR, SKILL_PATH, garantirSkill,
627
649
  temClaudeCode, temOrca, orcaAlcancavel, escolherCaminho,
628
650
  garantirEngine, requestAct, emitirDecisao,
@@ -0,0 +1,157 @@
1
+ /**
2
+ * acervo-site.js — as fotos, os vídeos e os fatos da PRÓPRIA marca, tirados
3
+ * do site dela.
4
+ *
5
+ * "Eu pedi um vídeo pra Paris Group pra anunciar a imersão: ele poderia
6
+ * puxar imagens das imersões, vídeos, fotos, etc. e colocar no decorrer do
7
+ * vídeo." · "Os roteiros são nada a ver." (25/09/2026)
8
+ *
9
+ * O roteiro saía inventado ("sem data, preço nem local", anotou o próprio
10
+ * modelo) e as imagens eram de banco. O site da marca tem as duas coisas: a
11
+ * página da imersão diz "6 a 8 out · Chapecó/SC · 20 cadeiras", e a galeria
12
+ * tem as fotos da sala. Aqui se lê a página inicial e as páginas internas
13
+ * que combinam com o pedido, e sai:
14
+ *
15
+ * - fotos: as maiores que o site oferece (o maior "w" do srcset), sem logo,
16
+ * ícone, SVG nem miniatura;
17
+ * - vídeos: os .mp4 que a página toca (depoimento, bastidor);
18
+ * - fatos: o texto de cada página, limpo — a matéria-prima do roteiro.
19
+ */
20
+
21
+ 'use strict';
22
+
23
+ const { URL } = require('url');
24
+
25
+ const PALAVRAS_BOAS = ['galeria', 'imers', 'evento', 'sobre', 'about', 'portfolio', 'cases', 'case', 'turma', 'curso', 'produto', 'servic', 'equipe', 'time'];
26
+ const RUIM = /(logo|icon|favicon|sprite|avatar|emoji|badge|\/brand\/|placeholder|blank|pixel|\.svg(\?|$)|-sm\.|\.sm\.|thumb)/i;
27
+
28
+ function limpar(t) { return String(t || '').replace(/\s+/g, ' ').trim(); }
29
+
30
+ /** O texto da página, sem script, estilo, menu nem rodapé repetido. */
31
+ function textoDaPagina(html) {
32
+ let t = String(html || '')
33
+ .replace(/<script[\s\S]*?<\/script>|<style[\s\S]*?<\/style>|<noscript[\s\S]*?<\/noscript>/gi, ' ')
34
+ .replace(/<(nav|footer|header)[\s\S]*?<\/\1>/gi, ' ')
35
+ .replace(/<br\s*\/?>|<\/(p|h\d|li|div|section)>/gi, '. ')
36
+ .replace(/<[^>]+>/g, ' ');
37
+ t = t.replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#39;/g, "'").replace(/&[a-z]+;/g, ' ');
38
+ return limpar(t).replace(/(\.\s*){2,}/g, '. ').slice(0, 2200);
39
+ }
40
+
41
+ /** A versão maior de cada imagem: no srcset, o maior "w" até 2000. */
42
+ function maiorDoSrcset(srcset) {
43
+ let melhor = null, largura = 0;
44
+ for (const parte of String(srcset).split(',')) {
45
+ const [u, w] = parte.trim().split(/\s+/);
46
+ const n = parseInt(w, 10) || 1;
47
+ if (u && n <= 2000 && n >= largura) { melhor = u; largura = n; }
48
+ }
49
+ return melhor;
50
+ }
51
+
52
+ // A mesma foto em tamanhos diferentes (x.sm.webp, x.md.webp, x.lg.webp) é UMA foto.
53
+ const chaveDe = (u) => u.replace(/[?#].*$/, '').replace(/\.(sm|md|lg|xl|\d{2,4}w?)(?=\.[a-z]+$)/i, '').replace(/-\d{3,4}(?=\.[a-z]+$)/, '');
54
+
55
+ function colherMidia(html, base) {
56
+ const abs = (u) => { try { return new URL(String(u).replace(/\\\//g, '/'), base).toString(); } catch { return null; } };
57
+ const fotos = new Map(), videos = new Map();
58
+ const pesoDe = (u) => (/\.lg\.|1800w|1920|2000/.test(u) ? 3 : /\.md\.|1280/.test(u) ? 2 : 1);
59
+ const poe = (mapa, u, alt = '') => {
60
+ const a = abs(u);
61
+ if (!a || RUIM.test(a) || a.startsWith('data:')) return;
62
+ const k = chaveDe(a);
63
+ const atual = mapa.get(k);
64
+ // Sem alt, o nome do arquivo descreve a foto ("palestrante-plateia-mesas").
65
+ const doNome = decodeURIComponent(a.split('/').pop().replace(/\.[a-z0-9]+$/i, '').replace(/\.(sm|md|lg)$/i, ''))
66
+ .replace(/^\d+[-_]?/, '').replace(/[-_]+/g, ' ').replace(/\b(dsc|img|hero)\b\s*\d*/gi, '').trim();
67
+ if (!atual || pesoDe(a) > pesoDe(atual.url)) mapa.set(k, { url: a, alt: limpar(alt).slice(0, 120) || (atual && atual.alt) || doNome });
68
+ };
69
+ let m;
70
+ const reImg = /<img\b[^>]*>/gi;
71
+ while ((m = reImg.exec(html))) {
72
+ const tag = m[0];
73
+ const alt = (tag.match(/\balt=["']([^"']*)/i) || [])[1] || '';
74
+ const srcset = (tag.match(/\b(?:srcset|data-srcset)=["']([^"']+)/i) || [])[1];
75
+ const src = (srcset && maiorDoSrcset(srcset)) || (tag.match(/\b(?:data-src|src)=["']([^"']+)/i) || [])[1];
76
+ if (src && /\.(jpe?g|png|webp|avif)(\?|$)/i.test(src)) poe(fotos, src, alt);
77
+ }
78
+ // Sites em Next/React guardam a galeria num JSON dentro da página: o
79
+ // caminho da mídia aparece como texto, não como <img>.
80
+ const reSolto = /["'(]((?:https?:)?\/[^"'()\s]+?\.(?:jpe?g|png|webp|avif|mp4|webm))(?:\?[^"'()\s]*)?["')]/gi;
81
+ while ((m = reSolto.exec(html))) {
82
+ const u = m[1];
83
+ if (/\.(mp4|webm)$/i.test(u)) poe(videos, u);
84
+ else poe(fotos, u);
85
+ }
86
+ const og = (html.match(/<meta[^>]+property=["']og:image["'][^>]+content=["']([^"']+)/i) || [])[1];
87
+ if (og) poe(fotos, og, 'capa do site');
88
+ return { fotos: [...fotos.values()], videos: [...videos.values()] };
89
+ }
90
+
91
+ /** As páginas internas que valem a leitura para ESTE pedido. */
92
+ function paginasRelevantes(html, base, pedido, max = 5) {
93
+ const host = new URL(base).hostname;
94
+ const termos = String(pedido || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
95
+ .split(/[^a-z0-9]+/).filter((w) => w.length >= 4);
96
+ const vistos = new Set();
97
+ const lista = [];
98
+ const re = /href=["']([^"'#]+)["']/gi;
99
+ let m;
100
+ while ((m = re.exec(html))) {
101
+ let u;
102
+ try { u = new URL(m[1], base); } catch { continue; }
103
+ if (u.hostname !== host || /\.(css|js|png|jpe?g|svg|webp|pdf|ico|xml|json)$/i.test(u.pathname)) continue;
104
+ const caminho = u.pathname.replace(/\/$/, '');
105
+ if (!caminho || vistos.has(caminho) || /privacidade|privacy|termos|terms|login|blog\/|cdn-cgi/.test(caminho)) continue;
106
+ vistos.add(caminho);
107
+ const c = caminho.toLowerCase();
108
+ const pontos = termos.filter((t) => c.includes(t.slice(0, 6))).length * 3
109
+ + PALAVRAS_BOAS.filter((w) => c.includes(w)).length;
110
+ if (pontos > 0) lista.push({ url: u.toString(), pontos });
111
+ }
112
+ return lista.sort((a, b) => b.pontos - a.pontos).slice(0, max).map((x) => x.url);
113
+ }
114
+
115
+ /**
116
+ * Lê o site e baixa o acervo para a pasta do projeto.
117
+ *
118
+ * @param {object} op
119
+ * @param {string} op.url
120
+ * @param {string} op.pedido o que o usuário pediu (decide quais páginas ler)
121
+ * @param {string} op.pasta a pasta do projeto
122
+ * @param {function} op.buscar (url) → {corpo} — o leitor de página do identidade.js
123
+ * @param {function} op.baixar (item, {pasta, nome, sub}) → {ok, relativo} — o do assets.js
124
+ */
125
+ async function colher({ url, pedido = '', pasta, buscar, baixar, maxFotos = 16, maxVideos = 4, htmlInicial = null }) {
126
+ const paginas = [];
127
+ const inicial = htmlInicial || (await buscar(url)).corpo;
128
+ paginas.push({ url, html: inicial });
129
+ for (const u of paginasRelevantes(inicial, url, pedido)) {
130
+ try { paginas.push({ url: u, html: (await buscar(u)).corpo }); } catch { /* página que não abriu fica de fora */ }
131
+ }
132
+
133
+ const fatos = paginas.map((p) => ({ pagina: p.url, texto: textoDaPagina(p.html) })).filter((f) => f.texto.length > 80);
134
+ const fotosVistas = new Map(), videosVistos = new Map();
135
+ // As páginas internas primeiro: a galeria tem mais foto de verdade que a home.
136
+ for (const p of [...paginas.slice(1), paginas[0]]) {
137
+ const { fotos, videos } = colherMidia(p.html, p.url);
138
+ for (const f of fotos) if (!fotosVistas.has(chaveDe(f.url))) fotosVistas.set(chaveDe(f.url), { ...f, pagina: p.url });
139
+ for (const v of videos) if (!videosVistos.has(chaveDe(v.url))) videosVistos.set(chaveDe(v.url), { ...v, pagina: p.url });
140
+ }
141
+
142
+ const fotos = [], videos = [];
143
+ for (const f of fotosVistas.values()) {
144
+ if (fotos.length >= maxFotos) break;
145
+ const d = await baixar({ url: f.url, titulo: f.alt || 'foto da marca', licenca: 'da própria marca', fonte: 'site' }, { pasta, nome: 'marca-foto', sub: 'midia/marca' });
146
+ // Menos de 25 KB é ícone ou miniatura, mesmo sem cara de ícone no nome.
147
+ if (d.ok && d.bytes > 25000) fotos.push({ src: d.relativo, alt: f.alt, pagina: f.pagina });
148
+ }
149
+ for (const v of videosVistos.values()) {
150
+ if (videos.length >= maxVideos) break;
151
+ const d = await baixar({ url: v.url, titulo: 'vídeo da marca', licenca: 'da própria marca', fonte: 'site' }, { pasta, nome: 'marca-video', sub: 'midia/marca' });
152
+ if (d.ok && d.bytes > 50000) videos.push({ src: d.relativo, pagina: v.pagina });
153
+ }
154
+ return { ok: true, paginas: paginas.map((p) => p.url), fatos, fotos, videos };
155
+ }
156
+
157
+ module.exports = { colher, colherMidia, paginasRelevantes, textoDaPagina, maiorDoSrcset };
@@ -120,7 +120,41 @@ const COLECOES = [
120
120
  * O SVG vem com `currentColor` no traço para a cor ser decidida na cena — um
121
121
  * ícone com cor cravada no arquivo obriga a baixar de novo para mudar de cor.
122
122
  */
123
+ /* ── EMOJI 3D ─────────────────────────────────────────────────────────
124
+ * "Os emojis, quero emojis da Apple." (25/09/2026) Os da Apple têm direito
125
+ * autoral e não podem ir num vídeo publicado; os Fluent 3D da Microsoft têm
126
+ * licença MIT e o mesmo visual brilhante e com volume. "fluent-3d:robot" é o
127
+ * PNG do repositório oficial (pelo jsDelivr), embutido como <img> — o resto
128
+ * do render trata igual a um ícone SVG. Sem o 3D, cai no emoji plano. */
129
+ const cache3d = new Map();
130
+ async function emoji3d(nome, { pasta = null } = {}) {
131
+ if (cache3d.has(nome)) return cache3d.get(nome);
132
+ const palavras = nome.split('-');
133
+ const pastas = [
134
+ palavras.join(' ').replace(/^./, (c) => c.toUpperCase()),
135
+ palavras.map((w) => w[0].toUpperCase() + w.slice(1)).join(' '),
136
+ ];
137
+ for (const pastaGit of pastas) {
138
+ const url = `https://cdn.jsdelivr.net/gh/microsoft/fluentui-emoji@main/assets/${encodeURIComponent(pastaGit)}/3D/${palavras.join('_')}_3d.png`;
139
+ try {
140
+ const png = await pegar(url, { binario: true });
141
+ if (png && png.length > 1000 && png[0] === 0x89) {
142
+ if (pasta) guardar(path.join(pasta, 'midia', 'icones', `fluent-3d-${nome}.png`), png, { licenca: 'MIT (Microsoft Fluent UI Emoji)', origem: url });
143
+ const r = { ok: true, nome: 'fluent-3d:' + nome,
144
+ svg: `<img src="data:image/png;base64,${png.toString('base64')}" style="width:100%;height:100%;object-fit:contain;display:block" />` };
145
+ cache3d.set(nome, r);
146
+ return r;
147
+ }
148
+ } catch { /* a próxima grafia */ }
149
+ }
150
+ const plano = await icone('fluent-emoji-flat:' + nome, { pasta });
151
+ const r = plano.ok ? { ...plano, nome: 'fluent-3d:' + nome } : plano;
152
+ cache3d.set(nome, r);
153
+ return r;
154
+ }
155
+
123
156
  async function icone(nome, { pasta = null } = {}) {
157
+ if (/^fluent-3d:/i.test(String(nome))) return emoji3d(String(nome).split(':')[1], { pasta });
124
158
  const m = String(nome || '').match(/^([a-z0-9-]+):([a-z0-9-]+)$/i);
125
159
  if (!m) return { ok: false, error: 'o ícone é "coleção:nome", como "lucide:zap"' };
126
160
  const url = `https://api.iconify.design/${m[1]}/${m[2]}.svg?color=currentColor`;
@@ -213,7 +247,28 @@ function extensaoPorConteudo(bytes) {
213
247
  *
214
248
  * @returns {{ok, imagens: Array<{url, titulo, autor, licenca, creditos, fonte}>}}
215
249
  */
216
- async function procurarImagens(termo, { quantas = 8, transparente = false } = {}) {
250
+ /* O Pexels pelo SERVIDOR: a chave é dele, e ninguém precisa ter uma. Sem
251
+ servidor (ou sem a chave lá), devolve null e a busca segue no Openverse. */
252
+ async function pexelsDoServidor(tipo, termo, { quantas, orientacao } = {}) {
253
+ try {
254
+ const cfg = require('../config').loadConfig();
255
+ const r = await require('../api').getMidia(cfg.server, cfg.token, tipo, { q: termo, n: quantas, orientacao });
256
+ const lista = r && r.ok ? r[tipo] : null;
257
+ return lista && lista.length ? lista : null;
258
+ } catch { return null; }
259
+ }
260
+
261
+ /** Procura vídeo de apoio (b-roll). Só Pexels: o Openverse não tem vídeo. */
262
+ async function procurarVideos(termo, { quantas = 5, orientacao = null } = {}) {
263
+ const videos = await pexelsDoServidor('videos', termo, { quantas, orientacao });
264
+ return videos ? { ok: true, videos } : { ok: false, error: 'sem acervo de vídeo agora (o servidor está sem a chave do Pexels).', videos: [] };
265
+ }
266
+
267
+ async function procurarImagens(termo, { quantas = 8, transparente = false, orientacao = null } = {}) {
268
+ if (!transparente && !process.env.PEXELS_API_KEY) {
269
+ const imagens = await pexelsDoServidor('imagens', termo, { quantas, orientacao });
270
+ if (imagens) return { ok: true, imagens };
271
+ }
217
272
  const chave = process.env.PEXELS_API_KEY;
218
273
  if (chave && !transparente) {
219
274
  try {
@@ -256,14 +311,33 @@ async function procurarImagens(termo, { quantas = 8, transparente = false } = {}
256
311
  * `duracaoMin` existe para separar as duas coisas sem o usuário dizer: um
257
312
  * "whoosh" tem menos de dois segundos, uma trilha tem mais de trinta.
258
313
  */
314
+ /* A MÚSICA vem da Jamendo (pelo Openverse): música de verdade, moderna, de
315
+ licença aberta. O Openverse geral devolvia concerto antigo, vento e trilha
316
+ de terror para "cinematic" (medido em 25/09). Licença "nd" (sem obra
317
+ derivada) fica de fora da música: pôr a faixa num vídeo editado é derivar. */
318
+ async function procurarMusica(termo, { quantas = 8, duracaoMin = 45 } = {}) {
319
+ try {
320
+ const r = await pegar(`https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}&source=jamendo`
321
+ + `&page_size=${Math.max(quantas * 2, 10)}&license_type=commercial`, { json: true });
322
+ const sons = (r.results || [])
323
+ .filter((x) => !/nd/.test(x.license || '') && (!duracaoMin || (x.duration || 0) / 1000 >= duracaoMin))
324
+ .slice(0, quantas)
325
+ .map((x) => ({ url: x.url, titulo: x.title || termo, autor: x.creator, licenca: x.license, creditos: x.attribution,
326
+ fonte: 'jamendo', segundos: x.duration ? Math.round(x.duration / 1000) : null }));
327
+ if (sons.length) return { ok: true, sons };
328
+ } catch { /* cai no Openverse geral */ }
329
+ return procurarSom(termo, { quantas, duracaoMin });
330
+ }
331
+
259
332
  async function procurarSom(termo, { quantas = 8, duracaoMin = 0, duracaoMax = 0 } = {}) {
260
333
  try {
261
334
  let url = `https://api.openverse.org/v1/audio/?q=${encodeURIComponent(termo)}`
262
335
  + `&page_size=${quantas}&license_type=commercial`;
263
- if (duracaoMin) url += `&length=${duracaoMin < 30 ? 'short' : 'medium'}`;
336
+ if (duracaoMin && duracaoMin < 30) url += '&length=short';
264
337
  const r = await pegar(url, { json: true });
265
338
  const sons = (r.results || [])
266
339
  .filter((x) => !duracaoMax || !x.duration || x.duration / 1000 <= duracaoMax)
340
+ .filter((x) => !duracaoMin || !x.duration || x.duration / 1000 >= duracaoMin)
267
341
  .map((x) => ({
268
342
  url: x.url, titulo: x.title || termo, autor: x.creator,
269
343
  licenca: x.license, creditos: x.attribution, fonte: 'openverse',
@@ -337,7 +411,7 @@ async function conferirFonte(familia) {
337
411
  module.exports = {
338
412
  extensaoPorConteudo,
339
413
  pegar, guardar,
340
- icone, procurarIcones, iconesDoPlano, COLECOES,
414
+ icone, procurarIcones, iconesDoPlano, COLECOES, procurarVideos, procurarMusica,
341
415
  procurarImagens, procurarSom, baixar,
342
416
  FONTES, conferirFonte,
343
417
  };