primocode 10.0.0 → 10.0.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +2 -2
  2. package/bin/primocode.js +69 -1
  3. package/lib/act.js +118 -19
  4. package/lib/browser.js +89 -5
  5. package/lib/catalogo.js +1 -1
  6. package/lib/claude-engine.js +38 -17
  7. package/lib/codex-engine.js +27 -9
  8. package/lib/entrada.js +73 -9
  9. package/lib/estudio/cinema.js +27 -9
  10. package/lib/estudio/diretor.js +13 -1
  11. package/lib/estudio/ende.js +16 -5
  12. package/lib/estudio/estilo.js +23 -8
  13. package/lib/estudio/gerativo.js +1453 -0
  14. package/lib/estudio/hibrido.js +165 -0
  15. package/lib/estudio/index.js +244 -36
  16. package/lib/estudio/kit/gerativo.jsx +980 -0
  17. package/lib/estudio/kit/index.jsx +3 -1
  18. package/lib/estudio/kit/tres.jsx +308 -0
  19. package/lib/estudio/kit.js +7 -1
  20. package/lib/estudio/lancamento.js +28 -0
  21. package/lib/estudio/referencias.js +105 -0
  22. package/lib/estudio/remotion.js +53 -9
  23. package/lib/estudio/ritmo.js +2 -2
  24. package/lib/estudio/sons-leves.js +38 -1
  25. package/lib/estudio/variedade.js +105 -0
  26. package/lib/estudio/vox.js +37 -8
  27. package/lib/mcp.js +156 -0
  28. package/lib/personas.js +67 -0
  29. package/lib/skills.js +36 -0
  30. package/lib/tools.js +22 -3
  31. package/lib/ui.js +41 -6
  32. package/lib/video/auto.js +329 -0
  33. package/lib/video/camadas.js +207 -0
  34. package/lib/video/cli.js +1 -1
  35. package/lib/video/filters.js +39 -6
  36. package/lib/video/fx3d/Camadas.jsx +64 -0
  37. package/lib/video/fx3d/Objetos.jsx +128 -0
  38. package/lib/video/fx3d/Root.jsx +18 -0
  39. package/lib/video/fx3d/modelos.jsx +304 -0
  40. package/lib/video/fx3d.js +108 -0
  41. package/lib/video/gerados.js +119 -0
  42. package/lib/video/objetos.js +111 -0
  43. package/lib/video/ops-fx.js +453 -0
  44. package/lib/video/ops.js +30 -5
  45. package/lib/video/paralelo.js +50 -0
  46. package/lib/video/pedido.js +191 -0
  47. package/lib/video/rastrear.js +95 -0
  48. package/lib/video/rastro.js +276 -0
  49. package/lib/video/render.js +67 -14
  50. package/lib/video/texto.js +6 -4
  51. package/lib/video/timeline.js +9 -1
  52. package/package.json +2 -2
  53. package/skills/motion-de-primeira/SKILL.md +5 -4
  54. package/studio/narrar.py +10 -7
  55. package/studio/rastrear.py +399 -0
@@ -32,6 +32,14 @@ const EFEITOS = {
32
32
  subida: ['-f', 'lavfi', '-i', "aevalsrc='0.28*sin(2*PI*(180+260*t*t)*t)*(t/1.4)':s=44100:d=1.4", '-af', 'afade=t=out:st=1.2:d=0.2'],
33
33
  // Toque grave e macio: o que chega e assenta.
34
34
  toque: ['-f', 'lavfi', '-i', "aevalsrc='0.7*sin(2*PI*(110-50*t)*t)*exp(-7*t)':s=44100:d=0.6", '-af', 'lowpass=f=900'],
35
+ /* Os CHEIOS do motion gerativo ("motion é vida: efeito sonoro NA ação",
36
+ 30/09): o whoosh da passagem, o impacto da tese, o riser antes da
37
+ revelação e o pop da letra que nasce. Ainda sintetizados, ainda limpos. */
38
+ whoosh: ['-f', 'lavfi', '-i', 'anoisesrc=d=0.75:c=pink:a=0.9', '-af',
39
+ 'bandpass=f=1100:width_type=o:w=2.2,afade=t=in:st=0:d=0.42:curve=exp,afade=t=out:st=0.42:d=0.33:curve=qsin,volume=2.2'],
40
+ impacto: ['-f', 'lavfi', '-i', "aevalsrc='0.95*sin(2*PI*(62-26*t)*t)*exp(-4.5*t)+0.45*(random(0)*2-1)*exp(-38*t)':s=44100:d=1.1", '-af', 'lowpass=f=2600,volume=1.4'],
41
+ riser: ['-f', 'lavfi', '-i', "aevalsrc='(0.32*sin(2*PI*(170+620*t*t)*t)+0.22*(random(0)*2-1))*pow(t/1.1,2)':s=44100:d=1.1", '-af', 'highpass=f=260,afade=t=out:st=1.04:d=0.06,volume=1.3'],
42
+ pop: ['-f', 'lavfi', '-i', "aevalsrc='0.8*sin(2*PI*(980-640*t)*t)*exp(-28*t)*(1-exp(-700*t))':s=44100:d=0.18"],
35
43
  };
36
44
 
37
45
  /**
@@ -59,4 +67,33 @@ function preparar(pasta, { rodar = null } = {}) {
59
67
  return sons;
60
68
  }
61
69
 
62
- module.exports = { EFEITOS, preparar };
70
+ /**
71
+ * A família de sons de UM vídeo (gerativo.js sonsDoVideo): cada receita vira
72
+ * midia/sfx-g<semente>-<nome>.mp3. O que já existe não é refeito.
73
+ */
74
+ function prepararReceitas(pasta, { receitas = {}, semente = 0 } = {}, { rodar = null } = {}) {
75
+ const exec = rodar || ((args) => {
76
+ try { require('child_process').execFileSync('ffmpeg', args, { stdio: 'ignore', timeout: 30000 }); return true; }
77
+ catch { return false; }
78
+ });
79
+ const sons = {};
80
+ const sufixo = (semente >>> 0).toString(36);
81
+ for (const [nome, args] of Object.entries(receitas)) {
82
+ const rel = `midia/sfx-g${sufixo}-${nome}.mp3`;
83
+ const cheio = path.join(pasta, rel);
84
+ if (!fs.existsSync(cheio)) {
85
+ fs.mkdirSync(path.dirname(cheio), { recursive: true });
86
+ // O limitador no fim: o golpe sintetizado passava de 0 dB e estalava.
87
+ // "{pasta}" é a pasta do projeto: o som feito da própria trilha lê o arquivo dela.
88
+ const a = args.map((x) => String(x).split('{pasta}').join(pasta));
89
+ const fc = a.indexOf('-filter_complex'), i = a.indexOf('-af');
90
+ if (fc >= 0) a[fc + 1] += ',alimiter=limit=0.8:level=false';
91
+ else if (i >= 0) a[i + 1] += ',alimiter=limit=0.8:level=false'; else a.push('-af', 'alimiter=limit=0.8:level=false');
92
+ if (!exec(['-y', '-loglevel', 'error', ...a, '-q:a', '3', cheio])) continue;
93
+ }
94
+ sons[nome] = rel;
95
+ }
96
+ return sons;
97
+ }
98
+
99
+ module.exports = { EFEITOS, preparar, prepararReceitas };
@@ -0,0 +1,105 @@
1
+ /**
2
+ * variedade.js — a trilha e os efeitos dos vídeos de corte (híbrido, cinema,
3
+ * impacto) sem repetir de um vídeo para o outro.
4
+ *
5
+ * "todos os vídeos é sempre as mesmas músicas, os mesmos efeitos sonoros,
6
+ * sempre o mesmo padrão de vídeo" (Joel, 02/10)
7
+ *
8
+ * A causa era a mesma nos dois: um termo fixo na busca ("inspiring
9
+ * corporate", "cinematic whoosh") e o PRIMEIRO resultado baixado. A busca
10
+ * devolve a mesma lista na mesma ordem, então todo vídeo saía com a mesma
11
+ * faixa e o mesmo whoosh. Aqui:
12
+ * 1. o termo sai de um cardápio pelo clima do roteiro, em ordem sorteada;
13
+ * 2. dos resultados, sorteia — e pula o que os últimos vídeos já usaram;
14
+ * 3. o que foi usado entra na memória (~/.primocode/studio/<tipo>-usadas.json).
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ const fs = require('fs');
20
+ const os = require('os');
21
+ const path = require('path');
22
+
23
+ const PASTA = path.join(os.homedir(), '.primocode', 'studio');
24
+ const MEMORIA = { trilha: 16, sfx: 40 };
25
+
26
+ function arquivo(tipo) { return path.join(PASTA, `${tipo}-usadas.json`); }
27
+
28
+ function usadas(tipo) {
29
+ try { const l = JSON.parse(fs.readFileSync(arquivo(tipo), 'utf8')); return Array.isArray(l) ? l : []; } catch { return []; }
30
+ }
31
+
32
+ function marcar(tipo, id) {
33
+ if (!id) return;
34
+ try {
35
+ fs.mkdirSync(PASTA, { recursive: true });
36
+ const l = [id, ...usadas(tipo).filter((x) => x !== id)].slice(0, MEMORIA[tipo] || 20);
37
+ fs.writeFileSync(arquivo(tipo), JSON.stringify(l));
38
+ } catch { /* sem memória: só perde a garantia de não repetir */ }
39
+ }
40
+
41
+ function embaralhar(lista, aleatorio = Math.random) {
42
+ const a = [...lista];
43
+ for (let i = a.length - 1; i > 0; i--) {
44
+ const j = Math.floor(aleatorio() * (i + 1));
45
+ [a[i], a[j]] = [a[j], a[i]];
46
+ }
47
+ return a;
48
+ }
49
+
50
+ /** Os itens da busca na ordem de tentar: os nunca usados primeiro (sorteados), os usados no fim. */
51
+ function ordenar(itens, tipo, aleatorio = Math.random) {
52
+ const ja = new Set(usadas(tipo));
53
+ const validos = (itens || []).filter((x) => x && x.url);
54
+ return [...embaralhar(validos.filter((x) => !ja.has(x.url)), aleatorio), ...validos.filter((x) => ja.has(x.url))];
55
+ }
56
+
57
+ // ── a trilha: termos por clima ──────────────────────────────────────────
58
+ const CLIMAS = {
59
+ tecnologia: ['electronic instrumental', 'synthwave instrumental', 'future bass instrumental', 'downtempo electronic', 'minimal techno instrumental', 'chillwave instrumental'],
60
+ energetico: ['funk instrumental', 'rock instrumental', 'drum and bass instrumental', 'breakbeat instrumental', 'upbeat indie instrumental', 'house instrumental'],
61
+ epico: ['epic orchestral', 'trailer instrumental', 'cinematic drums', 'epic hybrid instrumental', 'dramatic strings'],
62
+ calmo: ['lofi hip hop instrumental', 'ambient instrumental', 'acoustic guitar instrumental', 'piano instrumental', 'jazz instrumental'],
63
+ alegre: ['ukulele instrumental', 'indie pop instrumental', 'happy acoustic instrumental', 'latin instrumental', 'disco instrumental'],
64
+ emocional: ['piano emotional instrumental', 'post rock instrumental', 'cinematic strings', 'neo classical instrumental'],
65
+ misterio: ['dark ambient instrumental', 'trip hop instrumental', 'suspense instrumental', 'noir jazz instrumental'],
66
+ neutro: ['indie instrumental', 'soul instrumental', 'jazz hop instrumental', 'world instrumental', 'electronic instrumental', 'acoustic instrumental'],
67
+ };
68
+ const SINONIMOS = {
69
+ tecnologia: /tec|tech|ia\b|ai\b|software|app|digital|futur/, energetico: /energ|agit|rapid|forte|esport|academ/,
70
+ epico: /epic|epico|grandi|trailer|impact/, calmo: /calm|tranq|suave|relax|chill/, alegre: /aleg|feliz|divert|leve/,
71
+ emocional: /emoc|emoti|inspir|sentim|historia/, misterio: /mist|sombr|dark|suspen|tens/,
72
+ };
73
+
74
+ function climaDe(roteiro = {}, estilo = '') {
75
+ const t = String([roteiro.clima, roteiro.genero, roteiro.tom].filter(Boolean).join(' ')).toLowerCase()
76
+ .normalize('NFD').replace(/[̀-ͯ]/g, '');
77
+ for (const [clima, re] of Object.entries(SINONIMOS)) if (re.test(t)) return clima;
78
+ return estilo === 'impacto' ? 'energetico' : 'neutro';
79
+ }
80
+
81
+ /**
82
+ * Os termos para buscar a trilha, em ordem de tentar. O pedido explícito
83
+ * (roteiro.musica) vem primeiro; depois o clima, sorteado; e por último um
84
+ * clima vizinho, para nunca sair sem música.
85
+ */
86
+ function termosTrilha(roteiro = {}, estilo = '', aleatorio = Math.random) {
87
+ const clima = climaDe(roteiro, estilo);
88
+ const reserva = clima === 'neutro' ? 'calmo' : 'neutro';
89
+ return [...new Set([roteiro.musica, ...embaralhar(CLIMAS[clima], aleatorio).slice(0, 3),
90
+ ...embaralhar(CLIMAS[reserva], aleatorio).slice(0, 2)].filter(Boolean))];
91
+ }
92
+
93
+ // ── os efeitos: várias formas de pedir o mesmo som ───────────────────────
94
+ const SFX = {
95
+ teclado: ['keyboard typing', 'typing keyboard', 'mechanical keyboard', 'laptop typing', 'typewriter keys'],
96
+ whoosh: ['cinematic whoosh', 'swoosh transition', 'air whoosh', 'fast swipe', 'woosh pass by', 'riser whoosh'],
97
+ impacto: ['cinematic boom impact', 'deep cinematic impact', 'trailer hit', 'bass drop hit', 'punch hit', 'sub boom'],
98
+ sala: ['crowd murmur indoor', 'office ambience', 'people talking background', 'cafe ambience', 'room tone people'],
99
+ };
100
+
101
+ function termosSfx(nome, aleatorio = Math.random) {
102
+ return embaralhar(SFX[nome] || [nome], aleatorio).slice(0, 3);
103
+ }
104
+
105
+ module.exports = { usadas, marcar, embaralhar, ordenar, climaDe, termosTrilha, termosSfx, CLIMAS, SFX };
@@ -91,7 +91,11 @@ function montarVox({ roteiro, largura = 1920, altura = 1080, tempos = [], narrac
91
91
  const partes = (roteiro && roteiro.partes) || [];
92
92
  if (!partes.length) return { ok: false, error: 'o roteiro veio sem partes.' };
93
93
  const W = largura, H = altura, deitado = W >= H;
94
- const b = Math.min(W, H) / 1080;
94
+ /* Deitado, o quadro tem 1920 de largura e o mesmo 1080 de altura do em
95
+ pé: com a escala do lado menor, tudo saía do tamanho do vertical,
96
+ miúdo e solto num canvas vazio ("deixar os elementos mais organizados",
97
+ 29/09). No 16:9 a tipografia cresce 30%. */
98
+ const b = (Math.min(W, H) / 1080) * (deitado ? 1.3 : 1);
95
99
  const M = Math.round(Math.min(W, H) * 0.075);
96
100
  const TOPO = Math.round(H * (deitado ? 0.17 : 0.2)); // a banda intocada
97
101
 
@@ -115,7 +119,8 @@ function montarVox({ roteiro, largura = 1920, altura = 1080, tempos = [], narrac
115
119
  const leitura = (p) => 1.6 + 0.3 * String([p.titulo, p.sub, p.valor, p.rotulo, p.nao, p.sim, ...itensDe(p)].filter(Boolean).join(' ')).split(/\s+/).length;
116
120
  const { ini, dur, total, temVoz } = tempoDasPartes(partes, tempos, { semVoz: leitura });
117
121
  const efeitos = [];
118
- const som = (t, nome, volume) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, t)), src: sons[nome], volume }); };
122
+ // Efeito que não se ouve é efeito que não existe: 1,6× o volume antigo.
123
+ const som = (t, nome, volume) => { if (sons[nome]) efeitos.push({ t: r2(Math.max(0, t)), src: sons[nome], volume: Math.min(0.65, volume * 1.6) }); };
119
124
 
120
125
  // ── as quatro profundidades, e o que mora em cada uma ──────────────
121
126
  const CAMADAS = [{ p: 0.12, desfoque: 2.2 }, { p: 0.4, desfoque: 0.9 }, { p: 0.55, desfoque: 0, sujeito: true }, { p: 1.25, desfoque: 3.4 }];
@@ -133,10 +138,16 @@ function montarVox({ roteiro, largura = 1920, altura = 1080, tempos = [], narrac
133
138
  return { zoom: [k(0, 3.4), k(Math.min(1.8, d * 0.55), 1.0, 'camera'), k(fimLento, 1.03, 'camera'), k(d, 1.06, 'acelera')],
134
139
  x: [k(0, 0), k(d, lado * 10 * b, 'camera')] };
135
140
  }
136
- const z1 = modo === 'climax' ? 1.14 : 1.045;
137
- return { zoom: [k(0, 1.0), k(fimLento, z1, 'camera'), k(d, z1 + 0.035, 'acelera')],
138
- x: [k(0, -lado * 14 * b), k(d, lado * 14 * b, 'camera')],
139
- rotacao: [k(0, -lado * 0.3), k(d, lado * 0.3, 'camera')] };
141
+ /* "Mais movimento" (29/09): a câmera contida do guia VOX (4,5% de
142
+ zoom, 14 px) lia como tela parada. Deitado ela empurra 12% (22% no
143
+ clímax) e desliza 34 px; em pé, menos, porque a âncora ocupa a
144
+ largura toda. */
145
+ const z1 = modo === 'climax' ? (deitado ? 1.22 : 1.16) : (deitado ? 1.12 : 1.08);
146
+ const desliza = (deitado ? 34 : 22) * b; // mais que isso leva a âncora até a borda
147
+ return { zoom: [k(0, 1.0), k(fimLento, z1, 'camera'), k(d, z1 + 0.05, 'acelera')],
148
+ x: [k(0, -lado * desliza), k(d, lado * desliza, 'camera')],
149
+ y: [k(0, lado * desliza * 0.35), k(d, -lado * desliza * 0.35, 'camera')],
150
+ rotacao: [k(0, -lado * 0.7), k(d, lado * 0.7, 'camera')] };
140
151
  };
141
152
 
142
153
  /* O CORTE NO BEAT dentro da parte longa: passou de 5 s, a câmera pula de
@@ -221,8 +232,10 @@ function montarVox({ roteiro, largura = 1920, altura = 1080, tempos = [], narrac
221
232
  const maior = itens.reduce((a, x) => ((x.termo + (x.def ? ' — ' + x.def : '')).length > a.length ? x.termo + (x.def ? ' — ' + x.def : '') : a), '');
222
233
  // Uma linha por item quando cabe grande; senão, duas.
223
234
  const umaLinha = caber(maior, { largura: larguraAncora * (deitado ? 1.05 : 1), linhas: 1, max: (tipo === 'contraste' ? 170 : itens.length > 3 ? 80 : 96) * b, min: 30 * b, fonte: F.titulo });
224
- const tam = umaLinha.tamanho >= 58 * b || tipo === 'contraste' ? umaLinha.tamanho
225
- : caber(maior, { largura: larguraAncora * (deitado ? 1.05 : 1), linhas: 2, max: 80 * b, min: 30 * b, fonte: F.titulo }).tamanho;
235
+ // O contraste com frase longa saía numa linha só, miúdo no meio do
236
+ // quadro (29/09). Não coube grande em uma? Vai em duas, maior.
237
+ const tam = umaLinha.tamanho >= (tipo === 'contraste' ? 72 : 58) * b ? umaLinha.tamanho
238
+ : caber(maior, { largura: larguraAncora * (deitado ? 1.05 : 1), linhas: 2, max: (tipo === 'contraste' ? 120 : 80) * b, min: 30 * b, fonte: F.titulo }).tamanho;
226
239
  const ts = g(itens.map((x) => (tipo === 'passos' ? x.def : x.termo)), { t0: titulo && tipo !== 'contraste' ? 0.6 : 0.3 });
227
240
  itens.forEach((x, j) => { x.t = ts[j]; });
228
241
  const linhasEst = itens.reduce((n, x) => n + Math.ceil(((x.termo + (x.def ? ' — ' + x.def : '')).length * tam * 0.56) / larguraAncora), 0);
@@ -354,6 +367,22 @@ function montarVox({ roteiro, largura = 1920, altura = 1080, tempos = [], narrac
354
367
  }
355
368
 
356
369
  const cam = corteInterno(camera(d, modo, i), d, tp, t0);
370
+ /* CADA ELEMENTO RESPIRA: além da câmera, cada peça flutua um pouco na
371
+ direção dela (alternando) e cresce 3% — em profundidades diferentes
372
+ isso vira parallax. O que já tem animação própria de posição,
373
+ escala ou rotação fica como está. */
374
+ els.forEach((el, j) => {
375
+ if (!el || el.peca === 'papel') return;
376
+ const a = el.anima || {};
377
+ const sinal = (i + j) % 2 ? 1 : -1;
378
+ const amp = (deitado ? 14 : 10) * b * (1 + (j % 3) * 0.4);
379
+ el.anima = {
380
+ ...a,
381
+ ...(a.y ? {} : { y: [k(0, sinal * amp), k(d, -sinal * amp, 'camera')] }),
382
+ ...(a.escala ? {} : { escala: [k(0, 1), k(d, 1.03, 'camera')] }),
383
+ ...(a.rotacao ? {} : { rotacao: [k(0, -sinal * 0.4), k(d, sinal * 0.4, 'camera')] }),
384
+ };
385
+ });
357
386
  return {
358
387
  duracao: d,
359
388
  fundo: escuro ? ESCURO : PAPEL,
package/lib/mcp.js ADDED
@@ -0,0 +1,156 @@
1
+ /**
2
+ * mcp.js — os servidores MCP ao alcance do agente, sem mudar o servidor.
3
+ *
4
+ * "Coloca MCP, coloca coisas pra otimizar ele." (30/09)
5
+ *
6
+ * Quem declara as ferramentas ao modelo é o servidor do PrimoCode, que este
7
+ * cliente não muda. Então o MCP entra como o editor de vídeo entrou: um
8
+ * subcomando que se descreve (`primocode mcp list`, `primocode mcp call`), e
9
+ * o agente chama pelo run_shell. A lista curta dos servidores e das tools vai
10
+ * no contexto — guardada em cache, para não subir servidor a cada pedido.
11
+ *
12
+ * A configuração é a mesma do Claude Code, para quem já tem uma:
13
+ * ~/.primocode/mcp.json e <pasta do projeto>/.mcp.json
14
+ * { "mcpServers": { "nome": { "command": "npx", "args": [...], "env": {...} } } }
15
+ *
16
+ * O cliente é o mínimo do protocolo por stdio: JSON-RPC 2.0, uma mensagem por
17
+ * linha — initialize, notifications/initialized, tools/list, tools/call.
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+ const { spawn } = require('child_process');
26
+
27
+ const CASA = path.join(os.homedir(), '.primocode');
28
+ const CONFIG = path.join(CASA, 'mcp.json');
29
+ const CACHE = path.join(CASA, 'mcp-cache.json');
30
+
31
+ function lerJson(arq) { try { return JSON.parse(fs.readFileSync(arq, 'utf8')); } catch { return null; } }
32
+
33
+ /* O PADRÃO: o Context7, a documentação ATUAL de qualquer biblioteca. É o
34
+ que mais melhora a programação de um modelo grátis — ele para de chutar a
35
+ API de memória. Só sobe quando o agente chama; PRIMOCODE_SEM_MCP=1 tira. */
36
+ const PADRAO = { context7: { command: 'npx', args: ['-y', '@upstash/context7-mcp@latest'] } };
37
+
38
+ /** Os servidores configurados: o padrão, o do usuário e o do projeto (o do projeto vence). */
39
+ function servidores(pasta = process.cwd()) {
40
+ const out = process.env.PRIMOCODE_SEM_MCP === '1' ? {} : { ...PADRAO };
41
+ for (const arq of [CONFIG, path.join(pasta, '.mcp.json')]) {
42
+ const j = lerJson(arq);
43
+ for (const [nome, s] of Object.entries((j && j.mcpServers) || {})) if (s && s.command) out[nome] = s;
44
+ }
45
+ return out;
46
+ }
47
+
48
+ /** Abre um servidor, faz o aperto de mão e devolve {pedir, fechar}. */
49
+ function conectar(def, { tetoMs = 60000 } = {}) {
50
+ return new Promise((resolve, reject) => {
51
+ const p = spawn(def.command, def.args || [], { env: { ...process.env, ...(def.env || {}) }, stdio: ['pipe', 'pipe', 'pipe'] });
52
+ let buf = '', id = 0;
53
+ const espera = new Map();
54
+ const relogio = setTimeout(() => { try { p.kill(); } catch {} reject(new Error('o servidor MCP não respondeu')); }, tetoMs);
55
+ p.on('error', (e) => { clearTimeout(relogio); reject(e); });
56
+ p.stdout.on('data', (d) => {
57
+ buf += d.toString();
58
+ let i;
59
+ while ((i = buf.indexOf('\n')) >= 0) {
60
+ const linha = buf.slice(0, i).trim(); buf = buf.slice(i + 1);
61
+ if (!linha) continue;
62
+ let m; try { m = JSON.parse(linha); } catch { continue; }
63
+ if (m.id != null && espera.has(m.id)) { const w = espera.get(m.id); espera.delete(m.id); m.error ? w.rej(new Error(m.error.message || 'erro do MCP')) : w.res(m.result); }
64
+ }
65
+ });
66
+ const pedir = (method, params = {}) => new Promise((res, rej) => {
67
+ const meu = ++id;
68
+ espera.set(meu, { res, rej });
69
+ p.stdin.write(JSON.stringify({ jsonrpc: '2.0', id: meu, method, params }) + '\n');
70
+ });
71
+ const avisar = (method, params = {}) => p.stdin.write(JSON.stringify({ jsonrpc: '2.0', method, params }) + '\n');
72
+ pedir('initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'primocode', version: '10' } })
73
+ .then(() => { avisar('notifications/initialized'); clearTimeout(relogio); resolve({ pedir, fechar: () => { try { p.kill(); } catch {} } }); })
74
+ .catch((e) => { clearTimeout(relogio); try { p.kill(); } catch {} reject(e); });
75
+ });
76
+ }
77
+
78
+ async function listar(pasta) {
79
+ const lista = servidores(pasta);
80
+ const out = {};
81
+ await Promise.all(Object.entries(lista).map(async ([nome, def]) => {
82
+ try {
83
+ const c = await conectar(def);
84
+ const r = await c.pedir('tools/list');
85
+ c.fechar();
86
+ out[nome] = (r.tools || []).map((t) => ({ nome: t.name, descricao: String(t.description || '').split(/\n|\. /)[0].slice(0, 120), args: Object.keys((t.inputSchema && t.inputSchema.properties) || {}) }));
87
+ } catch (e) { out[nome] = { erro: e.message }; }
88
+ }));
89
+ try { fs.mkdirSync(CASA, { recursive: true }); fs.writeFileSync(CACHE, JSON.stringify({ quando: Date.now(), servidores: out }, null, 2)); } catch { /* sem cache */ }
90
+ return out;
91
+ }
92
+
93
+ async function chamar(servidor, tool, args = {}, pasta) {
94
+ const def = servidores(pasta)[servidor];
95
+ if (!def) throw new Error(`não há servidor MCP "${servidor}" — veja primocode mcp list`);
96
+ const c = await conectar(def);
97
+ try {
98
+ const r = await c.pedir('tools/call', { name: tool, arguments: args });
99
+ return r;
100
+ } finally { c.fechar(); }
101
+ }
102
+
103
+ /** A linha do contexto: só se houver servidor configurado, a partir do cache. */
104
+ function dica(pasta) {
105
+ const lista = Object.keys(servidores(pasta));
106
+ if (!lista.length) return '';
107
+ const c = lerJson(CACHE);
108
+ const det = lista.map((n) => {
109
+ const tools = c && c.servidores && Array.isArray(c.servidores[n]) ? c.servidores[n].map((t) => t.nome).slice(0, 8).join(', ') : '';
110
+ return tools ? `${n} (${tools})` : n;
111
+ }).join(' · ');
112
+ return `[mcp] Servidores MCP disponíveis: ${det}. Use pelo run_shell: primocode mcp call <servidor> <tool> --json '{...}'`
113
+ + (c ? '' : ' (rode primocode mcp list para ver as tools).');
114
+ }
115
+
116
+ /** `primocode mcp …` */
117
+ async function main(argv) {
118
+ const [op, ...resto] = argv;
119
+ const json = (() => { const i = resto.indexOf('--json'); try { return i >= 0 ? JSON.parse(resto[i + 1] || '{}') : {}; } catch { return null; } })();
120
+ try {
121
+ if (!op || op === 'help') {
122
+ console.log('primocode mcp list — servidores e tools (de ~/.primocode/mcp.json e .mcp.json)');
123
+ console.log("primocode mcp call <servidor> <tool> --json '{...}' — chama uma tool; sai o resultado em JSON");
124
+ console.log('primocode mcp add <nome> -- <comando> [args…] — guarda um servidor em ~/.primocode/mcp.json');
125
+ return 0;
126
+ }
127
+ if (op === 'list') { console.log(JSON.stringify(await listar(process.cwd()))); return 0; }
128
+ if (op === 'call') {
129
+ if (json === null) { console.log(JSON.stringify({ ok: false, error: '--json inválido' })); return 1; }
130
+ const r = await chamar(resto[0], resto[1], json, process.cwd());
131
+ const texto = (r.content || []).map((x) => (x.type === 'text' ? x.text : `[${x.type}]`)).join('\n');
132
+ console.log(JSON.stringify({ ok: !r.isError, resultado: texto.slice(0, 20000) }));
133
+ return r.isError ? 1 : 0;
134
+ }
135
+ if (op === 'add') {
136
+ const i = resto.indexOf('--');
137
+ const nome = resto[0];
138
+ const cmd = i >= 0 ? resto.slice(i + 1) : resto.slice(1);
139
+ if (!nome || !cmd.length) { console.log('uso: primocode mcp add <nome> -- <comando> [args…]'); return 1; }
140
+ const j = lerJson(CONFIG) || { mcpServers: {} };
141
+ j.mcpServers = j.mcpServers || {};
142
+ j.mcpServers[nome] = { command: cmd[0], args: cmd.slice(1) };
143
+ fs.mkdirSync(CASA, { recursive: true });
144
+ fs.writeFileSync(CONFIG, JSON.stringify(j, null, 2));
145
+ console.log(JSON.stringify({ ok: true, servidor: nome }));
146
+ return 0;
147
+ }
148
+ console.log(JSON.stringify({ ok: false, error: `operação desconhecida: ${op}` }));
149
+ return 1;
150
+ } catch (e) {
151
+ console.log(JSON.stringify({ ok: false, error: e.message }));
152
+ return 1;
153
+ }
154
+ }
155
+
156
+ module.exports = { servidores, conectar, listar, chamar, dica, main };
@@ -0,0 +1,67 @@
1
+ /**
2
+ * personas.js — a equipe que trabalha calada por trás de cada pedido.
3
+ *
4
+ * "Coloca personas que fiquem trabalhando ocultamente, e que seja surreal."
5
+ * (30/09)
6
+ *
7
+ * Não são subagentes: cada subagente é mais uma ida ao modelo, e o plano
8
+ * grátis conta idas. A persona é o OLHAR de um especialista entrando no
9
+ * contexto do mesmo pedido — curta, e só a que o pedido pede. O agente faz o
10
+ * trabalho com o padrão de quem faria aquilo de profissão.
11
+ *
12
+ * escolher(pedido) → [{ nome, olhar }] no máximo duas
13
+ * bloco(pedido) → o texto que entra no contexto, ou ''
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const EQUIPE = [
19
+ {
20
+ nome: 'engenheira sênior', curto: 'código pequeno e certo, siga o padrão da casa, rode o que prova', quando: /\b(c[oó]digo|api|bug|erro|teste|fun[cç][aã]o|refator|backend|servidor|banco|typescript|javascript|python|node|react|next|deploy|build)\b/i,
21
+ olhar: 'Antes de escrever, olhe o que já existe e siga o padrão da casa. Mudança pequena e certa vence reescrita. '
22
+ + 'Trate o erro de verdade (entrada vazia, rede caída), sem try/catch que engole tudo. Rode o teste ou o comando que prova. '
23
+ + 'Biblioteca que você não domina: consulte a doc atual com primocode mcp call context7 query-docs antes de chutar a API.',
24
+ },
25
+ {
26
+ nome: 'designer de produto', curto: 'identidade própria, um acento, hierarquia, funciona em 390px', quando: /\b(site|p[aá]gina|landing|app|tela|interface|layout|dashboard|formul[aá]rio|loja)\b/i,
27
+ olhar: 'Nada de cara de template: uma tipografia com caráter (não Inter/Roboto por padrão), uma paleta tirada do assunto com UM acento, '
28
+ + 'hierarquia clara, espaço para respirar, estados de foco e de vazio, e funciona em 390px. Texto real do negócio, nunca lorem ipsum.',
29
+ },
30
+ {
31
+ nome: 'diretor de motion', curto: 'o áudio comanda o quadro, uma ação por vez, respiro', quando: /\b(v[ií]deo|motion|anima[cç][aã]o|reels?|an[uú]ncio|vinheta|trailer)\b/i,
32
+ olhar: 'O áudio comanda o quadro: cada elemento entra na palavra que o pede. Uma ação dominante por vez, um acento só, '
33
+ + 'respiro de 30–40% do quadro. Roteiro curto e falado, com uma palavra-tese por parte.',
34
+ },
35
+ {
36
+ nome: 'redatora', curto: 'frase curta, verbo forte, uma ideia por frase', quando: /\b(texto|copy|legenda|post|e-?mail|roteiro|t[ií]tulo|descri[cç][aã]o|artigo)\b/i,
37
+ olhar: 'Frase curta, verbo forte, uma ideia por frase. Fale com quem vai ler, no português de gente. Corte adjetivo que não carrega fato.',
38
+ },
39
+ {
40
+ /* Só quando a pessoa pede para MEXER no computador ("abre o YouTube",
41
+ "clica em…"). "Pesquisa sobre X" não é isso: era o que abria o Chrome
42
+ do nada, mostrava o mouse e parava (01/10). */
43
+ nome: 'operador', curto: 'só quando pedirem para mexer no computador; termine o que pediram', quando: /\b(abr[ae]|abrir|clica|clique|digita|navega)\b[\s\S]{0,40}\b(chrome|youtube|safari|firefox|spotify|whatsapp|navegador|site|aba|app|aplicativo|janela)\b|\b(mouse|teclado)\b/i,
44
+ olhar: 'A pessoa pediu para mexer no computador: faça à vista (browser_open, browser_read, clique pelas refs @e3) e TERMINE o que ela pediu — '
45
+ + 'abrir a janela não é a tarefa. Diga em uma linha o que fez.',
46
+ },
47
+ ];
48
+
49
+ function escolher(pedido) {
50
+ const t = String(pedido || '');
51
+ return EQUIPE.filter((p) => p.quando.test(t)).slice(0, 2);
52
+ }
53
+
54
+ /* A IA ESCOLHE (01/10). Antes entravam só as que a regex casava — e a regex
55
+ errava para os dois lados (a "pesquisa" que chamava o operador). Agora a
56
+ equipe inteira vai, curta, e o modelo adota quem a tarefa pede; as que a
57
+ regex casou vêm primeiro, com o olhar inteiro, como sugestão — não ordem. */
58
+ function bloco(pedido) {
59
+ const t = String(pedido || '').trim();
60
+ if (!t || /^(oi|ol[aá]|e a[ií]|bom dia|boa tarde|boa noite|valeu|obrigad\w*|tudo bem)\b[\s\S]{0,20}$/i.test(t)) return '';
61
+ const sugeridas = escolher(t);
62
+ const linhas = sugeridas.map((p) => `- ${p.nome} (provável): ${p.olhar}`);
63
+ for (const p of EQUIPE) if (!sugeridas.includes(p)) linhas.push(`- ${p.nome}: ${p.curto}`);
64
+ return '[equipe] Adote o olhar de quem esta tarefa pede (no máximo dois; nenhum, se for conversa):\n' + linhas.join('\n');
65
+ }
66
+
67
+ module.exports = { escolher, bloco, EQUIPE };
package/lib/skills.js CHANGED
@@ -408,7 +408,43 @@ function montar(texto, op = {}) {
408
408
  return { texto: cabeca + texto, skills: escolhidas };
409
409
  }
410
410
 
411
+ /**
412
+ * O CARDÁPIO: a IA escolhe (01/10). "Tem que ser a IA que vai decidir o que
413
+ * ela vai fazer." Antes a skill entrava INTEIRA por contagem de palavra — e
414
+ * "faz um roteiro de vídeo" trazia "Motion de primeira", que mandava animar.
415
+ * Agora entra uma linha por skill (as que casam com o pedido primeiro, até
416
+ * 12), e o modelo carrega a que servir com `primocode skill <nome>` — como a
417
+ * ferramenta Skill do Claude Code. Custa uma ida a mais só quando a skill é
418
+ * usada, e as linhas custam menos que um corpo inteiro.
419
+ */
420
+ function cardapio(texto, { todas = null, max = 12 } = {}) {
421
+ const lista = (todas || listar()).filter((s) => s.ligada && s.corpo);
422
+ if (!lista.length) return { texto, sugeridas: [] };
423
+ const comNota = lista.map((s) => ({ s, pontos: pontuar(s, texto) }))
424
+ .sort((a, b) => b.pontos - a.pontos || a.s.nome.localeCompare(b.s.nome));
425
+ const linhas = comNota.slice(0, max).map(({ s, pontos }) =>
426
+ `- ${s.nome}${pontos ? ' (pode servir)' : ''} — ${String(s.descricao || s.titulo).replace(/\s+/g, ' ').slice(0, 90)}`);
427
+ const cabeca = '[skills] Você decide se usa alguma. Se uma é do tipo desta tarefa, CARREGUE antes de começar '
428
+ + '(run_shell: primocode skill <nome>; várias na mesma resposta). Se nenhuma serve, ignore.\n'
429
+ + linhas.join('\n') + '\n\nPEDIDO:\n';
430
+ return { texto: cabeca + texto, sugeridas: comNota.filter((x) => x.pontos > 0).map((x) => x.s) };
431
+ }
432
+
433
+ /** `primocode skill <nome>` | `primocode skill list` — o corpo da skill, para o agente carregar. */
434
+ function main(argv) {
435
+ const [nome] = argv;
436
+ if (!nome || nome === 'list' || nome === 'help') {
437
+ for (const s of listar().filter((x) => x.ligada)) console.log(`${s.nome} — ${s.descricao || s.titulo}`);
438
+ return 0;
439
+ }
440
+ const s = ler(apelidar(nome)) || listar().find((x) => x.nome.startsWith(String(nome).toLowerCase()));
441
+ if (!s) { console.log(`não há skill "${nome}" — veja primocode skill list`); return 1; }
442
+ console.log(`# ${s.titulo}\n\n${s.corpo}`);
443
+ return 0;
444
+ }
445
+
411
446
  module.exports = {
447
+ cardapio, main,
412
448
  impressao,
413
449
  CASA, ORIGEM, MAX_CARACTERES,
414
450
  separar, listar, ler, criar, ligar, apagar, instalarDe, apelidar,
package/lib/tools.js CHANGED
@@ -402,6 +402,13 @@ function readFile({ path: rawPath, offset, limit }, projectDir) {
402
402
  };
403
403
  }
404
404
 
405
+ /* Arquivo pequeno vem INTEIRO, mesmo pedido aos pedaços: o modelo lia um
406
+ app.js de 393 linhas de 50 em 50, cada pedaço uma ida ao servidor
407
+ (teste da loja, 01/10). Inteiro, uma vez, custa menos. */
408
+ if (todas.length <= 400 && bruto.length <= 40000) {
409
+ return { ok: true, content: todas.map((l, i) => `${i + 1}\t${l}`).join('\n'), linhas: todas.length, bytes: bruto.length,
410
+ inteiro: true, dica: 'arquivo pequeno: veio inteiro, com o número das linhas — não precisa ler de novo' };
411
+ }
405
412
  const inicio = Math.max(0, (offset || 1) - 1);
406
413
  const fim = Math.min(todas.length, inicio + (limit || 200));
407
414
  const recorte = todas.slice(inicio, fim).map((l, i) => `${inicio + i + 1}\t${l}`).join('\n');
@@ -560,6 +567,14 @@ async function despachar(name, args, ctx) {
560
567
  const alvo = /^https?:\/\//i.test(args.target || '')
561
568
  ? args.target
562
569
  : resolveInProject(args.target, projectDir);
570
+ /* Site da web abre À VISTA no Chrome controlado (o cursor vai na
571
+ barra e digita). Arquivo do projeto continua no navegador padrão. */
572
+ if (/^https?:/i.test(alvo) && process.env.PRIMOCODE_BROWSER_HEADLESS !== '1' && require('./cursor').disponivel()) {
573
+ try {
574
+ const r = await browser.abrirVisivel(alvo);
575
+ if (r && r.ok !== false) return { ok: true, opener: 'chrome à vista', url: r.url, pagina: await browser.retrato(40), comoUsar: snapshot.COMO_USAR };
576
+ } catch { /* cai no navegador padrão */ }
577
+ }
563
578
  return openInBrowser(alvo);
564
579
  }
