primocode 10.8.0 → 10.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.8.0
1
+ # PrimoCode v10.9.0
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 v10.8.0
12
+ ▐▛███▜▌ Primo Code v10.9.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/bin/primocode.js CHANGED
@@ -253,16 +253,20 @@ async function comandoApi(rest) {
253
253
 
254
254
  const apelido = { claude: 'claude-code', gpt: 'chatgpt', openai: 'chatgpt' }[arg.toLowerCase()];
255
255
  let id = apelido || ids.find((k) => k === arg.toLowerCase() || P[k].nome.toLowerCase().startsWith(arg.toLowerCase()) && arg);
256
+ // A lista numerada, a pergunta e a resposta somem depois da escolha: fica só o resultado (lib/entrada.js, marcar).
257
+ let marca = null;
258
+ const tirarLista = () => { if (marca && rl && rl.voltarPara) rl.voltarPara(marca); marca = null; };
256
259
  if (!id) {
260
+ if (rl && rl.marcar) marca = rl.marcar();
257
261
  console.log('');
258
262
  console.log(' ' + c.bold('Com qual IA você quer rodar?') + c.muted(' a sua chave ou a sua assinatura, sem o limite do servidor'));
259
263
  if (atual) console.log(' ' + c.muted('agora: ') + c.white(atual.cli ? atual.nome : `${atual.nome} · ${atual.modelo} · chave ${provedorLocal.mascarar(atual.chave)}`));
260
264
  ids.forEach((k, i) => console.log(` ${c.brand(String(i + 1))} ${c.white(P[k].nome.padEnd(24))}${c.dim(P[k].dica)}`));
261
265
  console.log(` ${c.brand('0')} ${c.white('Servidor do PrimoCode'.padEnd(24))}${c.dim('sem chave, com o limite do plano')}`);
262
266
  const escolha = String(await ask(` ${c.brand('?')} Número: `)).trim();
263
- if (escolha === '0') return comandoApi('off');
267
+ if (escolha === '0') { tirarLista(); return comandoApi('off'); }
264
268
  id = ids[Number(escolha) - 1];
265
- if (!id) { console.log(c.muted(' Nada mudou.')); return; }
269
+ if (!id) { tirarLista(); console.log(c.muted(' Nada mudou.')); return; }
266
270
  }
267
271
 
268
272
  const p = P[id];
@@ -270,22 +274,25 @@ async function comandoApi(rest) {
270
274
  // e logada. Quem pensa é ela; quem executa é o PrimoCode.
271
275
  if (p.cli) {
272
276
  if (!require('../lib/local/motor-cli').instalado(id)) {
277
+ tirarLista();
273
278
  console.log(c.warn(` O ${p.nome} não está instalado nesta máquina.`) + c.muted(` Veja ${p.onde}`));
274
279
  return;
275
280
  }
276
281
  const salvo = provedorLocal.salvar({ provedor: id, modelo: p.modelos[0] });
282
+ tirarLista();
277
283
  console.log(c.ok(` Pronto: ${salvo.nome}`) + c.muted(' · usa a sua assinatura, sem chave'));
278
284
  console.log(c.dim(` O ${p.nome} decide, o PrimoCode executa. /api off volta ao servidor`));
279
285
  return;
280
286
  }
281
287
  console.log(c.muted(` Pegue a chave em `) + c.white(p.onde));
282
288
  const chave = String(await askSecret(` ${c.brand('?')} Cole a chave do ${p.nome} (não aparece na tela): `)).trim();
283
- if (!chave) { console.log(c.muted(' Nada mudou.')); return; }
289
+ if (!chave) { tirarLista(); console.log(c.muted(' Nada mudou.')); return; }
284
290
  const sp = new Spinner('conferindo a chave').start();
285
291
  const r = await provedorLocal.conferir(id, chave);
286
292
  sp.stop();
287
- if (!r.ok) { console.log(c.warn(` ${r.error}`)); return; }
293
+ if (!r.ok) { tirarLista(); console.log(c.warn(` ${r.error}`)); return; }
288
294
  const salvo = provedorLocal.salvar({ provedor: id, chave, modelo: r.modelo });
295
+ tirarLista();
289
296
  console.log(c.ok(` Pronto: ${salvo.nome} · ${salvo.modelo}`) + c.muted(` · chave ${provedorLocal.mascarar(chave)}`));
290
297
  console.log(c.dim(' Agora os pedidos vão direto daqui, com a sua cota. /api modelo <nome> troca o modelo · /api off volta ao servidor'));
291
298
  }
@@ -1279,6 +1286,8 @@ async function enviarPedido(userText) {
1279
1286
  // ferramentas desktop_* no servidor (ver DICA_COMPUTADOR).
1280
1287
  instrucao: [i === 0 && querComputador(userText) ? vozModo.DICA_COMPUTADOR : '',
1281
1288
  i === 0 ? require('../lib/estudio/referencias.js').instrucao() : '',
1289
+ // As imagens do pedido (arrastadas ou coladas): guardadas para o Estúdio importar, e ditas ao agente.
1290
+ i === 0 ? (() => { const R = require('../lib/estudio/referencias.js'); return R.instrucaoDasImagens(R.guardarImagens(R.imagensNoTexto(userText))); })() : '',
1282
1291
  i === 0 ? plan.notaOutraPasta || '' : ''].filter(Boolean).join('\n\n'),
1283
1292
  });
1284
1293
 
package/lib/act.js CHANGED
@@ -686,6 +686,13 @@ const RITMO_AGENTE = '[ritmo]\n'
686
686
  entrar já lido (ver lib/pre-leitura.js). */
687
687
  function buildMemoryContext(projectDir, projectName, pedido = '', { memoriaDaPasta = true } = {}) {
688
688
  const parts = [];
689
+ /* NO SERVIDOR O CONTEXTO É CURTO (13/10: "continua lendo e listando
690
+ coisas desnecessárias"). O plano grátis guarda ~1.900 tokens de
691
+ conversa; 3.400 de contexto fixo empurravam para fora o que o modelo
692
+ acabou de ler, e ele lia de novo. Com provedor próprio (/api) nada muda. */
693
+ let proprio = false;
694
+ try { proprio = !!require('./local/provedor.js').atual(); } catch { /* sem provedor próprio */ }
695
+ const curto = (t) => (proprio || t.length <= 1500 ? t : '…' + t.slice(-1500));
689
696
  // As instruções vêm primeiro: são regra de quem manda no projeto, e a
690
697
  // memória é o que o agente anotou. Entram aqui (e não no bin) para valer
691
698
  // igual no terminal, no modo voz e em cada passo de um plano.
@@ -697,14 +704,14 @@ function buildMemoryContext(projectDir, projectName, pedido = '', { memoriaDaPas
697
704
  bin), ela não vem de novo. */
698
705
  if (memoriaDaPasta) {
699
706
  const daPasta = require('./agents-md.js').ler(projectDir);
700
- if (daPasta) parts.push(`[AGENTS.md — memória desta pasta: de onde a conversa parou]\n${daPasta}`);
707
+ if (daPasta) parts.push(`[AGENTS.md — memória desta pasta: de onde a conversa parou]\n${curto(daPasta)}`);
701
708
  }
702
709
  const geral = cfg.readGeneralMemory();
703
- if (geral.trim()) parts.push(`[memory-geral.md]\n${geral}`);
710
+ if (geral.trim()) parts.push(`[memory-geral.md]\n${curto(geral)}`);
704
711
  const projeto = cfg.readProjectMemory(projectDir);
705
- if (projeto.trim()) parts.push(`[${projectName || 'projeto'}/memory.md]\n${projeto}`);
712
+ if (projeto.trim()) parts.push(`[${projectName || 'projeto'}/memory.md]\n${curto(projeto)}`);
706
713
  const arquivos = arquivosDoProjeto(projectDir);
707
- const mapa = mapaDoProjeto(projectDir, 80, arquivos);
714
+ const mapa = mapaDoProjeto(projectDir, proprio ? 80 : 30, arquivos);
708
715
  if (mapa) parts.push(`[${projectName || 'projeto'}/arquivos]\n${mapa}\n\n${preLeitura.JUNTAR_LEITURAS}`);
709
716
  const lidos = preLeitura.bloco(preLeitura.escolher(projectDir, arquivos, pedido));
710
717
  if (lidos) parts.push(lidos);
package/lib/entrada.js CHANGED
@@ -37,6 +37,9 @@
37
37
 
38
38
  'use strict';
39
39
 
40
+ const fs = require('fs');
41
+ const os = require('os');
42
+ const path = require('path');
40
43
  const readline = require('readline');
41
44
  const { EventEmitter } = require('events');
42
45
  const { c, visibleWidth, truncate } = require('./ui.js');
@@ -46,7 +49,51 @@ const stripAnsiLocal = (s) => String(s).replace(/\x1b\[[0-9;]*m/g, '');
46
49
  // ── O desenho ────────────────────────────────────────────────────────────
47
50
 
48
51
  /** A etiqueta de colagem em destaque (fininha, na cor da marca), sem mudar a largura. */
49
- const RE_ETIQUETA = /\[Colado #\d+ · [^\]]+\]/g;
52
+ const RE_ETIQUETA = /\[(?:Colado|Imagem) #\d+[^\]]*\]/g;
53
+
54
+ /* A IMAGEM ARRASTADA (Joel, 13/10: "#imagem1 #imagem2, igual fica aqui").
55
+ Arrastar arquivo para o terminal é uma colagem do CAMINHO, e cada terminal
56
+ escreve de um jeito: o do macOS escapa o espaço e põe um no fim
57
+ ("Captura\ de\ Tela.png "), o iTerm põe entre aspas, outros mandam
58
+ file://. Devolve os caminhos quando TODOS são imagens que existem; senão
59
+ null, e a colagem segue como texto. */
60
+ const RE_IMAGEM = /\.(png|jpe?g|gif|webp|heic|bmp|tiff?|svg)$/i;
61
+ function imagensArrastadas(texto) {
62
+ const t = String(texto || '').trim();
63
+ if (!t || t.includes('\n')) return null;
64
+ const pedacos = [];
65
+ let cur = '', aspas = null;
66
+ for (let i = 0; i < t.length; i++) {
67
+ const ch = t[i];
68
+ if (aspas) { if (ch === aspas) aspas = null; else cur += ch; continue; }
69
+ if (ch === "'" || ch === '"') { aspas = ch; continue; }
70
+ if (ch === '\\' && i + 1 < t.length) { cur += t[++i]; continue; }
71
+ if (/\s/.test(ch)) { if (cur) pedacos.push(cur); cur = ''; continue; }
72
+ cur += ch;
73
+ }
74
+ if (aspas) return null;
75
+ if (cur) pedacos.push(cur);
76
+ const arquivos = pedacos.map((p) => (p.startsWith('file://') ? decodeURI(p.slice(7)) : p.replace(/^~(?=\/)/, os.homedir())));
77
+ const existe = (f) => { try { return fs.statSync(f).isFile(); } catch { return false; } };
78
+ if (!arquivos.length || !arquivos.every((f) => path.isAbsolute(f) && RE_IMAGEM.test(f) && existe(f))) return null;
79
+ return arquivos;
80
+ }
81
+
82
+ /* O QUE A ETIQUETA GUARDAVA, embaixo do pedido já enviado: o nome da imagem,
83
+ ou as primeiras linhas do texto colado e quanto falta. Na caixa a etiqueta
84
+ poupa espaço; depois de mandar, quem rola a conversa quer ver o que foi. */
85
+ function guardadosNaTela(guardados, largura) {
86
+ const out = [];
87
+ for (const [et, conteudo] of guardados || []) {
88
+ if (et.startsWith('[Imagem')) { out.push(`⎿ ${et} ${path.basename(conteudo)}`); continue; }
89
+ const linhas = String(conteudo).split('\n');
90
+ const mostra = linhas.slice(0, 3).map((l) => truncate(l, Math.max(10, largura - et.length - 6)));
91
+ out.push(`⎿ ${et} ${mostra[0]}`);
92
+ for (const l of mostra.slice(1)) out.push(` ${' '.repeat(et.length)} ${l}`);
93
+ if (linhas.length > 3) out.push(` ${' '.repeat(et.length)} … +${linhas.length - 3} linhas`);
94
+ }
95
+ return out;
96
+ }
50
97
  function pintarEtiquetas(s) { return String(s).replace(RE_ETIQUETA, (m) => c.brand(m) + '\x1b[38;5;254m'); }
51
98
 
52
99
  /* As cores do painel, no cubo de 256 cores. O fundo é um degrau acima do
@@ -248,12 +295,14 @@ function montar(e) {
248
295
  }
249
296
 
250
297
  /** O pedido que foi mandado, como fica no histórico da tela: um painel. */
251
- function pedidoNaTela(texto, colunas) {
298
+ function pedidoNaTela(texto, colunas, guardados = []) {
252
299
  const largura = Math.max(24, Math.min(colunas || 80, 200)) - 1;
253
300
  const barra = `${COR.barra}┃${COR.reset}`;
254
301
  const linhas = quebrar(texto, Math.max(8, largura - PREFIXO))
255
302
  .map((l) => painel(barra + ' ' + pintarEtiquetas(c.white(l.texto)), largura));
256
- return [painel(barra, largura), ...linhas, painel(barra, largura)].join('\n');
303
+ const extras = guardadosNaTela(guardados, largura - PREFIXO)
304
+ .map((l) => painel(barra + ' ' + c.dim(truncate(l, largura - PREFIXO)), largura));
305
+ return [painel(barra, largura), ...linhas, ...extras, painel(barra, largura)].join('\n');
257
306
  }
258
307
 
259
308
  // ── A caixa viva ─────────────────────────────────────────────────────────
@@ -403,6 +452,21 @@ class Entrada extends EventEmitter {
403
452
  if (this.ativo && !this.fechado) this._desenhar();
404
453
  }
405
454
 
455
+ /* UMA ESCOLHA QUE SOME depois de respondida (o /api, 13/10: "só apareça
456
+ pra qual modelo trocou, aquela lista enorme os números somem").
457
+ marcar() antes de listar, voltarPara(marca) depois: a lista, a pergunta
458
+ e a resposta saem da conversa, e fica só o que vier depois. Só na tela
459
+ cheia; no modo linha o terminal já rolou e o texto fica. */
460
+ marcar() { return this.cheia ? { n: this.conversa.length, ultima: this.conversa[this.conversa.length - 1] } : null; }
461
+ voltarPara(marca) {
462
+ if (!marca || !this.cheia || this.conversa.length < marca.n) return;
463
+ this.conversa.length = marca.n;
464
+ if (marca.n) this.conversa[marca.n - 1] = marca.ultima;
465
+ this.rolagem = 0;
466
+ this._quadroAnterior = null;
467
+ this._pintar();
468
+ }
469
+
406
470
  /** Um comando com tela própria desenha aqui (na tela cheia). null fecha. */
407
471
  painel(texto) {
408
472
  this.painelTexto = texto == null ? null : String(texto).replace(/\n+$/, '');
@@ -566,7 +630,7 @@ class Entrada extends EventEmitter {
566
630
  this._mudou();
567
631
  }
568
632
 
569
- limpar() { this.line = ''; this.cursor = 0; this.menu = null; this.colados = new Map(); this._mudou(); }
633
+ limpar() { this.line = ''; this.cursor = 0; this.menu = null; this.colados = new Map(); this.nImagem = 0; this._mudou(); }
570
634
 
571
635
  /* COLAR VIRA ETIQUETA, como no Claude Code: texto grande colado (várias
572
636
  linhas, ou mais de 300 caracteres) entra na caixa como
@@ -576,10 +640,22 @@ class Entrada extends EventEmitter {
576
640
  colar(texto) {
577
641
  const t = String(texto || '');
578
642
  if (!t) return;
643
+ if (!this.colados) this.colados = new Map();
644
+ /* Imagem arrastada: uma etiqueta por arquivo, e no Enter volta o caminho.
645
+ O contador não reaproveita número: apagar a #1 não faz a próxima repetir a #2. */
646
+ const imagens = imagensArrastadas(t);
647
+ if (imagens) {
648
+ const ets = imagens.map((f) => {
649
+ this.nImagem = (this.nImagem || 0) + 1;
650
+ const et = `[Imagem #${this.nImagem}]`;
651
+ this.colados.set(et, f);
652
+ return et;
653
+ });
654
+ return this.write(ets.join(' ') + (/\s$/.test(t) ? ' ' : ''));
655
+ }
579
656
  const linhas = t.split('\n').length;
580
657
  if (linhas < 3 && t.length <= 300) return this.write(t);
581
- if (!this.colados) this.colados = new Map();
582
- const n = this.colados.size + 1;
658
+ const n = [...this.colados.keys()].filter((k) => k.startsWith('[Colado')).length + 1;
583
659
  const etiqueta = `[Colado #${n} · ${linhas > 1 ? `${linhas} linhas` : `${t.length} caracteres`}]`;
584
660
  this.colados.set(etiqueta, t);
585
661
  this.write(etiqueta);
@@ -890,7 +966,9 @@ class Entrada extends EventEmitter {
890
966
  if (this.menu) return this._escolherDoMenu();
891
967
  const visto = this.line;
892
968
  const texto = this.expandir(visto);
969
+ const guardados = [...(this.colados || [])].filter(([et]) => visto.includes(et));
893
970
  this.colados = new Map();
971
+ this.nImagem = 0;
894
972
  const oculto = this.oculto;
895
973
  this.oculto = false;
896
974
  this.line = '';
@@ -921,8 +999,8 @@ class Entrada extends EventEmitter {
921
999
  const temAlgo = this.conversa.some((l) => /\S/.test(l));
922
1000
  const ultima = this.conversa[this.conversa.length - 1] || '';
923
1001
  const antes = !temAlgo ? '' : /\S/.test(ultima) ? '\n\n' : '\n';
924
- tela.acrescentar(this.conversa, antes + pedidoNaTela(visto, mainW - 1) + '\n\n');
925
- } else this.escreverCru(pedidoNaTela(visto, process.stdout.columns || 80) + '\n');
1002
+ tela.acrescentar(this.conversa, antes + pedidoNaTela(visto, mainW - 1, guardados) + '\n\n');
1003
+ } else this.escreverCru(pedidoNaTela(visto, process.stdout.columns || 80, guardados) + '\n');
926
1004
  if (this.historico[this.historico.length - 1] !== texto) this.historico.push(texto);
927
1005
  }
928
1006
  this._desenhar();
@@ -932,4 +1010,4 @@ class Entrada extends EventEmitter {
932
1010
 
933
1011
  function criar(op) { return new Entrada(op).iniciar(); }
934
1012
 
935
- module.exports = { criar, Entrada, montar, quebrar, encher, pintarEtiquetas, painel, pedidoNaTela, PREFIXO, COR };
1013
+ module.exports = { criar, Entrada, montar, quebrar, encher, pintarEtiquetas, painel, pedidoNaTela, imagensArrastadas, PREFIXO, COR };
@@ -76,7 +76,29 @@ function estiloDaVez(aleatorio = Math.random, semImagem = false) {
76
76
  return variedade.sortear(lista, 'estilo', { aleatorio }) || lista[0];
77
77
  }
78
78
 
79
+ /* O ESTILO DA CASA (13/10, sobre o primocode-ele-constroi.mp4: "esse vídeo tá
80
+ top! quero os vídeos nesse estilo, todos os vídeos que ele gerar"). Todo
81
+ vídeo é o GERATIVO — tipografia grande e reta, as cores da marca, Inter e
82
+ JetBrains Mono. O estilo que o agente ou o roteiro escolhem não vale mais:
83
+ o rodízio dava um vídeo de cada jeito. Muda só quando a PESSOA pede pelo
84
+ nome, ou pede a animação 3D com personagens (o personagem). */
85
+ // Nome de estilo inequívoco, ou "estilo X". "Vídeo de lançamento" e "cinema" contam o assunto, não o estilo.
86
+ const PEDE_PELO_NOME = /\b(fluid[oa]|vox|explainer|h[ií]brid[oa]|cl[aá]ssico|brand reveal|estilo (?:de )?(?:marca|impacto|cinema|lan[çc]amento|minimalista|fluido|vox))\b/i;
87
+ const CASA = true;
88
+
79
89
  function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = false, semImagem = false, aleatorio } = {}) {
90
+ if (CASA) {
91
+ // O personagem 3D é outro produto (bonecos conversando), não um jeito do motion: esse o agente e o roteiro ainda pedem.
92
+ if (PEDE_PERSONAGEM.test(pedido) || normal(agente) === 'personagem' || normal(roteiro) === 'personagem') return 'personagem';
93
+ const nomeado = (String(pedido).match(PEDE_PELO_NOME) || [])[1];
94
+ if (nomeado) {
95
+ const x = nomeado.toLowerCase().replace(/^estilo (?:de )?/, '').replace(/^fluida$/, 'fluido').replace(/^h[ií]brid[oa]$/, 'hibrido')
96
+ .replace(/^brand reveal$/, 'marca').replace(/^minimalista$/, 'fluido').replace(/^explainer$/, 'vox').replace(/^cl[aá]ssico$/, 'classico').replace(/^lan[çc]amento$/, 'lancamento');
97
+ const n = normal(x);
98
+ if (n) return n;
99
+ }
100
+ return 'gerativo';
101
+ }
80
102
  const pedidoAgente = normal(agente);
81
103
  if (pedidoAgente) return pedidoAgente;
82
104
  const doRoteiro = normal(roteiro);
@@ -101,4 +123,4 @@ function escolherEstilo({ pedido = '', agente = null, roteiro = null, girar = fa
101
123
  /** Os estilos que usam os efeitos leves e a trilha do ende.app (os de motion, não os de corte). */
102
124
  const LEVES = new Set(['gerativo', 'motion', 'vox', 'marca', 'lancamento', 'fluido', 'personagem']);
103
125
 
104
- module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO, RODIZIO_SEM_IMAGEM, PEDE_PERSONAGEM };
126
+ module.exports = { escolherEstilo, estiloDaVez, ESTILOS, LEVES, PEDE_MOTION, PEDE_FLUIDO, RODIZIO, RODIZIO_SEM_IMAGEM, PEDE_PERSONAGEM, PEDE_PELO_NOME };
@@ -238,7 +238,7 @@ function acertosDeHumor(roteiro) {
238
238
  lê roteiro.escolhasGerativo, e a marca continua mandando por cima. */
239
239
  function escolherVisual(roteiro = {}, { aleatorio = Math.random } = {}) {
240
240
  const variedade = require('./variedade.js');
241
- const marca = roteiro.marca || {};
241
+ const marca = require('./marca.js').marcaEmObjeto(roteiro.marca);
242
242
  const n = acertosDeHumor(roteiro);
243
243
  const max = Math.max(...Object.values(n));
244
244
  const humor = variedade.sortear(Object.keys(HUMORES), 'humor-gerativo', { aleatorio, adequacao: (h) => (n[h] + 1) / (max + 1) });
@@ -637,7 +637,11 @@ function camposDe(marca, humor, s, dita = null, fixa = null) {
637
637
  if (dita && !ehCor(c.destaque)) { const j = k.indexOf(dita); if (j > 0) k[j] = k[1]; k[1] = dita; }
638
638
  // A marca: o fundo dela, o destaque dela e a tinta dela tomam os lugares de mesmo papel.
639
639
  const escuro = (x) => luz(x) < 0.35, claro = (x) => luz(x) > 0.7;
640
- for (const cor of [c.fundo, c.destaque, c.tinta].filter(ehCor)) {
640
+ /* Marca de fundo escuro sem tinta declarada: o claro é o branco frio, não
641
+ o creme do sorteio (o motion do PrimoCode, preto e ciano, ganhava fundo
642
+ #F1EADB na lista e no fim — 13/10). */
643
+ const tinta = ehCor(c.tinta) ? c.tinta : (ehCor(c.fundo) && luz(c.fundo) < 0.2 ? '#F5F5F7' : null);
644
+ for (const cor of [c.fundo, c.destaque, tinta].filter(ehCor)) {
641
645
  const lugar = escuro(cor) ? 0 : claro(cor) ? 2 : 1;
642
646
  if (!k.includes(cor)) k[lugar] = cor;
643
647
  }
@@ -941,7 +945,7 @@ function montarGerativo({ roteiro, largura = 1920, altura = 1080, tempos = [], n
941
945
  /** A partitura, sem áudio nem plano — é o que os testes olham. */
942
946
  function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icones = {} }) {
943
947
  const partes = roteiro.partes || [];
944
- const marca = roteiro.marca || {};
948
+ const marca = require('./marca.js').marcaEmObjeto(roteiro.marca);
945
949
  const W = largura, H = altura, deitado = W >= H;
946
950
  const semente = roteiro.semente != null ? hash('semente:' + roteiro.semente)
947
951
  : hash(JSON.stringify([partes.map((p) => [p.tipo, p.titulo, p.texto, p.fala, p.valor, p.itens, p.nao, p.sim, p.destaque]), marca.nome || '']));
@@ -959,8 +963,14 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
959
963
  eixos para animar); a genérica da marca vira texto, e o título ganha
960
964
  uma face da biblioteca do humor. ─────────────────────────────────── */
961
965
  const pedida = (marca.fontes && marca.fontes.titulo) || '';
962
- const pedidaTexto = (marca.fontes && marca.fontes.texto) || '';
963
- const boa = (f) => f && VARIAVEIS[f] && !BANIDAS.includes(f) && VARIAVEIS[f].eixos.wght;
966
+ /* O texto da marca; sem ele, a mono da marca (o site do PrimoCode é a
967
+ Instrument Serif nos títulos e a JetBrains Mono no resto, 13/10). */
968
+ const monoMarca = (marca.fontes && marca.fontes.mono) || '';
969
+ const pedidaTexto = (marca.fontes && marca.fontes.texto) || (VARIAVEIS[monoMarca] ? monoMarca : '');
970
+ /* A da marca vale se a biblioteca carrega (e não é cara de IA), mesmo sem
971
+ eixo de peso: sem eixo ela só não anima o peso. Exigir o wght trocava a
972
+ Instrument Serif do site por uma Bodoni sorteada (13/10). */
973
+ const boa = (f) => f && VARIAVEIS[f] && !BANIDAS.includes(f);
964
974
  const ou = (salva, sorteada) => salva || sorteada;
965
975
  const display = boa(pedida) ? pedida : ou(E.display, s.um(H0.displays));
966
976
  const cinetica = eixoDe(display, 'wdth') && eixoDe(display, 'wdth')[0] <= 75 ? display : s.um(H0.cineticas);
@@ -1129,7 +1139,12 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1129
1139
  const mx = Math.max(r, g, bb), mn = Math.min(r, g, bb), d = mx - mn; if (!d) return 0;
1130
1140
  const hh = mx === r ? ((g - bb) / d) % 6 : mx === g ? (bb - r) / d + 2 : (r - g) / d + 4; return (hh * 60 + 360) % 360; };
1131
1141
  const longe = (x) => { const d = Math.abs(matiz(x) - matiz(ident.acento)); return Math.min(d, 360 - d); };
1132
- const ousada = [campos[3], campos[4]].filter(ehCor).sort((x, y) => longe(y) - longe(x))[0];
1142
+ /* Com marca, a ousada é a de APOIO da marca, ou nenhuma: o amarelo do
1143
+ sistema sorteado entrava de fundo no motion preto e ciano do PrimoCode (13/10). */
1144
+ const mc = marca.cores || {};
1145
+ const daMarca = ehCor(mc.fundo) || ehCor(mc.destaque);
1146
+ const ousada = daMarca ? (ehCor(mc.apoio) && mc.apoio !== ident.acento ? mc.apoio : null)
1147
+ : [campos[3], campos[4]].filter(ehCor).sort((x, y) => longe(y) - longe(x))[0];
1133
1148
  const fortes = [ident.acento, ousada].filter((x, i, a) => ehCor(x) && a.indexOf(x) === i);
1134
1149
  const nCor = batidas.length >= 6 ? Math.max(2, Math.round(batidas.length / 3.5)) : batidas.length >= 4 ? 1 : 0;
1135
1150
  const candCor = batidas.map((b, i) => i).filter((i) => i > 0 && !['marca', 'produto'].includes(batidas[i].comp));
@@ -1453,6 +1468,36 @@ function partituraDe({ roteiro, largura = 1920, altura = 1080, tempos = [], icon
1453
1468
  fundo.ambiente = AMBIENTES.includes(roteiro.ambiente) ? roteiro.ambiente : E.ambiente || 'limpo';
1454
1469
  fundo.acento = ACENTOS_JEITO.includes(roteiro.acento) ? roteiro.acento : E.acento || 'traco';
1455
1470
 
1471
+ /* SEM LETRA TORTA (13/10: "os motions saem muito tortos"). A letra que
1472
+ vira outra antes de assentar (decodifica, flap), a que chega voando e
1473
+ girando (monta), a que entra inclinada (onda) e a palavra que explode em
1474
+ letras rodando deixavam o "SGERE", o "a o" solto e o quadro vazio de
1475
+ letras fantasmas. Os sorteios acontecem igual (a semente não muda o
1476
+ resto); no fim, cada gesto vira o seu par limpo. */
1477
+ /* O substituto é um limpo que não aparece nas 3 batidas de cada lado: a
1478
+ passagem e o gesto da tese não voltam antes de 3 batidas. */
1479
+ const TORTOS = ['decodifica', 'flap', 'onda', 'monta'];
1480
+ const GESTOS_LIMPOS = ['mascara', 'desfoque', 'tracking', 'pontos', 'contorno', 'carimbo'];
1481
+ const SAIDAS_LIMPAS = ['evapora', 'desliza', 'comprime', 'pontos', 'atravessa'];
1482
+ const vizinhas = (i) => batidas.filter((_, j) => j !== i && Math.abs(j - i) <= 3);
1483
+ const principal = (b) => (b.linhas || []).find((l) => (l.papel === 'tese' || l.papel === 'numero') && !l.marca);
1484
+ const comGesto = batidas.filter(principal);
1485
+ batidas.forEach((b, i) => {
1486
+ if (b.saida === 'explode' || b.saida === 'chicote') {
1487
+ const perto = vizinhas(i).map((x) => x.saida);
1488
+ b.saida = SAIDAS_LIMPAS.find((x) => !perto.includes(x)) || 'corte';
1489
+ if (batidas[i + 1]) batidas[i + 1].entrada = b.saida;
1490
+ }
1491
+ const tortas = (b.linhas || []).filter((l) => TORTOS.includes(l.comportamento));
1492
+ if (tortas.length) {
1493
+ // A janela conta só as batidas com linha principal (tese ou número, fora a marca), como quem assiste vê.
1494
+ const k = comGesto.indexOf(b);
1495
+ const perto = k < 0 ? [] : comGesto.filter((_, j) => j !== k && Math.abs(j - k) <= 3).map((x) => principal(x).comportamento);
1496
+ const limpo = GESTOS_LIMPOS.find((x) => !perto.includes(x)) || 'mascara';
1497
+ for (const l of tortas) l.comportamento = limpo;
1498
+ }
1499
+ });
1500
+
1456
1501
  return {
1457
1502
  versao: 1, semente, fps: FPS, humor,
1458
1503
  musica: { genero: ou(E.genero, s.um(H0.generos)), generos: H0.generos }, largura: W, altura: H, duracao, regras: REGRAS,
@@ -1480,14 +1525,16 @@ function molduraDe(roteiro, E, { W, H, deitado, marca, secoes }) {
1480
1525
  };
1481
1526
  }
1482
1527
 
1528
+ /* O eixo de peso da fonte; a estática (a Instrument Serif da marca) é só 400. */
1529
+ const pesoDe = (fonte) => (VARIAVEIS[fonte] && VARIAVEIS[fonte].eixos.wght) || [400, 400];
1530
+
1483
1531
  // ── as composições: de conteúdo + contexto, uma batida ─────────────────
1484
1532
  function compor(comp, br, ctx) {
1485
1533
  const { s, W, H, M, deitado, base, larguraUtil, tamRotulo, fontes } = ctx;
1486
1534
  const c = br.conteudo;
1487
1535
  const palavras = ctx.palavras || [];
1488
1536
  const b = { id: 'b' + Math.round(br.de * 1000), parte: br.parte, de: r3(br.de), ate: r3(br.ate), comp, origem: c.tipo === 'falado' ? 'falado' : 'conteudo', linhas: [], acento: [] };
1489
- const eixosDisplay = VARIAVEIS[fontes.display].eixos;
1490
- const wMax = eixosDisplay.wght[1];
1537
+ const wMax = pesoDe(fontes.display)[1];
1491
1538
  const wTese = Math.min(wMax, s.um([800, 900, 900, 1000]));
1492
1539
  const destaqueTk = tokens(c.destaque || '');
1493
1540
  const ehTese = (w) => destaqueTk.length && tokens(w).some((k) => destaqueTk.includes(k));
@@ -1516,13 +1563,21 @@ function compor(comp, br, ctx) {
1516
1563
  };
1517
1564
  const vert = (opcoes) => s.um(opcoes);
1518
1565
  const tese = (l) => { l.papel = 'tese'; return l; };
1566
+ /* O ponto do acento ENTRA NO LUGAR do ponto final: com os dois, saía
1567
+ "Uma ideia.." (13/10). */
1568
+ const pontoFinal = (l) => {
1569
+ l.texto = l.texto.replace(/\.$/, '');
1570
+ const u = l.palavras[l.palavras.length - 1];
1571
+ if (u) u.texto = u.texto.replace(/\.$/, '');
1572
+ b.acento.push({ forma: 'ponto', alvo: { linha: l.id, fim: true } });
1573
+ };
1519
1574
 
1520
1575
  switch (comp) {
1521
1576
  case 'gigante': {
1522
1577
  const txt = c.titulo || c.nome || c.sim || c.valor || c.texto || '';
1523
1578
  const maiusc = txt.length <= 16 && s.chance(0.65);
1524
1579
  const fonte = s.chance(0.5) ? fontes.cinetica : fontes.display;
1525
- const eixos = { wght: Math.min(VARIAVEIS[fonte].eixos.wght[1], wTese) };
1580
+ const eixos = { wght: Math.min(pesoDe(fonte)[1], wTese) };
1526
1581
  const op = { fonte, wght: eixos.wght, maiusc };
1527
1582
  // A frase de 3+ palavras pode ir em 3 linhas no 16:9: cresce na altura em vez de encolher na largura.
1528
1583
  const nMax = deitado ? Math.min(3, Math.max(2, txt.split(/\s+/).length)) : Math.min(4, txt.split(/\s+/).length);
@@ -1543,7 +1598,7 @@ function compor(comp, br, ctx) {
1543
1598
  plano inteiro (12/10, o estilo padrão quebrava). Sem linha, sem acento. */
1544
1599
  if (!ultima) break;
1545
1600
  if (c.riscado) b.acento.push({ forma: 'risco', alvo: { linha: ultima.id } });
1546
- else if (/[.!]$/.test(txt) || s.chance(0.4)) b.acento.push({ forma: 'ponto', alvo: { linha: ultima.id, fim: true } });
1601
+ else if (/[.!]$/.test(txt) || s.chance(0.4)) pontoFinal(ultima);
1547
1602
  else {
1548
1603
  const lt = b.linhas.find((l) => l.papel === 'tese' && l.palavras.some((p) => p.papel === 'tese')) || ultima;
1549
1604
  const pi = Math.max(0, lt.palavras.findIndex((p) => p.papel === 'tese'));
@@ -1608,13 +1663,13 @@ function compor(comp, br, ctx) {
1608
1663
  for (const t of fit.linhas) { const l = linha(t, 'apoio', { tamanho: fit.tamanho, eixos: { wght: 560 } }); misturarPesos(l, { maiorTese: 1.14 }); }
1609
1664
  const lt = b.linhas.find((l) => l.palavras.some((p) => p.papel === 'tese'));
1610
1665
  if (lt) { tese(lt); b.acento.push({ forma: s.chance(0.6) ? 'sublinha' : 'anel', alvo: { linha: lt.id, palavra: lt.palavras.findIndex((p) => p.papel === 'tese') } }); }
1611
- else b.acento.push({ forma: 'ponto', alvo: { linha: b.linhas[b.linhas.length - 1].id, fim: true } });
1666
+ else pontoFinal(b.linhas[b.linhas.length - 1]);
1612
1667
  break;
1613
1668
  }
1614
1669
  case 'contador': {
1615
1670
  const n = numeroDe(c.valor);
1616
1671
  const fonte = s.chance(0.6) ? fontes.cinetica : fontes.display;
1617
- const eixos = { wght: Math.min(VARIAVEIS[fonte].eixos.wght[1], wTese) };
1672
+ const eixos = { wght: Math.min(pesoDe(fonte)[1], wTese) };
1618
1673
  const alinha = 'centro';
1619
1674
  const lg = deitado ? W * 0.62 : larguraUtil;
1620
1675
  const tam = tamanhoQueCabe(c.valor, lg * 0.82, (deitado ? 0.36 : 0.17) * H, { fonte, wght: eixos.wght });
@@ -1653,8 +1708,11 @@ function compor(comp, br, ctx) {
1653
1708
  case 'divisao': {
1654
1709
  const lado = deitado ? (larguraUtil - M * 0.6) / 2 : larguraUtil;
1655
1710
  const op = { fonte: fontes.display, wght: wTese };
1656
- const fN = caberEmLinhas(c.nao, lado, deitado ? 3 : 2, (deitado ? 0.13 : 0.08) * H, { ...op, wght: 400 });
1657
- const fS = caberEmLinhas(c.sim, lado, deitado ? 3 : 3, (deitado ? 0.17 : 0.1) * H, op);
1711
+ /* Teto mais alto (13/10): com 13% e 17% da altura, "sugere / constrói"
1712
+ ficava miúdo no canto, metade do tamanho do gigante ao lado. A
1713
+ largura da metade é que segura a frase longa. */
1714
+ const fN = caberEmLinhas(c.nao, lado, deitado ? 3 : 2, (deitado ? 0.2 : 0.11) * H, { ...op, wght: 400 });
1715
+ const fS = caberEmLinhas(c.sim, lado, deitado ? 3 : 3, (deitado ? 0.26 : 0.14) * H, op);
1658
1716
  if (deitado) {
1659
1717
  b.bloco = { x: M, y: H * 0.5, alinha: 'esq', vert: 'meio', largura: lado, gap: 0.1, colunas: [M, M + lado + M * 0.6] };
1660
1718
  } else {
@@ -1688,7 +1746,7 @@ function compor(comp, br, ctx) {
1688
1746
  const lg = deitado ? W * 0.7 : larguraUtil * 0.9;
1689
1747
  const tam = tamanhoQueCabe(nome, lg, (deitado ? 0.3 : 0.16) * H, { fonte, wght: 800 });
1690
1748
  b.bloco = { x: W / 2 + tam * 0.3, y: H * 0.5, alinha: 'centro', vert: 'meio', largura: lg, gap: 0.2 };
1691
- linha(nome, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(800, VARIAVEIS[fonte].eixos.wght[1]) }, comportamento: 'contorno' }).marca = true;
1749
+ linha(nome, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(800, pesoDe(fonte)[1]) }, comportamento: 'contorno' }).marca = true;
1692
1750
  if (c.sub || c.link) linha(c.sub || c.link, 'rotulo', { fonte: fontes.mono, tamanho: tamRotulo, tracking: 0.2, maiusc: true, eixos: { wght: 400 } });
1693
1751
  b.acento.push({ forma: 'ponto', alvo: { linha: b.linhas[0].id, antes: true } });
1694
1752
  break;
@@ -1699,7 +1757,7 @@ function compor(comp, br, ctx) {
1699
1757
  const maiusc = true;
1700
1758
  const tam = tamanhoQueCabe(txt, larguraUtil, (deitado ? 0.26 : 0.13) * H, { fonte, wght: 900, maiusc });
1701
1759
  b.bloco = { x: W / 2, y: H * 0.5, alinha: 'centro', vert: 'meio', largura: larguraUtil, gap: 0.1 };
1702
- linha(txt, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(900, VARIAVEIS[fonte].eixos.wght[1]) }, maiusc });
1760
+ linha(txt, 'tese', { fonte, tamanho: tam, eixos: { wght: Math.min(900, pesoDe(fonte)[1]) }, maiusc });
1703
1761
  b.textura = { texto: (maiusc ? txt.toUpperCase() : txt), tamanho: tam, fonte, linhas: Math.ceil(H / (tam * 1.02)) + 3, velocidade: Math.round(s.entre(40, 90) * base) };
1704
1762
  // Moldura em volta da palavra é vício de "cara de IA" (a caixa na palavra-chave); sai em 08/10.
1705
1763
  b.acento.push({ forma: s.chance(0.5) ? 'ponto' : 'sublinha', alvo: { linha: b.linhas[0].id } });
@@ -47,6 +47,7 @@ const assets = require('./assets.js');
47
47
  const video = require('./video.js');
48
48
  const batida = require('./batida.js');
49
49
  const revisor = require('./revisor.js');
50
+ const marcaLib = require('./marca.js');
50
51
  const conferencia = require('./conferencia.js');
51
52
  const margem = require('./margem.js');
52
53
  const assinatura = require('./assinatura.js');
@@ -510,6 +511,15 @@ async function ferramentaMidia(args) {
510
511
 
511
512
  /* O ELEMENTO do site, tirado pelo código (print-site.js): a lista das peças
512
513
  (logo, botão, cartão, título…) e uma peça sozinha, com o fundo transparente. */
514
+ /* A REFERÊNCIA VISUAL que a pessoa manda (13/10: "não usa as referências
515
+ que mandei" — era o print da landing, arrastado no pedido). O agente não
516
+ enxerga imagem; aqui ela entra no projeto, as cores saem pelo ffmpeg e,
517
+ se o projeto ainda não tem marca, viram a marca. Com site, o site manda. */
518
+ if (acao === 'referencia' || acao === 'referência') {
519
+ if (!p) return semProjeto(args.id);
520
+ return importarReferencia(p, String(args.arquivo || args.caminho || args.src || ''));
521
+ }
522
+
513
523
  if (acao === 'elementos') return printSite.elementosDoSite(args.url || args.site || args.termo, { celular: !!args.celular });
514
524
  if (acao === 'elemento') {
515
525
  if (!p) return { ok: false, error: 'para tirar o elemento é preciso dizer em qual projeto (id).' };
@@ -1248,6 +1258,32 @@ function conferirAssinatura(p, r, nome) {
1248
1258
  projeto.anotar(p.id, 'assinatura', fora.length ? `fora da assinatura ${nome}: ${fora.join('; ')}` : `seguiu a assinatura ${nome}`);
1249
1259
  }
1250
1260
 
1261
+ /* A REFERÊNCIA VISUAL que a pessoa manda (13/10: "não usa as referências
1262
+ que mandei" — era o print da landing, arrastado no pedido). O agente não
1263
+ enxerga imagem; aqui ela entra no projeto, as cores saem pelo ffmpeg e, se o
1264
+ projeto ainda não tem marca, viram a marca. Com site, o site manda. */
1265
+ function importarReferencia(p, arquivo) {
1266
+ const orig = String(arquivo || '').trim().replace(/^file:\/\//, '');
1267
+ if (!orig || !fs.existsSync(orig)) return { ok: false, error: 'mande em "arquivo" o caminho da imagem de referência.' };
1268
+ const nome = path.basename(orig).normalize('NFD').replace(/[\u0300-\u036f]/g, '').replace(/[^\w.-]+/g, '-');
1269
+ const destino = path.join(p.pasta, 'midia', 'referencias', nome);
1270
+ fs.mkdirSync(path.dirname(destino), { recursive: true });
1271
+ fs.copyFileSync(orig, destino);
1272
+ const rel = path.relative(p.pasta, destino).replace(/\\/g, '/');
1273
+ const cores = marcaLib.coresDaImagem(destino);
1274
+ const temMarca = !!(p.marca && Object.keys(marcaLib.marcaEmObjeto(p.marca).cores || {}).length);
1275
+ const salvar = { referencias: [...(p.referencias || []).filter((x) => x.src !== rel), { src: rel, cores, de: orig }] };
1276
+ if (!temMarca && cores.length) salvar.marca = { ...(p.marca || {}), ...marcaLib.marcaEmObjeto({ cores }) };
1277
+ projeto.salvar(p.id, salvar);
1278
+ projeto.anotar(p.id, 'referencia', `imagem ${rel}: ${cores.join(' ') || 'sem cor'}${temMarca ? '' : ' (virou a marca)'}`);
1279
+ return {
1280
+ ok: true, src: rel, cores, viraMarca: !temMarca && cores.length > 0,
1281
+ comoUsar: (temMarca ? 'O projeto já tem a marca do site: ela manda nas cores; a referência guia o JEITO (composição, peso, o que aparece). '
1282
+ : 'Estas cores viraram a marca do projeto. ')
1283
+ + 'Se a imagem é print do site ou do produto, o vídeo mostra a tela de verdade (estudio_midia acao:"print" e a peça printsite).',
1284
+ };
1285
+ }
1286
+
1251
1287
  async function prepararRoteiro(p, args) {
1252
1288
  const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
1253
1289
  if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
@@ -1258,8 +1294,16 @@ async function prepararRoteiro(p, args) {
1258
1294
  for (const pt of roteiro.partes || []) {
1259
1295
  if (pt.src && !existe(pt.src)) { if (!pt.busca && /^https?:/i.test(pt.src)) pt.busca = pt.titulo || ''; delete pt.src; }
1260
1296
  }
1297
+ // As imagens do pedido (o terminal guardou em referencias.imagens) entram sozinhas, uma vez por projeto.
1298
+ const jaTem = new Set((p.referencias || []).map((x) => x.de));
1299
+ const novas = referencias.imagens().filter((f) => !jaTem.has(f));
1300
+ for (const f of novas) { try { importarReferencia(p, f); } catch (e) { projeto.anotar(p.id, 'referencia', `não importou ${path.basename(f)}: ${e.message}`); } }
1301
+ if (novas.length) p = projeto.ler(p.id) || p;
1261
1302
  if (roteiro.marca) {
1262
- roteiro.marca = { ...(p.marca || {}), ...roteiro.marca };
1303
+ /* A do roteiro por cima da do site, CAMPO A CAMPO: antes o "cores" em
1304
+ lista do agente apagava o {fundo, destaque} lido do site (13/10). */
1305
+ const site = marcaLib.marcaEmObjeto(p.marca), dele = marcaLib.marcaEmObjeto(roteiro.marca);
1306
+ roteiro.marca = { ...site, ...dele, cores: { ...(site.cores || {}), ...(dele.cores || {}) }, fontes: { ...(site.fontes || {}), ...(dele.fontes || {}) } };
1263
1307
  if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
1264
1308
  } else if (p.marca) roteiro.marca = p.marca;
1265
1309
  const partes = roteiro.partes || [];
@@ -1277,6 +1321,12 @@ async function prepararRoteiro(p, args) {
1277
1321
  const semImagem = referencias.semImagem(ref);
1278
1322
  let estilo = escolherEstilo({ pedido: pedidoTxt, agente: args.estilo, roteiro: roteiro.estilo, girar: true, semImagem });
1279
1323
  if (semImagem && (estilo === 'hibrido' || estilo === 'cinema')) estilo = 'gerativo';
1324
+ /* AS FONTES DA CASA (13/10, o primocode-ele-constroi.mp4 que o Joel aprovou):
1325
+ no gerativo, título em Inter e texto em JetBrains Mono, com as CORES da
1326
+ marca. Só muda se o pedido falar de fonte. */
1327
+ if (estilo === 'gerativo' && !/\bfontes?\b|tipografia|\bserifa/i.test(pedidoTxt)) {
1328
+ roteiro.marca = { ...(roteiro.marca || {}), fontes: { titulo: 'Inter', texto: 'JetBrains Mono' } };
1329
+ }
1280
1330
  /* "O vídeo deve ter um 3D do fone" (30/09): pedido de 3D vira o gerativo
1281
1331
  com o produto modelado girando numa batida (kit/tres.jsx) — a menos que
1282
1332
  o agente tenha escolhido outro estilo pelo nome. */
@@ -1848,6 +1898,9 @@ async function prepararRoteiro(p, args) {
1848
1898
  } catch { /* fica sem */ }
1849
1899
  continue;
1850
1900
  }
1901
+ // O ícone do próprio site (estudio_site guardou os <svg> na marca).
1902
+ const doSite = h.match(/^site:(\d+)$/i);
1903
+ if (doSite) { const x = ((roteiro.marca || {}).iconesSite || [])[Number(doSite[1]) - 1]; if (x && x.svg) icones[h] = x.svg; continue; }
1851
1904
  if (!/^[a-z0-9-]+:[a-z0-9-]+$/i.test(h) || icones[h]) continue;
1852
1905
  try { const ic = await assets.icone(h, { pasta: p.pasta }); if (ic && ic.ok) icones[h] = ic.svg; else projeto.anotar(p.id, 'icones', `herói ${h} não baixou: ${(ic && ic.error) || '?'}`); } catch { /* fica sem */ }
1853
1906
  }
@@ -2235,6 +2288,9 @@ async function ferramentaSite(args) {
2235
2288
  }
2236
2289
  }
2237
2290
  } catch { /* sem gradiente */ }
2291
+ /* Os ícones do próprio site (os <svg> dos cartões): viram "site:N" no roteiro. */
2292
+ const icSite = marcaLib.iconesDoSite(html);
2293
+ if (icSite.length) { marca.iconesSite = icSite; r.icones = icSite.map((x, i) => `site:${i + 1}${x.nome ? ' (' + x.nome + ')' : ''}`); }
2238
2294
  projeto.salvar(args.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: r.url }, marca });
2239
2295
  projeto.anotar(args.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos, ${a.paginas.length} páginas lidas`);
2240
2296
  let orcamento = 4200;
@@ -2248,7 +2304,8 @@ async function ferramentaSite(args) {
2248
2304
  r.comoUsar = 'Escreva o roteiro com os FATOS acima (datas, local, números, frases da marca) — nada '
2249
2305
  + 'inventado. As fotos e os vídeos reais da marca entram SOZINHOS no vídeo: não precisa pôr '
2250
2306
  + '"busca" nas partes de imagem (use tipo "imagem" com só o título). Use as cores, a fonte e '
2251
- + 'a logo em roteiro.marca.';
2307
+ + 'a logo em roteiro.marca (objeto: {cores: {fundo, destaque, tinta}, fontes: {titulo, texto}}).'
2308
+ + (r.icones ? ' Os ícones do site estão em "icones": ponha icone:"site:N" na parte em que o assunto dele aparece.' : '');
2252
2309
  } catch (e) {
2253
2310
  r.acervo = { erro: e.message };
2254
2311
  }
@@ -430,8 +430,8 @@ function letra({ t, fps, l, p, j, ch, i, idx, n, b, cx, cores, eixosF, alvoSaida
430
430
  const centroLinha = cx ? cx.x + cx.w / 2 : 0;
431
431
  switch (c) {
432
432
  case 'carimbo': {
433
- const r0 = (acaso(semente) - 0.5) * 8;
434
- tr.push('rotate(' + (r0 * (1 - m)).toFixed(2) + 'deg)', 'scale(' + (1 + 0.32 * (1 - m)).toFixed(4) + ')');
433
+ // Sem inclinação: a letra carimbada torta lia como defeito (13/10). Fica o impacto da escala.
434
+ tr.push('scale(' + (1 + 0.32 * (1 - m)).toFixed(4) + ')');
435
435
  const f = tl * fps;
436
436
  blur = f < 2 ? 1.4 : Math.max(0, 0.7 * (1 - (f - 2) / 7));
437
437
  break;
@@ -917,7 +917,11 @@ export function Gerativo({ el, t, fps }) {
917
917
  leve a cada meio segundo, como um bumbo. */
918
918
  // A respiração lenta do mundo (sem bumbo: o pulso batendo lia como tranco).
919
919
  sz += 0.008 * Math.sin((t * Math.PI * 2) / 3.4);
920
- const cam = { x: cam0.x + sx, y: cam0.y + sy, zoom: cam0.zoom * (1 + sz), rot: cam0.rot + sr };
920
+ /* A câmera NÃO GIRA (13/10, "os motions saem muito tortos"): o giro das
921
+ chaves (cam0.rot) e o da deriva (sr) inclinavam o mundo inteiro, e todo
922
+ texto saía com a linha de base caída. Ficam a deriva, o zoom e o tranco. */
923
+ void sr;
924
+ const cam = { x: cam0.x + sx, y: cam0.y + sy, zoom: cam0.zoom * (1 + sz), rot: 0 };
921
925
  const ac0 = acentoEm(t, estados);
922
926
  // O acento também vive: treme de leve e respira na espessura.
923
927
  const ac = { ...ac0, pts: ac0.pts.map((q, i) => [q[0] + Math.sin(t * 3.1 + i * 0.5) * 1.6 * base, q[1] + Math.cos(t * 2.7 + i * 0.37) * 1.6 * base]), larg: ac0.larg * (1 + 0.08 * Math.sin(t * 4.2)) };
@@ -929,7 +933,8 @@ export function Gerativo({ el, t, fps }) {
929
933
  const origem = tela(ac.cx, ac.cy);
930
934
  /* O ESPAÇO 3D: o mundo tem perspectiva, a câmera também inclina (rotateX/Y)
931
935
  e as camadas estão em profundidades diferentes — o texto não é adesivo. */
932
- const rx = Math.cos(t * 0.27) * 1.4, ry = Math.sin(t * 0.31) * 2 + cam.rot * 0.6;
936
+ // A perspectiva fica, mas quase parada: 1,4° e 2° deixavam a frase em trapézio.
937
+ const rx = Math.cos(t * 0.27) * 0.35, ry = Math.sin(t * 0.31) * 0.5;
933
938
  const mundo = 'translate(' + W / 2 + 'px,' + H / 2 + 'px) rotate(' + cam.rot.toFixed(3) + 'deg) rotateX(' + rx.toFixed(3) + 'deg) rotateY(' + ry.toFixed(3) + 'deg) scale(' + cam.zoom.toFixed(4) + ') translate(' + (-W / 2 - cam.x).toFixed(1) + 'px,' + (-H / 2 - cam.y).toFixed(1) + 'px)';
934
939
  // No ar: a batida atual, a anterior enquanto sai.
935
940
  const noAr = bs.filter((x, i) => i === bi || (i === bi - 1 && t < x.ate + 0.14 && x.saida !== 'corte'));
@@ -280,4 +280,109 @@ async function marcaDaEmpresa(args, { projectDir, studio }) {
280
280
  };
281
281
  }
282
282
 
283
- module.exports = { marcaDaEmpresa, candidatasALogo, coresDe, fontesDe, metas, atributo };
283
+ /* A MARCA EM LISTA VIRA OBJETO (13/10). O agente escreve a marca do jeito
284
+ que leu: "cores": ["#22d3ee", "#0a0b0b", …], "fontes": ["Instrument
285
+ Serif", "JetBrains Mono"]. O Estúdio lê {fundo, destaque, tinta} e
286
+ {titulo, texto, mono}. Sem a conversão, `cores.fundo` não existia, o
287
+ gerativo achava que não havia marca e sorteava o humor: o motion do
288
+ PrimoCode saiu vermelho e creme em Bodoni, com o site preto e ciano.
289
+ cores a primeira escura é o fundo, a primeira viva o destaque (a
290
+ segunda, o apoio), a primeira clara a tinta;
291
+ fontes a mono vai para mono; das outras, a 1ª é título e a 2ª texto.
292
+ O que já é objeto passa intacto. */
293
+ function marcaEmObjeto(m) {
294
+ if (!m || typeof m !== 'object') return {};
295
+ const out = { ...m };
296
+ if (Array.isArray(m.cores)) {
297
+ const rgb = (h) => { const n = parseInt(h.slice(1), 16); return [n >> 16, (n >> 8) & 255, n & 255].map((v) => v / 255); };
298
+ const luz = (h) => { const [r, g, b] = rgb(h); return 0.2126 * r + 0.7152 * g + 0.0722 * b; };
299
+ const sat = (h) => { const v = rgb(h); return Math.max(...v) - Math.min(...v); };
300
+ const cs = m.cores.map((x) => String((x && x.cor) || x || '').trim()).filter((x) => /^#[0-9a-f]{6}$/i.test(x));
301
+ const vivas = cs.filter((x) => sat(x) > 0.25);
302
+ const fundo = cs.find((x) => luz(x) < 0.12 && sat(x) <= 0.25);
303
+ const tinta = cs.find((x) => luz(x) > 0.8 && sat(x) <= 0.25);
304
+ out.cores = { ...(fundo ? { fundo } : {}), ...(vivas[0] ? { destaque: vivas[0] } : {}), ...(vivas[1] ? { apoio: vivas[1] } : {}), ...(tinta ? { tinta } : {}) };
305
+ }
306
+ if (Array.isArray(m.fontes)) {
307
+ const fs = m.fontes.map((f) => String((f && f.nome) || f || '').trim()).filter(Boolean);
308
+ const mono = fs.find((f) => /mono|code|consol/i.test(f));
309
+ const resto = fs.filter((f) => f !== mono);
310
+ out.fontes = { ...(resto[0] ? { titulo: resto[0] } : {}), ...(resto[1] ? { texto: resto[1] } : {}), ...(mono ? { mono } : {}) };
311
+ }
312
+ return out;
313
+ }
314
+
315
+ /* OS ÍCONES DO SITE (13/10: "não usa os ícones do site"). Os ícones de uma
316
+ landing quase sempre são <svg> inline (o desenho de traço ao lado de cada
317
+ cartão), e não arquivos. Aqui saem os pequenos — com viewBox, sem imagem
318
+ nem script dentro, até 4 KB — com um nome: o aria-label, o <title>, ou o
319
+ título (h2/h3/h4) logo depois dele no HTML. No roteiro viram "site:N". */
320
+ function iconesDoSite(html, max = 12) {
321
+ const out = [], vistos = new Set();
322
+ const re = /<svg\b[^>]*>[\s\S]*?<\/svg>/gi;
323
+ let m;
324
+ while ((m = re.exec(String(html || ''))) && out.length < max) {
325
+ const svg = m[0];
326
+ if (svg.length > 4000 || /<(image|script|foreignObject)\b/i.test(svg) || !/viewBox=/i.test(svg)) continue;
327
+ const abre = svg.slice(0, svg.indexOf('>') + 1);
328
+ const lado = Number((abre.match(/\b(?:width|height)=["']?(\d+)/i) || [])[1] || 0);
329
+ if (lado > 120) continue;
330
+ const corpo = svg.replace(/\s+/g, ' ');
331
+ if (vistos.has(corpo)) continue;
332
+ vistos.add(corpo);
333
+ const depois = String(html).slice(re.lastIndex, re.lastIndex + 500);
334
+ const nome = (abre.match(/aria-label=["']([^"']+)/i) || [])[1]
335
+ || (svg.match(/<title>([^<]+)<\/title>/i) || [])[1]
336
+ || ((depois.match(/<h[2-4][^>]*>([\s\S]*?)<\/h[2-4]>/i) || [])[1] || '').replace(/<[^>]+>/g, '').trim()
337
+ || '';
338
+ out.push({ nome: nome.slice(0, 40), svg });
339
+ }
340
+ return out;
341
+ }
342
+
343
+ /* AS CORES DE UMA IMAGEM (a referência que a pessoa manda: o print do site,
344
+ um frame, um anúncio de que gostou). O ffmpeg reduz a imagem a 160 px e
345
+ devolve os pixels; as cores parecidas viram uma, e saem as 6 que mais
346
+ aparecem, da mais comum à menos. Sem ffmpeg, lista vazia. */
347
+ function coresDaImagem(arquivo, max = 6) {
348
+ const { spawnSync } = require('child_process');
349
+ const r = spawnSync('ffmpeg', ['-v', 'error', '-i', arquivo, '-vf', 'scale=160:-1', '-frames:v', '1', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'], { maxBuffer: 1 << 22 });
350
+ if (r.status !== 0 || !r.stdout || !r.stdout.length) return [];
351
+ const conta = new Map();
352
+ for (let i = 0; i + 2 < r.stdout.length; i += 3) {
353
+ const k = ((r.stdout[i] >> 3) << 10) | ((r.stdout[i + 1] >> 3) << 5) | (r.stdout[i + 2] >> 3);
354
+ conta.set(k, (conta.get(k) || 0) + 1);
355
+ }
356
+ const hex = (k) => '#' + [(k >> 10) & 31, (k >> 5) & 31, k & 31].map((v) => (v * 8 + 4).toString(16).padStart(2, '0')).join('');
357
+ const rgb = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
358
+ const longe = (a, b) => { const x = rgb(a), y = rgb(b); return Math.hypot(x[0] - y[0], x[1] - y[1], x[2] - y[2]); };
359
+ // As 4 mais comuns, e as 2 mais VIVAS que aparecem de verdade: a cor da
360
+ // marca costuma ocupar pouca área (o ciano de um título, o botão), e sem
361
+ // isto o print dava só fundo e cinza.
362
+ const total = r.stdout.length / 3;
363
+ const sat = (h) => { const v = rgb(h); return (Math.max(...v) - Math.min(...v)) / 255; };
364
+ const lista = [...conta].sort((a, b) => b[1] - a[1]).map(([k, n]) => ({ h: hex(k), n }));
365
+ const out = [];
366
+ const por = (x) => { if (out.length < max && out.every((o) => longe(o, x.h) > 48)) out.push(x.h); };
367
+ for (const x of lista) { if (out.length >= Math.max(1, max - 2)) break; por(x); }
368
+ /* As vivas se espalham em dezenas de tons de 1 ou 2 pixels (a borda da
369
+ letra mistura com o fundo): contam por MATIZ, em faixas de 30°, e de
370
+ cada faixa vale o pixel mais vivo — o miolo da letra, não a borda. */
371
+ const faixas = new Map();
372
+ for (let i = 0; i + 2 < r.stdout.length; i += 3) {
373
+ const v = [r.stdout[i], r.stdout[i + 1], r.stdout[i + 2]], mx = Math.max(...v), mn = Math.min(...v), d = mx - mn;
374
+ if (d / 255 < 0.35 || mx < 60) continue;
375
+ const hh = mx === v[0] ? ((v[1] - v[2]) / d) % 6 : mx === v[1] ? (v[2] - v[0]) / d + 2 : (v[0] - v[1]) / d + 4;
376
+ const f = Math.floor(((hh * 60 + 360) % 360) / 30);
377
+ const x = faixas.get(f) || { n: 0, melhor: null, nota: -1 };
378
+ x.n++;
379
+ const nota = d * mx;
380
+ if (nota > x.nota) { x.nota = nota; x.melhor = '#' + v.map((c) => c.toString(16).padStart(2, '0')).join(''); }
381
+ faixas.set(f, x);
382
+ }
383
+ [...faixas.values()].filter((x) => x.n >= Math.max(8, total * 0.0005)).sort((a, b) => b.n - a.n).slice(0, 2).forEach((x) => por({ h: x.melhor }));
384
+ void sat; void lista;
385
+ return out;
386
+ }
387
+
388
+ module.exports = { marcaDaEmpresa, candidatasALogo, coresDe, fontesDe, metas, atributo, marcaEmObjeto, iconesDoSite, coresDaImagem };
@@ -80,8 +80,12 @@ function notasDaAssinatura() {
80
80
  function instrucao(atual = ATUAL) { const i = instrucaoDoModo(atual); return i ? i + notasDaAssinatura() : ''; }
81
81
  function instrucaoDoModo({ modo, escopo, explicito } = ATUAL) {
82
82
  // O vídeo sem imagem é motion: o agente escolhe o estilo de motion que serve ao roteiro, não um fixo.
83
- const motion = 'O vídeo é MOTION: tipografia, formas, ícones, animação e som. Escolha em roteiro.estilo o motion que serve a este '
84
- + 'roteiro (gerativo, fluido, impacto, vox, marca, lancamento) ou deixe vazio para o rodízio — não repita sempre o mesmo.';
83
+ const motion = 'O vídeo é MOTION: tipografia, formas, ícones, animação e som, no ESTILO DA CASA (o gerativo, com as cores da '
84
+ + 'marca): não ponha roteiro.estilo, a menos que a pessoa tenha pedido outro estilo pelo nome. '
85
+ /* O ROTEIRO (13/10: "o roteiro que monta é horrível" — eram os títulos da landing em fila). */
86
+ + 'O ROTEIRO é uma história de 15 a 25 s: gancho (a situação de quem assiste) → o que o produto FAZ → UMA prova concreta '
87
+ + 'dos fatos do site → a ação (nome, comando, link). Nunca os títulos das seções do site em sequência. Cada fala tem de 5 a '
88
+ + '12 palavras e puxa a próxima; o titulo da parte é a palavra-chave (1 a 4 palavras), não a fala repetida.';
85
89
  if (modo === 'sem' && explicito) {
86
90
  return '[REFERÊNCIAS: NENHUMA] A pessoa quer SÓ MOTION: não pesquise (nada de pesquisar_referencia nem estudio_site), '
87
91
  + 'não busque foto nem vídeo, não use estudio_midia de banco. Escreva o roteiro com o que o pedido já diz. ' + motion;
@@ -124,4 +128,42 @@ function filtrar(r, { modo, escopo } = ATUAL) {
124
128
  return { ...r, fontes: refs.length, referencias: refs, ...(fora ? { foraDoEscopo: fora } : {}) };
125
129
  }
126
130
 
127
- module.exports = { definir, atual, limpar, semImagem, termos, entender, inferir, instrucao, dominios, filtrar };
131
+ /* AS IMAGENS DO PEDIDO (13/10: "não usa as referências que mandei" — era o
132
+ print da landing, arrastado no pedido). O modelo do servidor não enxerga
133
+ imagem e nem pode chamar a importação (a lista de ações dele é fechada),
134
+ então o terminal guarda aqui os caminhos de imagem que EXISTEM no texto do
135
+ pedido, e o Estúdio importa sozinho no próximo roteiro (index.js). */
136
+ let IMAGENS = [];
137
+ const RE_EXT_IMG = /\.(png|jpe?g|gif|webp|heic|bmp)\b/gi;
138
+ function imagensNoTexto(texto) {
139
+ const fs = require('fs'), os = require('os');
140
+ const t = String(texto || '');
141
+ const achadas = [];
142
+ let m;
143
+ while ((m = RE_EXT_IMG.exec(t))) {
144
+ const fim = m.index + m[0].length;
145
+ // Do fim da extensão para trás: o começo é uma "/" (ou "~/", "file://") no início ou depois de espaço/aspas.
146
+ let melhor = null;
147
+ for (let i = m.index; i >= 0; i--) {
148
+ if (t[i] !== '/' && !(t[i] === '~' && t[i + 1] === '/')) continue;
149
+ const antes = i === 0 ? ' ' : t[i - 1];
150
+ if (!/[\s'"(:]/.test(antes) && !(t.slice(Math.max(0, i - 7), i) === 'file://')) continue;
151
+ const bruto = t.slice(i, fim).replace(/\\(.)/g, '$1').replace(/^~(?=\/)/, os.homedir());
152
+ try { if (fs.statSync(bruto).isFile()) melhor = bruto; } catch { /* não é este começo */ }
153
+ }
154
+ if (melhor && !achadas.includes(melhor)) achadas.push(melhor);
155
+ }
156
+ return achadas.slice(0, 6);
157
+ }
158
+ function guardarImagens(lista) { IMAGENS = Array.isArray(lista) ? lista.slice(0, 6) : []; return IMAGENS; }
159
+ function imagens() { return IMAGENS; }
160
+ function instrucaoDasImagens(lista = IMAGENS) {
161
+ if (!lista.length) return '';
162
+ return `[REFERÊNCIA VISUAL] A pessoa mandou ${lista.length} imagem(ns): ${lista.map((f) => require('path').basename(f)).join(', ')}. `
163
+ + 'Elas SÃO a referência do pedido, mesmo em "só motion". Se o pedido é vídeo, o Estúdio já guarda cada uma no projeto e tira as '
164
+ + 'cores dela (com site, as cores do site mandam). Escreva o roteiro seguindo o que a referência mostra e diga no fim, '
165
+ + 'em uma linha, o que dela o vídeo usou.';
166
+ }
167
+
168
+ module.exports = { definir, atual, limpar, semImagem, termos, entender, inferir, instrucao, dominios, filtrar,
169
+ imagensNoTexto, guardarImagens, imagens, instrucaoDasImagens };
@@ -32,7 +32,8 @@
32
32
  const fs = require('fs');
33
33
  const path = require('path');
34
34
 
35
- const ORCAMENTO_SERVIDOR = 6 * 1024;
35
+ // 2,5 KB no servidor (13/10): 6 KB de arquivo pré-lido não cabiam no orçamento do plano grátis e empurravam a conversa para fora.
36
+ const ORCAMENTO_SERVIDOR = 2.5 * 1024;
36
37
  const ORCAMENTO_LOCAL = 40 * 1024;
37
38
  const MAIOR_ARQUIVO = 20 * 1024;
38
39
 
package/lib/tools.js CHANGED
@@ -630,8 +630,15 @@ async function despachar(name, args, ctx) {
630
630
  return editFile(args, projectDir);
631
631
  case 'read_file':
632
632
  return readFile(args, projectDir);
633
- case 'list_dir':
634
- return { ok: true, tree: treeDir(resolverParaLer(args.path, projectDir), args.depth || 2) };
633
+ case 'list_dir': {
634
+ /* No máximo 60 linhas (13/10: "continua listando coisas desnecessárias").
635
+ Pasta grande listada inteira enche a conversa e não diz onde está o que importa. */
636
+ const arvore = String(treeDir(resolverParaLer(args.path, projectDir), args.depth || 2));
637
+ const ls = arvore.split('\n');
638
+ if (ls.length <= 60) return { ok: true, tree: arvore };
639
+ return { ok: true, tree: ls.slice(0, 60).join('\n'), cortado: ls.length - 60,
640
+ dica: 'Lista grande, cortei. Não liste de novo: use glob com o padrão do que procura (ex.: "src/**/*.css") ou grep pelo nome.' };
641
+ }
635
642
  /* `pasta` (opcional): procurar numa pasta que o usuário citou, sem
636
643
  trocar a de trabalho. Os caminhos voltam relativos a ELA, e a
637
644
  resposta diz qual é. */
package/lib/travado.js CHANGED
@@ -15,6 +15,10 @@
15
15
  * vezes sem escrita no meio? → travado
16
16
  * 3. VOLTAS_SEM_NOVIDADE voltas seguidas sem escrever nada e sem nenhuma
17
17
  * chamada que ainda não tinha sido feita? → travado
18
+ * 4. LIMITE_OLHAR leituras, listagens e buscas DIFERENTES seguidas, sem
19
+ * nenhuma ação no meio (escrever, rodar, usar o Estúdio)? → travado
20
+ * (13/10: "continua lendo e listando coisas desnecessárias". As regras
21
+ * 1 a 3 só pegam repetição: ler 20 arquivos diferentes nunca travava.)
18
22
  * Na primeira vez, um empurrão curto (o que está repetindo, o que fazer). Se
19
23
  * travar de novo depois do empurrão, PARA e diz por quê.
20
24
  *
@@ -28,10 +32,14 @@ const path = require('path');
28
32
  const LIMITE_RELEITURA = 3;
29
33
  const LIMITE_MESMA_CHAMADA = 4;
30
34
  const VOLTAS_SEM_NOVIDADE = 6;
35
+ const LIMITE_OLHAR = 8;
36
+ const OLHAR = new Set(['read_file', 'list_dir', 'glob', 'grep']);
37
+ // O shell que só olha: listar, achar, procurar, mostrar arquivo.
38
+ const RE_SHELL_OLHA = /^(?:cd\s+\S+\s*&&\s*)?(?:ls|find|grep|rg|cat|head|tail|sed\s+-n|tree|wc|nl|less|more|stat|file|du)\b/;
31
39
  const ESCRITAS = new Set(['write_file', 'edit_file', 'multi_edit']);
32
40
 
33
41
  function novo() {
34
- return { leituras: new Map(), chamadas: new Map(), vistas: new Set(), novidade: false, voltasSemNovidade: 0, avisos: 0 };
42
+ return { leituras: new Map(), chamadas: new Map(), vistas: new Set(), novidade: false, voltasSemNovidade: 0, avisos: 0, olhou: [], limiteOlhar: LIMITE_OLHAR };
35
43
  }
36
44
 
37
45
  /* A faixa que a leitura cobre. Sem offset nem limit, o read_file devolve o
@@ -89,6 +97,19 @@ function observar(estado, nome, args, pasta) {
89
97
  estado.novidade = true;
90
98
  }
91
99
  if (!estado.vistas.has(chave)) { estado.vistas.add(chave); estado.novidade = true; }
100
+ // Regra 4: o que só olha soma; qualquer ação zera.
101
+ const soOlha = OLHAR.has(nome) || (nome === 'run_shell' && RE_SHELL_OLHA.test(String(a.command || '').trim()));
102
+ if (!soOlha) { estado.olhou = []; estado.limiteOlhar = LIMITE_OLHAR; }
103
+ else {
104
+ const alvo = a.path || a.pattern || a.query || a.command || '.';
105
+ const curto = pasta && a.path ? path.relative(pasta, caminhoDe(a.path, pasta)) || '.' : String(alvo).slice(0, 40);
106
+ estado.olhou.push(`${nome === 'run_shell' ? '$ ' : ''}${curto}`);
107
+ if (estado.olhou.length >= estado.limiteOlhar) {
108
+ // A segunda vez vem 4 olhadas depois: o empurrão dá espaço para o último passo, não para mais um passeio.
109
+ estado.limiteOlhar = estado.olhou.length + 4;
110
+ return `olhou ${estado.olhou.length} coisas seguidas sem fazer nada (${estado.olhou.slice(-6).join(', ')})`;
111
+ }
112
+ }
92
113
  const leitura = nome === 'read_file' ? a : nome === 'run_shell' ? leituraDoShell(a.command) : null;
93
114
  if (leitura && leitura.path) {
94
115
  const caminho = caminhoDe(leitura.path, pasta);
@@ -138,4 +159,4 @@ function decidir(estado, motivo) {
138
159
  };
139
160
  }
140
161
 
141
- module.exports = { LIMITE_RELEITURA, LIMITE_MESMA_CHAMADA, VOLTAS_SEM_NOVIDADE, LEITURA_PADRAO, novo, faixa, leituraDoShell, observar, fimDaVolta, decidir };
162
+ module.exports = { LIMITE_RELEITURA, LIMITE_MESMA_CHAMADA, VOLTAS_SEM_NOVIDADE, LIMITE_OLHAR, LEITURA_PADRAO, novo, faixa, leituraDoShell, observar, fimDaVolta, decidir };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.8.0",
3
+ "version": "10.9.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {