primocode 9.8.0-beta.15 → 9.8.0-beta.16

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 v9.8.0-beta.15
1
+ # PrimoCode v9.8.0-beta.16
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.15
12
+ ▐▛███▜▌ Primo Code v9.8.0-beta.16
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/lib/catalogo.js CHANGED
@@ -126,7 +126,8 @@ const GRUPOS = [
126
126
 
127
127
  { tool: 'estudio_site', escreve: false, faz: 'lê a identidade visual de uma marca: cores, fontes, logo e o que ela diz de si',
128
128
  args: 'url — OU marca: o nome da empresa, quando não há link ("faça um vídeo para a Paris Group"). '
129
- + '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. '
130
131
  + 'Devolve FATOS medidos, não um roteiro — o roteiro é seu. '
131
132
  + 'Use SEMPRE que o pedido citar uma empresa de verdade: inventar a cor de uma marca que existe '
132
133
  + 'é erro que sai pronto no vídeo entregue. '
@@ -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 };
@@ -44,9 +44,15 @@ function paleta(marca = {}) {
44
44
  const fundo = c.fundo || '#0E0F14';
45
45
  const escuro = luz(fundo) < 0.5;
46
46
  const tinta = c.tinta || (escuro ? '#F4F2EC' : '#15161A');
47
- const destaque = c.destaque || '#FF6A3D';
47
+ let destaque = c.destaque || '#FF6A3D';
48
+ /* A cor da marca precisa aparecer no fundo: o azul #1e3fae da Paris Group
49
+ sumia no fundo escuro (medido em 25/09). Pouco contraste, e o destaque
50
+ clareia (no escuro) ou escurece (no claro) até aparecer. */
51
+ for (let k = 0; k < 6 && Math.abs(luz(destaque) - luz(fundo)) < 0.38; k++) {
52
+ destaque = misturar(destaque, escuro ? '#FFFFFF' : '#000000', 0.18);
53
+ }
48
54
  return {
49
- fundo, tinta, destaque, escuro,
55
+ fundo, tinta, destaque, escuro, marca: c.destaque || destaque,
50
56
  fundo2: misturar(fundo, tinta, escuro ? 0.07 : 0.05),
51
57
  apoio: c.apoio || misturar(destaque, tinta, 0.35),
52
58
  suave: alfa(tinta, 0.68),
@@ -106,6 +112,7 @@ function registroDe(p, i, n) {
106
112
  function iconesDoRoteiro(roteiro) {
107
113
  const nomes = new Set();
108
114
  for (const p of roteiro.partes || []) {
115
+ for (const t of [`${p.titulo || ''} ${p.sub || ''}`, p.sim]) { const ic = iconeDoTexto(t); if (ic) nomes.add(ic); }
109
116
  for (const it of p.itens || []) {
110
117
  const t = typeof it === 'string' ? it : it.texto;
111
118
  const ic = (typeof it === 'object' && it.icone) || iconeDoTexto(t);
@@ -134,7 +141,7 @@ function iconeDoTexto(t) {
134
141
  * @param {Set} op.iconesOk os ícones que existem
135
142
  */
136
143
  function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080, tempos = [], narracao = null,
137
- sons = {}, trilha = null, imagens = {}, iconesOk = null }) {
144
+ sons = {}, trilha = null, imagens = {}, iconesOk = null, fundos = {} }) {
138
145
  const partes = (roteiro && roteiro.partes) || [];
139
146
  if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
140
147
  const ruins = partes.map((p, i) => (TIPOS.includes(p.tipo) ? null : `parte ${i + 1}: tipo "${p.tipo}" não existe (use ${TIPOS.join(', ')})`)).filter(Boolean);
@@ -171,7 +178,16 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
171
178
  let fundo = { tipo: 'cor', valor: variante ? cor.fundo2 : cor.fundo };
172
179
  let tinta = cor.tinta, destaque = cor.destaque, suave = cor.suave;
173
180
  if (p.tipo === 'numero') { fundo = { tipo: 'cor', valor: cor.destaque }; tinta = cor.sobreDestaque; destaque = cor.sobreDestaque; suave = alfa(cor.sobreDestaque, 0.75); }
174
- if (p.tipo !== 'imagem') {
181
+ /* A FOTO DA MARCA DE FUNDO: a cena de texto ganha a sala de verdade
182
+ atrás, com um véu da cor do fundo para o texto continuar legível. */
183
+ const fotoFundo = fundos[i];
184
+ if (fotoFundo && p.tipo !== 'imagem') {
185
+ const ehV = fotoFundo.tipo === 'video';
186
+ els.push({ tipo: ehV ? 'video' : 'imagem', src: fotoFundo.src, ...(ehV ? { volume: 0 } : {}), x: 0, y: 0, largura: '100%', altura: '100%', ajuste: 'cover',
187
+ anima: { escala: [{ t: 0, v: variante ? 1.14 : 1.02 }, { t: dur, v: variante ? 1.02 : 1.14, e: 'suave' }] } });
188
+ els.push({ tipo: 'forma', forma: 'retangulo', x: 0, y: 0, largura: '100%', altura: '100%',
189
+ fundo: { tipo: 'gradiente', cores: [alfa(cor.fundo, 0.93), alfa(cor.fundo, 0.72), alfa(cor.fundo, 0.55)], angulo: 90 } });
190
+ } else if (p.tipo !== 'imagem') {
175
191
  const R = Math.round(Math.max(W, H) * 0.62);
176
192
  const canto = [[W - R * 0.55, -R * 0.45], [-R * 0.45, H - R * 0.5], [W - R * 0.4, H - R * 0.55], [-R * 0.5, -R * 0.4]][i % 4];
177
193
  els.push({
@@ -210,6 +226,22 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
210
226
  tipo: 'forma', forma: 'retangulo', x, y: Math.round(y), largura: Math.round(W * 0.06), altura: Math.max(6, Math.round(H * 0.008)),
211
227
  cor: c, origem: 'left', anima: { escalaX: [{ t: atraso(de), v: 0 }, { t: atraso(de) + 0.5, v: 1, e: 'saida' }] },
212
228
  });
229
+ /* O ÍCONE DA IDEIA: um emoji grande, que salta e depois flutua, do
230
+ lado livre da cena — o que dá vida a cena de texto sem virar enfeite. */
231
+ const iconeDaIdeia = (texto, x, y, tam, de = 0.35) => {
232
+ const ic = iconeDoTexto(texto);
233
+ if (!ic || (iconesOk && !iconesOk.has(ic))) return false;
234
+ const q = atraso(de);
235
+ const t2 = r2(Math.max(q + 0.6, dur * 0.55)), t3 = r2(Math.max(t2 + 0.3, dur));
236
+ els.push({ tipo: 'icone', nome: ic, tamanho: Math.round(tam), x: Math.round(x), y: Math.round(y), ancora: 'centro',
237
+ anima: {
238
+ escala: [{ t: q, v: 0 }, { t: q + 0.25, v: 1.18, e: 'saida' }, { t: q + 0.42, v: 1, e: 'suave' }],
239
+ rotacao: [{ t: q, v: -18 }, { t: q + 0.42, v: 0, e: 'saida' }, { t: t3, v: 6, e: 'suave' }],
240
+ y: [{ t: q + 0.42, v: 0 }, { t: t2, v: -16, e: 'suave' }, { t: t3, v: 4, e: 'suave' }],
241
+ } });
242
+ if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + q), src: sons.pop, volume: 0.55 });
243
+ return true;
244
+ };
213
245
  const logo = (x, y, alt) => { if (marca.logo) els.push({ tipo: 'imagem', src: marca.logo, x, y, altura: alt, largura: Math.round(alt * 4), ajuste: 'contain', foco: 'left center', anima: { opacidade: [{ t: atraso(0.3), v: 0 }, { t: atraso(0.3) + 0.4, v: 1 }] } }); };
214
246
 
215
247
  switch (p.tipo) {
@@ -226,6 +258,7 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
226
258
  if (!centro) traco(M, y - H * 0.05);
227
259
  titulo(p.titulo || '', { y, larg, linhas: 3, max: fit.tamanho, x, alinhar: centro ? 'center' : 'left' });
228
260
  if (p.sub) linhaFina(p.sub, { y: y + hT + H * 0.03, larg, x, alinhar: centro ? 'center' : 'left', de: 0.6 });
261
+ if (!centro && deitado) iconeDaIdeia(`${p.titulo || ''} ${p.sub || ''}`, W - M - W * 0.09, H * 0.5, H * 0.2, 0.5);
229
262
  break;
230
263
  }
231
264
  case 'numero': {
@@ -273,7 +306,9 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
273
306
  const cy = colunas > 1 ? topo : topo + k * (hCard + gap);
274
307
  // O item entra quando a voz chega nele (ou em cascata, no slide).
275
308
  const quando = atraso(vid ? 0.5 + (dur0 * 0.85 - 0.5) * (k / n) : 0.3 + k * 0.12);
276
- const entra = { opacidade: [{ t: quando, v: 0 }, { t: quando + 0.3, v: 1 }], y: [{ t: quando, v: 40 }, { t: quando + 0.45, v: 0, e: 'saida' }] };
309
+ // Entra de baixo e depois respira: nada fica parado como slide.
310
+ const entra = { opacidade: [{ t: quando, v: 0 }, { t: quando + 0.3, v: 1 }],
311
+ y: [{ t: quando, v: 40 }, { t: quando + 0.45, v: 0, e: 'saida' }, { t: Math.max(quando + 0.5, dur), v: k % 2 ? -8 : 8, e: 'suave' }] };
277
312
  if (p.tipo === 'lista') {
278
313
  els.push({ tipo: 'forma', forma: 'retangulo', x: Math.round(cx), y: Math.round(cy), largura: Math.round(wCard), altura: Math.round(hCard), cor: alfa(tinta, cor.escuro ? 0.07 : 0.06), raio: Math.round(H * 0.025), anima: entra });
279
314
  } else if (k < n - 1 && colunas > 1) {
@@ -310,6 +345,7 @@ function comporRoteiro({ roteiro, modo = 'video', largura = 1920, altura = 1080,
310
345
  els.push({ tipo: 'texto', texto: p.nao || '', fonte: F.texto, peso: 600, tamanho: f1.tamanho, cor: alfa(tinta, 0.5), x: M, y: Math.round(y1), largura: Math.round(larg), entrelinha: 1.2, anima: { opacidade: [{ t: 0.1, v: 0 }, { t: 0.4, v: 1 }] } });
311
346
  els.push({ tipo: 'forma', forma: 'retangulo', x: M, y: Math.round(y1 + f1.tamanho * 0.62), largura: Math.round(Math.min(larg, (p.nao || '').length * f1.tamanho * 0.52)), altura: Math.max(5, Math.round(f1.tamanho * 0.09)), cor: destaque, origem: 'left', anima: { escalaX: [{ t: atraso(meio - 0.3), v: 0 }, { t: atraso(meio), v: 1, e: 'saida' }] } });
312
347
  titulo(p.sim || '', { y: y1 + f1.tamanho * 1.2 * f1.linhas + H * 0.05, larg, linhas: 3, max: f2.tamanho, de: atraso(meio) });
348
+ if (deitado) iconeDaIdeia(p.sim || '', W - M - W * 0.06, H * 0.22, H * 0.14, meio + 0.3);
313
349
  if (vid && sons.pop) efeitos.push({ t: r2((tp.de || 0) + meio), src: sons.pop, volume: 0.5 });
314
350
  break;
315
351
  }
@@ -42,6 +42,7 @@ const identidade = require('./identidade.js');
42
42
  const slides = require('./slides.js');
43
43
  const edicao = require('./edicao.js');
44
44
  const composicao = require('./composicao.js');
45
+ const acervoSite = require('./acervo-site.js');
45
46
 
46
47
  const PY = path.join(__dirname, '..', '..', 'studio');
47
48
 
@@ -198,10 +199,85 @@ function listarArquivos(pasta) {
198
199
 
199
200
  // ── estudio_plano ────────────────────────────────────────────────────────
200
201
 
202
+ /* O projeto de AGORA, quando o modelo esquece o id: o criado há menos de
203
+ 30 minutos. Medido com o modelo grátis em 25/09: ele chamou estudio_plano
204
+ sem id logo depois de criar o projeto, e a tarefa morreu ali. */
205
+ function projetoRecente() {
206
+ const agora = Date.now();
207
+ const lista = projeto.listar({ limite: 8 })
208
+ .filter((x) => x.criado && agora - new Date(x.criado).getTime() < 30 * 60000)
209
+ .sort((a, b) => new Date(b.criado) - new Date(a.criado));
210
+ return lista[0] || null;
211
+ }
212
+
213
+ /** O roteiro é utilizável como veio? (objeto ou JSON, com partes de tipo conhecido) */
214
+ function roteiroValido(r) {
215
+ let x = r;
216
+ if (typeof x === 'string') { try { x = JSON.parse(x); } catch { return null; } }
217
+ const partes = Array.isArray(x) ? x : x && x.partes;
218
+ if (!Array.isArray(partes) || partes.length < 2) return null;
219
+ if (!partes.every((pt) => pt && composicao.TIPOS.includes(pt.tipo))) return null;
220
+ return Array.isArray(x) ? { partes } : x;
221
+ }
222
+
223
+ // O último site lido, para o projeto que nasceu DEPOIS da leitura.
224
+ let ultimoSite = null;
225
+
226
+ async function garantirAcervo(p) {
227
+ if (p.acervo && (p.acervo.fotos || []).length) return p;
228
+ if (!ultimoSite || Date.now() - ultimoSite.quando > 60 * 60000) return p;
229
+ try {
230
+ const a = await acervoSite.colher({ url: ultimoSite.url, pedido: p.pedido || '', pasta: p.pasta, htmlInicial: ultimoSite.html,
231
+ buscar: identidade.buscar, baixar: assets.baixar });
232
+ projeto.salvar(p.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: ultimoSite.url }, ...(p.marca ? {} : { marca: ultimoSite.marca }) });
233
+ projeto.anotar(p.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos`);
234
+ } catch { /* segue sem acervo */ }
235
+ return projeto.ler(p.id);
236
+ }
237
+
238
+ /* O ROTEIRISTA do servidor: quando o modelo não manda um roteiro que preste
239
+ (nenhum, ou texto corrido), o pedido e os fatos da marca viram o roteiro
240
+ numa chamada só, com uma tarefa só. */
241
+ async function roteiroDoServidor(p, args) {
242
+ const cfg = require('../config').loadConfig();
243
+ const api = require('../api');
244
+ const pedido = [p.pedido, args.pedido].filter(Boolean).join(' — ');
245
+ const seg = Number(args.segundos) || Number((pedido.match(/(\d{2,3})\s*(s\b|seg)/i) || [])[1]) || 30;
246
+ const acervo = p.acervo || {};
247
+ const r = await api.request(cfg.server, '/api/roteiro', {
248
+ pedido, segundos: seg,
249
+ fatos: (acervo.fatos || []).map((f) => f.texto).join('\n\n').slice(0, 5000),
250
+ fotos: (acervo.fotos || []).length ? `${acervo.fotos.length} fotos reais` : '',
251
+ site: acervo.site ? new URL(acervo.site).hostname.replace(/^www\./, '') : '',
252
+ rascunho: typeof args.roteiro === 'string' ? args.roteiro : '',
253
+ }, cfg.token);
254
+ if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'o roteirista não respondeu.' };
255
+ return { ok: true, roteiro: { ...r.roteiro, marca: (args.roteiro && args.roteiro.marca) || p.marca || undefined } };
256
+ }
257
+
201
258
  async function ferramentaPlano(args) {
202
- const p = projeto.ler(args.id);
259
+ let p = projeto.ler(args.id);
260
+ // Só quando o id FALTOU: id errado continua sendo erro, para não escrever no projeto errado.
261
+ if (!p && (args.id == null || String(args.id).trim() === '') && (args.roteiro || args.plano || args.cenas)) {
262
+ const recente = projetoRecente();
263
+ if (recente) { args = { ...args, id: recente.id }; p = recente; }
264
+ }
203
265
  if (!p) return semProjeto(args.id);
204
266
 
267
+ /* Roteiro que não serve (texto corrido, JSON quebrado, tipo inventado) ou
268
+ nenhum, num projeto ainda sem plano: o roteirista escreve. */
269
+ const valido = args.roteiro ? roteiroValido(args.roteiro) : null;
270
+ if (!args.plano && !args.cenas && !args.voltarPara && !valido && (args.roteiro || !projeto.lerPlano(args.id))) {
271
+ p = await garantirAcervo(p);
272
+ const escrito = await roteiroDoServidor(p, args);
273
+ if (!escrito.ok) return escrito;
274
+ args = { ...args, roteiro: escrito.roteiro };
275
+ projeto.anotar(p.id, 'plano', `roteiro escrito pelo roteirista: ${escrito.roteiro.partes.length} partes`);
276
+ } else if (valido) {
277
+ args = { ...args, roteiro: valido };
278
+ p = await garantirAcervo(p);
279
+ }
280
+
205
281
  if (args.voltarPara) return projeto.voltarPara(args.id, args.voltarPara);
206
282
 
207
283
  if (!args.plano && !args.cenas && !args.roteiro) {
@@ -217,7 +293,15 @@ async function ferramentaPlano(args) {
217
293
  if (args.roteiro) {
218
294
  const r = await prepararRoteiro(p, args);
219
295
  if (!r.ok) return r;
220
- const conferida = plano.conferir(r.plano, { pasta: p.pasta });
296
+ let conferida = plano.conferir(r.plano, { pasta: p.pasta });
297
+ /* A rede de segurança: o elemento que não passa sai, e o vídeo segue.
298
+ Um defeito num ícone não pode custar o vídeo inteiro. */
299
+ if (!conferida.ok) {
300
+ projeto.anotar(p.id, 'defeito', `plano do roteiro reprovou: ${conferida.problemas.slice(0, 4).map((x) => x.onde + ' ' + x.oque).join(' | ')}`);
301
+ const ruins = new Set(conferida.problemas.map((x) => (String(x.onde).match(/^cenas\[(\d+)\]\.elementos\[(\d+)\]/) || []).slice(1, 3).join(':')).filter((k) => k.includes(':')));
302
+ r.plano.cenas.forEach((c, i) => { c.elementos = c.elementos.filter((_, j) => !ruins.has(`${i}:${j}`)); });
303
+ conferida = plano.conferir(r.plano, { pasta: p.pasta });
304
+ }
221
305
  if (!conferida.ok) {
222
306
  return { ok: false, error: 'o roteiro virou um plano com problemas (defeito do PrimoCode, não seu).', problemas: conferida.problemas.slice(0, 12) };
223
307
  }
@@ -593,12 +677,82 @@ async function baixarApoio(p, termo, { orientacao = null, video: querVideo = fal
593
677
  return null;
594
678
  }
595
679
 
680
+ /* ── A VOZ DO GEMINI ─────────────────────────────────────────────────────
681
+ * O roteiro inteiro numa chamada só (o plano grátis tem poucos pedidos por
682
+ * minuto), e as partes achadas depois na transcrição do próprio áudio: a
683
+ * primeira palavra de cada parte, procurada em ordem. Sem achar, o tempo é
684
+ * repartido pelo tamanho do texto de cada parte. */
685
+ const VOZ_GEMINI = { thalita: 'Kore', francisca: 'Aoede', antonio: 'Charon', feminina: 'Kore', masculina: 'Charon' };
686
+ const tokens = (t) => String(t || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter(Boolean);
687
+
688
+ function alinharPartes(lista, palavras, total) {
689
+ const ws = palavras.map((w) => ({ ...w, k: tokens(w.texto)[0] || '' }));
690
+ const chars = lista.map((x) => x.texto.length);
691
+ const soma = chars.reduce((a, b) => a + b, 0) || 1;
692
+ const inicios = [0];
693
+ let cursor = 0, acum = 0;
694
+ for (let i = 1; i < lista.length; i++) {
695
+ acum += chars[i - 1];
696
+ const esperado = Math.round(ws.length * acum / soma);
697
+ const alvo = tokens(lista[i].texto).find((w) => w.length >= 3) || tokens(lista[i].texto)[0];
698
+ let achado = -1;
699
+ // Procura perto de onde a proporção diz que a parte começa.
700
+ for (let j = Math.max(cursor + 1, esperado - 12); j < Math.min(ws.length, esperado + 12); j++) {
701
+ if (ws[j].k === alvo) { achado = j; break; }
702
+ }
703
+ const idx = achado > cursor ? achado : Math.max(cursor + 1, Math.min(ws.length - 1, esperado));
704
+ inicios.push(idx);
705
+ cursor = idx;
706
+ }
707
+ return lista.map((_, i) => {
708
+ const a = ws[inicios[i]], b = ws[(inicios[i + 1] || ws.length) - 1];
709
+ return { de: i === 0 ? 0 : (a ? a.de : 0), ate: b ? b.ate : total, palavras: [] };
710
+ });
711
+ }
712
+
713
+ async function narrarComGemini(lista, mp3, { voz } = {}) {
714
+ try {
715
+ const cfg = require('../config').loadConfig();
716
+ const api = require('../api');
717
+ const r = await api.request(cfg.server, '/api/voz', {
718
+ texto: lista.map((x) => x.texto).join('\n\n'),
719
+ voz: VOZ_GEMINI[String(voz || '').toLowerCase()] || voz || 'Charon',
720
+ }, cfg.token);
721
+ if (!r || !r.ok) return { ok: false, error: (r && r.erro) || 'sem voz no servidor' };
722
+ const pcm = mp3.replace(/\.mp3$/, '.pcm');
723
+ fs.writeFileSync(pcm, Buffer.from(r.audio, 'base64'));
724
+ const taxa = (String(r.formato).match(/rate=(\d+)/) || [])[1] || '24000';
725
+ const c = await video.rodar(video.ffmpeg(), ['-y', '-f', 's16le', '-ar', taxa, '-ac', '1', '-i', pcm,
726
+ '-af', 'loudnorm=I=-14:TP=-1.5:LRA=9', '-c:a', 'libmp3lame', '-q:a', '3', mp3], { timeout: 120000 });
727
+ try { fs.unlinkSync(pcm); } catch { /* fica */ }
728
+ if (!c.ok) return { ok: false, error: 'não consegui converter a voz' };
729
+ const med = await video.medir(mp3);
730
+ const tr = await api.requestTranscricao(cfg.server, { token: cfg.token, audio: fs.readFileSync(mp3).toString('base64'), nome: 'voz.mp3', idioma: 'pt' });
731
+ const palavras = (tr && tr.palavras) || [];
732
+ if (!palavras.length) return { ok: false, error: 'sem a transcrição da voz para achar as partes' };
733
+ return { ok: true, voz: `gemini ${r.voz}`, segundos: med.segundos, partes: alinharPartes(lista, palavras, med.segundos) };
734
+ } catch (e) {
735
+ return { ok: false, error: e.message };
736
+ }
737
+ }
738
+
596
739
  const PROPORCOES = { '16:9': [1920, 1080], '9:16': [1080, 1920], '1:1': [1080, 1080], '4:5': [1080, 1350] };
597
740
 
598
741
  /** O roteiro vira plano: baixa o que falta, narra, e chama o compositor. */
599
742
  async function prepararRoteiro(p, args) {
600
743
  const roteiro = typeof args.roteiro === 'string' ? JSON.parse(args.roteiro) : args.roteiro;
601
744
  if (Array.isArray(roteiro)) return prepararRoteiro(p, { ...args, roteiro: { partes: roteiro } });
745
+ /* O que o modelo manda e o disco não tem vira problema de render: um
746
+ "src" que é endereço da internet vira busca, e um arquivo que não
747
+ existe na pasta sai. A logo da marca vale só se estiver lá. */
748
+ const existe = (rel) => rel && !/^https?:/i.test(rel) && fs.existsSync(path.resolve(p.pasta, rel));
749
+ for (const pt of roteiro.partes || []) {
750
+ if (pt.src && !existe(pt.src)) { if (!pt.busca && /^https?:/i.test(pt.src)) pt.busca = pt.titulo || ''; delete pt.src; }
751
+ }
752
+ if (roteiro.marca) {
753
+ roteiro.marca = { ...(p.marca || {}), ...roteiro.marca };
754
+ if (roteiro.marca.logo && !existe(roteiro.marca.logo)) roteiro.marca.logo = (p.marca && existe(p.marca.logo)) ? p.marca.logo : undefined;
755
+ } else if (p.marca) roteiro.marca = p.marca;
602
756
  const partes = roteiro.partes || [];
603
757
  const modo = (args.modo || roteiro.modo || (p.formato === 'slides' || p.formato === 'arte' ? 'slides' : 'video'));
604
758
  const [largura, altura] = PROPORCOES[roteiro.proporcao || args.proporcao] || (modo === 'slides' ? PROPORCOES['16:9'] : PROPORCOES['16:9']);
@@ -607,12 +761,43 @@ async function prepararRoteiro(p, args) {
607
761
  for (const nome of composicao.iconesDoRoteiro(roteiro)) {
608
762
  try { const r = await assets.icone(nome); if (r && r.ok) iconesOk.add(nome); } catch { /* sem */ }
609
763
  }
610
- const imagens = {};
764
+ /* O ACERVO DA MARCA (guardado pelo estudio_site): as fotos reais entram
765
+ nas partes de imagem e de FUNDO nas cenas de texto; os vídeos, nas
766
+ partes de imagem do vídeo. A foto escolhida é a que mais combina, pela
767
+ descrição, com o que a parte diz. Sem acervo, vale a busca (Pexels). */
768
+ const acervo = (p.acervo && (p.acervo.fotos || []).length) ? p.acervo : { fotos: [], videos: [] };
769
+ const usadas = new Set();
770
+ const palavrasDe = (t) => new Set(String(t || '').normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().split(/[^a-z0-9]+/).filter((w) => w.length >= 4));
771
+ const melhorFoto = (texto) => {
772
+ const quer = palavrasDe(texto);
773
+ let melhor = null, pontos = -1;
774
+ for (const f of acervo.fotos) {
775
+ if (usadas.has(f.src)) continue;
776
+ const n = [...palavrasDe(f.alt)].filter((w) => quer.has(w)).length;
777
+ if (n > pontos) { melhor = f; pontos = n; }
778
+ }
779
+ if (melhor) usadas.add(melhor.src);
780
+ return melhor ? { src: melhor.src, tipo: 'imagem' } : null;
781
+ };
782
+ const videosMarca = [...(acervo.videos || [])];
783
+ const imagens = {}, fundos = {};
611
784
  for (const [i, parte] of partes.entries()) {
612
- if (parte.tipo !== 'imagem' || parte.src || !parte.busca) continue;
613
- const m = await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' });
785
+ if (parte.tipo !== 'imagem' || parte.src) continue;
786
+ const texto = `${parte.titulo || ''} ${parte.sub || ''} ${parte.fala || ''} ${parte.busca || ''}`;
787
+ if (modo === 'video' && videosMarca.length && i % 2 === 1) { imagens[i] = { src: videosMarca.shift().src, tipo: 'video' }; continue; }
788
+ const m = melhorFoto(texto) || (parte.busca ? await baixarApoio(p, parte.busca, { orientacao: largura >= altura ? 'landscape' : 'portrait', video: modo === 'video' }) : null);
614
789
  if (m) imagens[i] = m;
615
790
  }
791
+ if (acervo.fotos.length) {
792
+ const comFundo = ['abertura', 'titulo', 'contraste', 'citacao', 'cta', 'passos'];
793
+ let alterna = 0;
794
+ for (const [i, parte] of partes.entries()) {
795
+ if (!comFundo.includes(parte.tipo)) continue;
796
+ if (i !== 0 && alterna++ % 2 === 1) continue; // uma sim, uma não: o respiro também é ritmo
797
+ const m = melhorFoto(`${parte.titulo || ''} ${parte.sim || ''} ${parte.fala || ''}`);
798
+ if (m) fundos[i] = m;
799
+ }
800
+ }
616
801
 
617
802
  let tempos = [], narracao = null, sons = {}, trilha = null;
618
803
  if (modo === 'video') {
@@ -621,7 +806,9 @@ async function prepararRoteiro(p, args) {
621
806
  fs.mkdirSync(path.dirname(arq), { recursive: true });
622
807
  fs.writeFileSync(arq, JSON.stringify(lista));
623
808
  const mp3 = path.join(p.pasta, 'midia', `narracao-${Date.now().toString(36)}.mp3`);
624
- const v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
809
+ // A voz de gente primeiro (Gemini, pelo servidor); o Edge é a reserva.
810
+ let v = await narrarComGemini(lista, mp3, { voz: args.voz || roteiro.voz });
811
+ if (!v.ok) v = await rodarPython('narrar.py', ['roteiro', '--arquivo', arq, '--saida', mp3, '--voz', String(args.voz || roteiro.voz || 'thalita')], { timeout: 10 * 60000 });
625
812
  if (!v.ok) return v;
626
813
  tempos = v.partes;
627
814
  narracao = path.relative(p.pasta, mp3).replace(/\\/g, '/');
@@ -635,7 +822,7 @@ async function prepararRoteiro(p, args) {
635
822
  }
636
823
  } catch { /* sem música */ }
637
824
  }
638
- return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk });
825
+ return composicao.comporRoteiro({ roteiro, modo, largura, altura, tempos, narracao, sons, trilha, imagens, iconesOk, fundos });
639
826
  }
640
827
 
641
828
  /* Sem o tempo de cada palavra, cada trecho é repartido pelas palavras dele,
@@ -801,6 +988,13 @@ async function ferramentaSite(args) {
801
988
  uma pasta de projeto, é lixo que ninguém recolhe. */
802
989
  const html = r.html;
803
990
  delete r.html; // o HTML cru não vai para o modelo
991
+ if (r.ok) {
992
+ const marcaLida = {
993
+ cores: { destaque: ((r.cores && r.cores.marca) || [])[0] ? r.cores.marca[0].cor : undefined },
994
+ fontes: { titulo: ((r.fontes && r.fontes.declaradas) || [])[0] ? r.fontes.declaradas[0].fonte : undefined },
995
+ };
996
+ ultimoSite = { url: r.url, html, quando: Date.now(), marca: marcaLida };
997
+ }
804
998
  if (r.ok && args.id && args.logo !== false) {
805
999
  const p = projeto.ler(args.id);
806
1000
  if (p) {
@@ -817,11 +1011,44 @@ async function ferramentaSite(args) {
817
1011
  if (r.ok && args.id) {
818
1012
  projeto.anotar(args.id, 'referencia', `leu ${r.url}: ${(r.cores.marca[0] || {}).cor || 'sem cor'} · ${(r.fontes.declaradas[0] || {}).fonte || 'sem fonte'}`);
819
1013
  }
1014
+
1015
+ /* O ACERVO DA MARCA: as fotos e os vídeos reais do site, e o texto das
1016
+ páginas que combinam com o pedido (lib/estudio/acervo-site.js). As
1017
+ fotos ficam guardadas no projeto e o roteiro as usa sozinho; os fatos
1018
+ voltam para o modelo escrever o roteiro com dado de verdade. */
1019
+ if (r.ok && args.id && args.acervo !== false) {
1020
+ const p = projeto.ler(args.id);
1021
+ if (p) {
1022
+ try {
1023
+ const a = await acervoSite.colher({
1024
+ url: r.url, pedido: args.pedido || p.pedido || '', pasta: p.pasta, htmlInicial: html,
1025
+ buscar: identidade.buscar, baixar: assets.baixar,
1026
+ });
1027
+ const marca = { ...ultimoSite.marca, ...(r.logo && r.logo.ok ? { logo: r.logo.src } : {}) };
1028
+ projeto.salvar(args.id, { acervo: { fotos: a.fotos, videos: a.videos, fatos: a.fatos, site: r.url }, marca });
1029
+ projeto.anotar(args.id, 'midia', `acervo do site: ${a.fotos.length} fotos, ${a.videos.length} vídeos, ${a.paginas.length} páginas lidas`);
1030
+ let orcamento = 4200;
1031
+ r.fatos = a.fatos.map((f) => {
1032
+ const texto = f.texto.slice(0, Math.max(0, Math.min(1400, orcamento)));
1033
+ orcamento -= texto.length;
1034
+ return { pagina: f.pagina, texto };
1035
+ }).filter((f) => f.texto);
1036
+ r.acervo = { fotos: a.fotos.length, videos: a.videos.length,
1037
+ exemplos: a.fotos.slice(0, 6).map((f) => f.alt).filter(Boolean) };
1038
+ r.comoUsar = 'Escreva o roteiro com os FATOS acima (datas, local, números, frases da marca) — nada '
1039
+ + 'inventado. As fotos e os vídeos reais da marca entram SOZINHOS no vídeo: não precisa pôr '
1040
+ + '"busca" nas partes de imagem (use tipo "imagem" com só o título). Use as cores, a fonte e '
1041
+ + 'a logo em roteiro.marca.';
1042
+ } catch (e) {
1043
+ r.acervo = { erro: e.message };
1044
+ }
1045
+ }
1046
+ }
820
1047
  return r;
821
1048
  }
822
1049
 
823
1050
  module.exports = {
824
1051
  ferramentaProjeto, ferramentaPlano, ferramentaMidia, ferramentaNarrar,
825
1052
  ferramentaVideo, ferramentaRender, ferramentaSite,
826
- projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython,
1053
+ projeto, plano, remotion, assets, video, identidade, slides, python, rodarPython, alinharPartes,
827
1054
  };
@@ -1,5 +1,5 @@
1
1
  {
2
- "origem": "primocode-server 8.15.0 52f6257",
2
+ "origem": "primocode-server 8.18.0 437cba2",
3
3
  "SYSTEM_NUCLEO": "Você é o PrimoCode, agente de engenharia da ConectaPrimoAI.\n\nCONVERSA OU TRABALHO: cumprimento, agradecimento, pergunta sobre você ou papo\n(\"oi\", \"o que você faz?\") → responda em texto, 1 ou 2 frases, sem tool nenhuma,\ne não invente tarefa que ninguém pediu. Pedido de criar, alterar, ler, rodar ou\nconsertar → EXECUTE, até o fim, sem conversar no meio.\n\nREGRAS ABSOLUTAS (executando):\n1. NUNCA escreva código na resposta. Código vai SEMPRE dentro de uma tool.\n2. NUNCA pergunte ao usuário. Decida e execute.\n3. NUNCA invente ferramenta, serviço, porta ou API: as suas são EXATAMENTE as\n desta lista. E nunca diga que algo \"não está rodando\" para se esquivar — o\n que precisa subir, sobe sozinho quando você chama a tool. Prometer o que\n você não tem, ou culpar um serviço, é o pior erro possível.\n4. Caminhos RELATIVOS (\"index.html\", \"src/game.js\"). Você já está na pasta do\n projeto: nunca caminho absoluto, nunca \"../\".\n5. Termine SEMPRE com finish(\"frase curta do que foi feito\").\n6. Resposta em texto: no máximo 1 frase, sem markdown.\n7. NUNCA repita uma chamada que já fez. Reformular a mesma busca traz as mesmas\n fontes; spec e manual são FIXOS — leu uma vez, está lido. Pesquisou e leu a\n spec? O passo seguinte é CRIAR com o que já tem.\n\nESCOLHENDO A TOOL: arquivo novo → write_file; mudar parte → edit_file (nunca\nreescreva o arquivo inteiro para trocar poucas linhas); várias mudanças no mesmo\narquivo → multi_edit, numa chamada; não sabe onde está → grep (conteúdo) ou glob\n(nome); arquivo grande → read_file com offset/limit; servidor que fica de pé →\nrun_in_new_terminal.\n\nANTES DE EDITAR, LEIA — SEM EXCEÇÃO: edit_file exige o texto LITERAL do arquivo,\ne você não adivinha indentação. read_file (ou grep) PRIMEIRO, edit_file depois.\n\nQUANDO FALHA: leia o erro e conserte. Se edit_file disser \"trecho não\nencontrado\", ele devolve \"trechosParecidos\" com as linhas reais — copie de lá.\nNUNCA chame finish dizendo que fez algo que falhou: \"não consegui alterar X\nporque Y\" é melhor que um \"pronto!\" falso.\n\nPLANO: tarefa de 3+ passos começa com write_todos e vai atualizando o status (só\nUMA \"in_progress\" por vez). Tarefa curta não precisa.\n\nCOMO VOCÊ FALA ENQUANTO TRABALHA: antes de uma tool que muda o rumo (conserto,\nconversão, desvio), UMA frase em primeira pessoa com o quê e o porquê (\"o vídeo\nveio em .avi, vou converter para mp4\"); resolvido, \"feito, continuando\". Passo\nóbvio não precisa. NUNCA cite limite, cota, token, modelo, provedor, tentativa\nou erro interno: quem lê vê o trabalho, não o encanamento. Contornou uma\nfalha? Conte o contorno.\n\nMEMÓRIA: você recebe memory-geral.md (o usuário) e o memory.md do projeto.\nRespeite as preferências e decisões que estão lá, e não recomece do zero um\nprojeto que já tem arquivos — leia antes de escrever. O memory.md do projeto se\ngrava sozinho. Já o memory-geral.md só guarda o que você mandar com\nremember_general: nome, preferência que vale em qualquer projeto, decisão de\nlongo prazo. Sem essa chamada, nada disso sobrevive para a próxima vez.\n\nPROCESSO: ver o que existe (list_dir/glob) → entender (read_file/grep) → fazer\n(write_file/edit_file) → conferir → finish.",
4
4
  "SECOES": {
5
5
  "pesquisa": "PEDIDO QUE DEPENDE DE FATO DO MUNDO — PESQUISE PRIMEIRO:\nSe o que o usuário pediu envolve algo real (uma empresa, um produto, uma\npessoa, um mercado, um número, um acontecimento), chame pesquisar_referencia\nANTES de criar qualquer coisa, em qualquer esforço. \"Pesquise sobre a empresa X\ne faça slides\" é UM pedido só: pesquisa e depois cria, sem parar para perguntar\ne sem entregar o deck com dado inventado.\n\nUm deck bonito cheio de número inventado é pior que nenhum deck — o usuário\nleva aquilo para uma reunião. Se depois da pesquisa faltar um dado, escreva na\npeça o que você sabe e deixe o buraco explícito (\"faturamento: não divulgado\"),\nnunca um número plausível de sua cabeça.",
@@ -743,7 +743,7 @@
743
743
  "type": "function",
744
744
  "function": {
745
745
  "name": "estudio_site",
746
- "description": "Le cores, fontes, logo e discurso de uma marca de verdade. Use SEMPRE que o pedido citar uma empresa. Com id, baixa a logo.",
746
+ "description": "Le cores, fontes, logo e discurso de uma marca de verdade. Use SEMPRE que o pedido citar uma empresa. Com id, baixa a logo e o ACERVO: fotos e videos reais do site (entram sozinhos no video) e os FATOS das paginas (datas, local, numeros) para escrever o roteiro sem inventar.",
747
747
  "parameters": {
748
748
  "type": "object",
749
749
  "properties": {
@@ -755,6 +755,10 @@
755
755
  },
756
756
  "id": {
757
757
  "type": "string"
758
+ },
759
+ "pedido": {
760
+ "type": "string",
761
+ "description": "O pedido do usuario: decide quais paginas ler"
758
762
  }
759
763
  },
760
764
  "required": []
@@ -1927,7 +1931,7 @@
1927
1931
  "type": "function",
1928
1932
  "function": {
1929
1933
  "name": "estudio_site",
1930
- "description": "Le cores, fontes, logo e discurso de uma marca de verdade. Use SEMPRE que o pedido citar uma empresa. Com id, baixa a logo.",
1934
+ "description": "Le cores, fontes, logo e discurso de uma marca de verdade. Use SEMPRE que o pedido citar uma empresa. Com id, baixa a logo e o ACERVO: fotos e videos reais do site (entram sozinhos no video) e os FATOS das paginas (datas, local, numeros) para escrever o roteiro sem inventar.",
1931
1935
  "parameters": {
1932
1936
  "type": "object",
1933
1937
  "properties": {
@@ -1939,6 +1943,10 @@
1939
1943
  },
1940
1944
  "id": {
1941
1945
  "type": "string"
1946
+ },
1947
+ "pedido": {
1948
+ "type": "string",
1949
+ "description": "O pedido do usuario: decide quais paginas ler"
1942
1950
  }
1943
1951
  },
1944
1952
  "required": []
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.8.0-beta.15",
3
+ "version": "9.8.0-beta.16",
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": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/app-janela.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"