565
580
  case 'multi_edit':
@@ -570,11 +585,14 @@ async function despachar(name, args, ctx) {
570
585
  // Pesquisa de referência: abre documentação e exemplos sobre o
571
586
  // pedido e devolve o TEXTO para o modelo aprender antes de
572
587
  // escrever. Aparece no terminal, com as fontes — nada oculto.
573
- case 'pesquisar_referencia':
574
- return referencia.pesquisar({
588
+ case 'pesquisar_referencia': {
589
+ const r = await referencia.pesquisar({
575
590
  consulta: args.consulta || args.query || '',
576
591
  paginas: Math.max(1, Math.min(5, Number(args.paginas) || 3)),
577
592
  });
593
+ // Escopo fechado ("só do site x.com"): o buscador devolve o mundo; aqui só passa o site.
594
+ return require('./estudio/referencias.js').filtrar(r);
595
+ }
578
596
 
579
597
  // memory-geral.md fica FORA da pasta do projeto — write_file/edit_file
580
598
  // não alcançam por design (resolveInProject bloqueia ../ de propósito).
@@ -595,7 +613,8 @@ async function despachar(name, args, ctx) {
595
613
  const alvo = /^https?:\/\//i.test(args.url || '')
596
614
  ? args.url
597
615
  : 'file://' + resolveInProject(args.url, projectDir);
598
- const r = await browser.open(alvo);
616
+ // Endereço da web abre À VISTA: o cursor vai na barra e digita (browser.abrirVisivel).
617
+ const r = /^https?:/i.test(alvo) ? await browser.abrirVisivel(alvo) : await browser.open(alvo);
599
618
  return r && r.ok !== false ? { ...r, pagina: await browser.retrato(40), comoUsar: snapshot.COMO_USAR } : r;
600
619
  }
601
620
  case 'browser_click': {
package/lib/ui.js CHANGED
@@ -310,11 +310,11 @@ const TIPS = [
310
310
  ];
311
311
 
312
312
  const NEWS = [
313
- 'Digite enquanto ele trabalha: o recado entra na tarefa depois do passo atual',
314
- 'Mais rápido: não relê o que escreveu, e a espera aparece com os segundos',
315
- 'primocode video help — o editor de vídeo que o agente opera sozinho',
316
- 'Motion novo: vox, marca e lancamento · /voice: fale, e ele responde em voz',
317
- 'Esc Esc a qualquer momento para o agente no meio do trabalho',
313
+ 'A IA decide o caminho: skills e equipe num cardápio, ela escolhe o que usa',
314
+ 'Até 8 agentes em paralelo, cada um volta com uma entrega — e passa a vez',
315
+ 'Edite falando no vídeo: primocode video auto aplica o que você diz, na hora',
316
+ 'Colou texto grande? Vira etiqueta [Colado #1 · N linhas] na caixa',
317
+ 'Escopo fechado de verdade: "só do site x.com" filtra a pesquisa',
318
318
  ];
319
319
 
320
320
  // ── Cabeçalho ────────────────────────────────────────────────────────────
@@ -419,6 +419,21 @@ class Spinner {
419
419
  // Fora de um terminal (pipe, CI) a animação só sujaria a saída.
420
420
  if (!process.stdout.isTTY) return this;
421
421
  this.desde = Date.now();
422
+ /* Com a caixa de digitar no ar, o spinner desenha DENTRO dela (ver
423
+ mostrarSpinner em lib/entrada.js): escrever "\r" por cima apagava
424
+ a caixa e a barra de status enquanto ele girava. */
425
+ const caixa = Spinner.caixa;
426
+ if (caixa && caixa.mostrarSpinner && caixa.desenhado !== undefined) {
427
+ this.naCaixa = caixa;
428
+ this.timer = setInterval(() => {
429
+ if (this.textos.length > 1) this.text = this.textos[Math.floor((Date.now() - this.desde) / 4000) % this.textos.length];
430
+ const seg = Math.floor((Date.now() - this.desde) / 1000);
431
+ const extra = (seg >= 3 ? ` · ${seg}s` : '') + (this.detalhe ? ` · ${this.detalhe}` : '');
432
+ caixa.mostrarSpinner(`${c.muted(this.frames[this.i])} ${c.muted(this.text)}${c.dim(extra)}`);
433
+ this.i = (this.i + 1) % this.frames.length;
434
+ }, 120);
435
+ return this;
436
+ }
422
437
  this.timer = setInterval(() => {
423
438
  // Com mais de uma frase, troca a cada 4s. A frase nova pode ser mais
424
439
  // curta que a anterior: o resto da linha velha ficaria na tela, então
@@ -439,10 +454,29 @@ class Spinner {
439
454
  return this;
440
455
  }
441
456
  stop() {
457
+ if (this.naCaixa) {
458
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
459
+ this.naCaixa.mostrarSpinner('');
460
+ this.naCaixa = null;
461
+ return;
462
+ }
442
463
  if (this.timer) { clearInterval(this.timer); this.timer = null; process.stdout.write(`\r${' '.repeat(Math.max(80, this.largura + 4))}\r`); }
443
464
  }
444
465
  }
445
466
 
467
+ /* O TÍTULO DO TERMINAL: é por ele que o Orca (e qualquer terminal com abas)
468
+ sabe que este painel é um agente e se está trabalhando. O Orca lê o glifo
469
+ de spinner em braile como "trabalhando" e o nome como identidade — então
470
+ "⠋ PrimoCode — tarefa" enquanto trabalha, "PrimoCode" parado. Vai pelo
471
+ canal cru da caixa: pelo stdout embrulhado, um escape sem quebra de linha
472
+ deixaria a caixa apagada (o mesmo defeito do spinner). */
473
+ function titulo(texto) {
474
+ if (!process.stdout.isTTY || process.env.PRIMOCODE_SEM_TITULO === '1') return;
475
+ const limpo = String(texto || '').replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 80);
476
+ const escrever = Spinner.caixa && Spinner.caixa.escreverCru ? Spinner.caixa.escreverCru : (s) => process.stdout.write(s);
477
+ try { escrever(`\x1b]0;${limpo}\x07`); } catch { /* terminal fechado */ }
478
+ }
479
+
446
480
  // O rótulo de cada tool na tela — o que a pessoa lê no lugar do nome interno.
447
481
  const ROTULOS = {
448
482
  read_file:'Read', write_file:'Write', edit_file:'Edit', multi_edit:'MultiEdit',
@@ -616,4 +650,5 @@ function friendlyError(raw) {
616
650
  }
617
651
 
618
652
 
619
- module.exports = { c, mascot, MASCOTES, resolverMascote, mascoteInfo, galeriaMascotes, reacao, welcomeBox, Spinner, PENSANDO, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
653
+ module.exports = {
654
+ titulo, c, mascot, MASCOTES, resolverMascote, mascoteInfo, galeriaMascotes, reacao, welcomeBox, Spinner, PENSANDO, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };