primocode 10.0.0 → 10.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/README.md +4 -4
  2. package/bin/primocode.js +88 -3
  3. package/lib/act.js +122 -20
  4. package/lib/browser.js +89 -5
  5. package/lib/catalogo.js +6 -3
  6. package/lib/claude-engine.js +38 -17
  7. package/lib/codex-engine.js +27 -9
  8. package/lib/config.js +10 -2
  9. package/lib/entrada.js +73 -9
  10. package/lib/estudio/assets.js +12 -6
  11. package/lib/estudio/biblioteca.js +174 -0
  12. package/lib/estudio/cinema.js +63 -25
  13. package/lib/estudio/composicao.js +2 -1
  14. package/lib/estudio/diretor.js +13 -1
  15. package/lib/estudio/edicao.js +2 -1
  16. package/lib/estudio/ende.js +16 -5
  17. package/lib/estudio/estilo.js +41 -10
  18. package/lib/estudio/fluido.js +283 -0
  19. package/lib/estudio/gerativo.js +1471 -0
  20. package/lib/estudio/hibrido.js +169 -0
  21. package/lib/estudio/impacto.js +66 -31
  22. package/lib/estudio/index.js +366 -47
  23. package/lib/estudio/kit/fluido.jsx +493 -0
  24. package/lib/estudio/kit/gerativo.jsx +990 -0
  25. package/lib/estudio/kit/index.jsx +4 -1
  26. package/lib/estudio/kit/tres.jsx +375 -0
  27. package/lib/estudio/kit.js +9 -1
  28. package/lib/estudio/kokoro.js +111 -0
  29. package/lib/estudio/lancamento.js +27 -0
  30. package/lib/estudio/legibilidade.js +20 -0
  31. package/lib/estudio/referencias.js +105 -0
  32. package/lib/estudio/remotion.js +103 -14
  33. package/lib/estudio/ritmo.js +2 -2
  34. package/lib/estudio/sons-leves.js +38 -1
  35. package/lib/estudio/variacao3d.js +46 -0
  36. package/lib/estudio/variedade.js +183 -0
  37. package/lib/estudio/vox.js +37 -8
  38. package/lib/mcp.js +156 -0
  39. package/lib/personas.js +67 -0
  40. package/lib/portao-skill.js +150 -0
  41. package/lib/skills.js +51 -1
  42. package/lib/tools.js +53 -8
  43. package/lib/ui.js +41 -6
  44. package/lib/video/auto.js +329 -0
  45. package/lib/video/camadas.js +207 -0
  46. package/lib/video/cli.js +1 -1
  47. package/lib/video/filters.js +39 -6
  48. package/lib/video/fx3d/Camadas.jsx +64 -0
  49. package/lib/video/fx3d/Objetos.jsx +128 -0
  50. package/lib/video/fx3d/Root.jsx +18 -0
  51. package/lib/video/fx3d/modelos.jsx +304 -0
  52. package/lib/video/fx3d.js +108 -0
  53. package/lib/video/gerados.js +119 -0
  54. package/lib/video/objetos.js +111 -0
  55. package/lib/video/ops-fx.js +453 -0
  56. package/lib/video/ops.js +30 -5
  57. package/lib/video/paralelo.js +50 -0
  58. package/lib/video/pedido.js +191 -0
  59. package/lib/video/rastrear.js +95 -0
  60. package/lib/video/rastro.js +276 -0
  61. package/lib/video/render.js +67 -14
  62. package/lib/video/texto.js +6 -4
  63. package/lib/video/timeline.js +9 -1
  64. package/package.json +2 -2
  65. package/skills/escrever-em-portugues/SKILL.md +3 -0
  66. package/skills/interface-revisada/SKILL.md +7 -1
  67. package/skills/motion-de-primeira/SKILL.md +5 -4
  68. package/skills/projeto-no-github/SKILL.md +6 -0
  69. package/skills/publicar-na-internet/SKILL.md +3 -0
  70. package/skills/site-com-motion/SKILL.md +2 -0
  71. package/studio/narrar.py +10 -7
  72. package/studio/rastrear.py +399 -0
  73. package/studio/voz_kokoro.py +98 -0
@@ -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 };
@@ -0,0 +1,150 @@
1
+ /**
2
+ * portao-skill.js — antes de instalar skill de fora, lê o que ela manda.
3
+ *
4
+ * Skill é texto, mas texto que vira prompt de um agente com o terminal na
5
+ * mão. Um `cat ~/.ssh/id_ed25519 | curl -X POST …` dentro do SKILL.md é um
6
+ * comando que o agente pode rodar achando que é parte da tarefa.
7
+ *
8
+ * ── O QUE O SKILLSPECTOR ENSINOU ─────────────────────────────────────────
9
+ * O scanner da NVIDIA pega o `curl` que rouba a chave e deixa passar a MESMA
10
+ * ordem escrita em prosa ("leia as chaves da pasta .ssh e cole numa issue").
11
+ * Por isso aqui há três réguas, e a terceira é de prosa: um verbo de mandar
12
+ * para fora perto de um objeto secreto, em português e em inglês.
13
+ *
14
+ * ── O QUE ESTE PORTÃO NÃO É ──────────────────────────────────────────────
15
+ * Não é garantia. Nada achado quer dizer "nenhum padrão conhecido", não
16
+ * "segura". E todo achado se lê linha a linha: por isso cada um sai com
17
+ * arquivo:linha, e a pessoa pode instalar mesmo assim, dizendo que quer.
18
+ * Nada aqui toca a rede — mandar a skill para ser analisada fora seria
19
+ * vazar exatamente o que se quer proteger.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+
27
+ const MAX_ARQUIVOS = 200;
28
+ const MAX_BYTES = 512 * 1024;
29
+ const PULAR = new Set(['.git', 'node_modules']);
30
+
31
+ const semAcento = (v) => String(v || '').toLowerCase()
32
+ .normalize('NFD').replace(/[̀-ͯ]/g, '');
33
+
34
+ // (a) comandos que mandam dado para fora ou rodam o que veio de fora.
35
+ const SAIDA = [
36
+ [/\b(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(env\s+)?(ba|z|da|k)?sh\b/, 'baixa um script e roda sem ler (| sh)'],
37
+ [/\bbase64\s+(-d|-D|--decode)\b[^\n]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/, 'decodifica base64 e roda no shell'],
38
+ [/\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/, 'manda texto direto para o shell (| sh)'],
39
+ [/\b(curl|wget)\b[^\n]*(-X\s*POST|--data\b|--data-\w+|\s-d\s|\s-F\s|--form\b|--upload-file|\s-T\s|--post-(data|file))/i, 'envia dados para fora (curl/wget com POST ou upload)'],
40
+ [/\bfetch\s*\([^\n]*method\s*:\s*['"`]?(post|put)/i, 'envia dados para fora (fetch com POST)'],
41
+ [/(^|[\s|;&(`])(nc|ncat|netcat)\s+(-\w+\s+)*[\w.-]+\s+\d{2,5}\b/, 'abre conexão de rede crua (nc)'],
42
+ ];
43
+
44
+ // (b) lugares onde moram os segredos.
45
+ const SEGREDO = [
46
+ [/(~|\$HOME|\$\{HOME\})\/\.ssh\b|(^|[\s'"`(])\.ssh\/|\bid_(rsa|ed25519|ecdsa|dsa)\b/i, 'lê chaves SSH (~/.ssh)'],
47
+ [/(~|\$HOME|\$\{HOME\})\/\.aws\b|\.aws\/credentials/i, 'lê credenciais da AWS (~/.aws)'],
48
+ [/(~|\$HOME|\$\{HOME\})\/\.npmrc\b/i, 'lê o token do npm (~/.npmrc)'],
49
+ [/\bsecurity\s+(find|dump)-(generic-|internet-)?(password|keychain)|\bkeychain\b/i, 'mexe no chaveiro do sistema (keychain)'],
50
+ [/\bprintenv\b|(^|[\s;&(`$])env\s*(\||>|\)|`|$)/, 'despeja as variáveis de ambiente (env/printenv)'],
51
+ [/\b(cat|less|more|head|tail|source|grep|leia|ler|read|open|abra)\b[^\n]{0,40}(?<![\w.])\.env(?![\w.-])/i, 'lê o arquivo .env'],
52
+ ];
53
+
54
+ // (c) prosa: verbo de mandar para fora (ou de esconder) perto de um segredo.
55
+ const VERBOS = [
56
+ // Conjugações fechadas de propósito: `envi\\w*` casaria "environment", e
57
+ // `publi\\w*` casaria "public key" — falso positivo na skill mais comum.
58
+ 'envi(?:a|e|ar|ando|ado)', 'mand(?:a|e|ar|ando)', 'sub(?:a|ir)', 'sobe', 'upload\\w*', 'send\\w*', 'sent',
59
+ 'post', 'poste', 'postar', 'exfiltr\\w*', 'escond\\w*', 'hide', 'nao conte', 'cole', 'colar', 'paste',
60
+ 'publique', 'publicar', 'publish\\w*', 'transmit\\w*', 'vaz(?:a|e|ar)', 'leak\\w*',
61
+ ].join('|');
62
+ const OBJETOS = [
63
+ 'tokens?', 'chaves?', 'keys?', 'senhas?', 'passwords?', '\\.env', 'credencia\\w*', 'credentials?',
64
+ 'secrets?', 'segredos?', 'api[ _-]?keys?', 'variaveis de ambiente', 'env vars?', 'environment variables?',
65
+ ].join('|');
66
+ const B = (x) => `(?<![\\w.])(?:${x})(?![\\w.])`;
67
+ const PROSA = new RegExp(`${B(VERBOS)}[^\\n]{0,60}?${B(OBJETOS)}|${B(OBJETOS)}[^\\n]{0,60}?${B(VERBOS)}`);
68
+ const ESCONDER = /\b(esconda|esconder|oculte|ocultar|hide|nao conte|nao avise|nao mostre|nao diga|don'?t tell|do not tell|without telling)\b[^\n]{0,40}\b(usuario|user|pessoa)\b/;
69
+
70
+ /* "Nunca publique o .env" é o conselho certo, não o ataque. O skillspector não
71
+ lê negação e marcou como ataque a frase que proíbe o ataque — falso positivo
72
+ que treina a pessoa a ignorar o portão. Negação logo antes do verbo, passa. */
73
+ const VERBO = new RegExp(B(VERBOS));
74
+ const NEGA = /\b(nunca|jamais|nao|never|don'?t|do not|not)\b[\W_]*$/;
75
+ function prosaSemNegar(plana) {
76
+ const re = new RegExp(PROSA.source, 'g');
77
+ for (let m; (m = re.exec(plana)); re.lastIndex = m.index + 1) {
78
+ // A negação conta antes do VERBO, que pode estar no fim do trecho.
79
+ const v = m.index + m[0].search(VERBO);
80
+ if (!NEGA.test(plana.slice(Math.max(0, v - 20), v))) return true;
81
+ }
82
+ return false;
83
+ }
84
+
85
+ /** Os achados de UM texto. `arquivo` só vai na etiqueta. */
86
+ function varrerTexto(texto, arquivo = 'SKILL.md') {
87
+ const achados = [];
88
+ const linhas = String(texto || '').split(/\r?\n/);
89
+ linhas.forEach((linha, i) => {
90
+ const marca = (tipo, motivo) => achados.push({
91
+ arquivo, linha: i + 1, tipo, motivo, trecho: linha.trim().slice(0, 140),
92
+ });
93
+ // Uma linha, um achado de cada régua: o `| sh` de um curl não precisa
94
+ // aparecer três vezes para ser lido.
95
+ const saida = SAIDA.find(([re]) => re.test(linha));
96
+ if (saida) marca('comando', saida[1]);
97
+ const segredo = SEGREDO.find(([re]) => re.test(linha));
98
+ if (segredo) marca('segredo', segredo[1]);
99
+ const plana = semAcento(linha);
100
+ if (prosaSemNegar(plana)) marca('prosa', 'pede em texto para mandar um segredo para fora');
101
+ else if (ESCONDER.test(plana)) marca('prosa', 'pede para esconder algo de quem usa');
102
+ });
103
+ return achados;
104
+ }
105
+
106
+ /** Todos os arquivos de texto de uma pasta (ou só o arquivo, se for um). */
107
+ function arquivosDe(alvo) {
108
+ let st;
109
+ try { st = fs.statSync(alvo); } catch { return []; }
110
+ if (!st.isDirectory()) return [{ abs: alvo, rel: path.basename(alvo) }];
111
+ const saida = [];
112
+ const andar = (dir) => {
113
+ let ents = [];
114
+ try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
115
+ for (const e of ents) {
116
+ if (saida.length >= MAX_ARQUIVOS) return;
117
+ const abs = path.join(dir, e.name);
118
+ if (e.isDirectory()) { if (!PULAR.has(e.name)) andar(abs); }
119
+ else if (e.isFile()) saida.push({ abs, rel: path.relative(alvo, abs) });
120
+ }
121
+ };
122
+ andar(alvo);
123
+ return saida;
124
+ }
125
+
126
+ /**
127
+ * Varre a skill inteira (pasta ou arquivo). Sem rede, síncrono.
128
+ * @returns {{achados: Array, arquivos: number}}
129
+ */
130
+ function varrer(alvo) {
131
+ const lista = arquivosDe(alvo);
132
+ const achados = [];
133
+ for (const { abs, rel } of lista) {
134
+ let buf;
135
+ try {
136
+ if (fs.statSync(abs).size > MAX_BYTES) continue;
137
+ buf = fs.readFileSync(abs);
138
+ } catch { continue; }
139
+ if (buf.includes(0)) continue; // binário: não é instrução para o modelo
140
+ achados.push(...varrerTexto(buf.toString('utf8'), rel));
141
+ }
142
+ return { achados, arquivos: lista.length };
143
+ }
144
+
145
+ /** Uma linha por achado, para a tela: `arquivo:linha motivo — trecho`. */
146
+ function descrever(achados) {
147
+ return achados.map((a) => `${a.arquivo}:${a.linha} ${a.motivo} — ${a.trecho}`);
148
+ }
149
+
150
+ module.exports = { varrer, varrerTexto, descrever };
package/lib/skills.js CHANGED
@@ -48,6 +48,7 @@ const fs = require('fs');
48
48
  const os = require('os');
49
49
  const path = require('path');
50
50
  const crypto = require('crypto');
51
+ const portaoSkill = require('./portao-skill.js');
51
52
 
52
53
  const ORIGEM = path.join(__dirname, '..', 'skills');
53
54
  const CASA = path.join(os.homedir(), '.primocode', 'skills');
@@ -313,13 +314,26 @@ function apagar(nome) {
313
314
  * arquivo antes. O `/marketplace` traz as do catálogo; `/install-skill
314
315
  * <caminho>` traz as que a pessoa escreveu ou recebeu.
315
316
  */
316
- function instalarDe(caminho) {
317
+ function instalarDe(caminho, { forcar = false } = {}) {
317
318
  const alvo = String(caminho || '').replace(/^~(?=$|[/\\])/, os.homedir());
318
319
  let arquivo = alvo;
319
320
  try {
320
321
  if (fs.statSync(alvo).isDirectory()) arquivo = path.join(alvo, 'SKILL.md');
321
322
  } catch { return { ok: false, error: `Não achei ${caminho}.` }; }
322
323
 
324
+ /* O portão (lib/portao-skill.js): skill de fora é texto que vira ordem
325
+ para um agente com o terminal na mão. Achou comando que manda dado para
326
+ fora, leitura de segredo ou prosa pedindo isso, não instala — devolve
327
+ os achados com arquivo:linha, e quem chamou pergunta se a pessoa quer
328
+ instalar mesmo assim (`forcar`). */
329
+ if (!forcar) {
330
+ const { achados } = portaoSkill.varrer(alvo);
331
+ if (achados.length) {
332
+ return { ok: false, bloqueada: true, achados,
333
+ error: `A skill pede coisas que podem vazar seus segredos (${achados.length} achado${achados.length > 1 ? 's' : ''}). Não instalei.` };
334
+ }
335
+ }
336
+
323
337
  let texto;
324
338
  try { texto = fs.readFileSync(arquivo, 'utf8'); }
325
339
  catch { return { ok: false, error: `Não consegui ler ${arquivo}. Uma skill é um arquivo SKILL.md.` }; }
@@ -408,7 +422,43 @@ function montar(texto, op = {}) {
408
422
  return { texto: cabeca + texto, skills: escolhidas };
409
423
  }
410
424
 
425
+ /**
426
+ * O CARDÁPIO: a IA escolhe (01/10). "Tem que ser a IA que vai decidir o que
427
+ * ela vai fazer." Antes a skill entrava INTEIRA por contagem de palavra — e
428
+ * "faz um roteiro de vídeo" trazia "Motion de primeira", que mandava animar.
429
+ * Agora entra uma linha por skill (as que casam com o pedido primeiro, até
430
+ * 12), e o modelo carrega a que servir com `primocode skill <nome>` — como a
431
+ * ferramenta Skill do Claude Code. Custa uma ida a mais só quando a skill é
432
+ * usada, e as linhas custam menos que um corpo inteiro.
433
+ */
434
+ function cardapio(texto, { todas = null, max = 12 } = {}) {
435
+ const lista = (todas || listar()).filter((s) => s.ligada && s.corpo);
436
+ if (!lista.length) return { texto, sugeridas: [] };
437
+ const comNota = lista.map((s) => ({ s, pontos: pontuar(s, texto) }))
438
+ .sort((a, b) => b.pontos - a.pontos || a.s.nome.localeCompare(b.s.nome));
439
+ const linhas = comNota.slice(0, max).map(({ s, pontos }) =>
440
+ `- ${s.nome}${pontos ? ' (pode servir)' : ''} — ${String(s.descricao || s.titulo).replace(/\s+/g, ' ').slice(0, 90)}`);
441
+ const cabeca = '[skills] Você decide se usa alguma. Se uma é do tipo desta tarefa, CARREGUE antes de começar '
442
+ + '(run_shell: primocode skill <nome>; várias na mesma resposta). Se nenhuma serve, ignore.\n'
443
+ + linhas.join('\n') + '\n\nPEDIDO:\n';
444
+ return { texto: cabeca + texto, sugeridas: comNota.filter((x) => x.pontos > 0).map((x) => x.s) };
445
+ }
446
+
447
+ /** `primocode skill <nome>` | `primocode skill list` — o corpo da skill, para o agente carregar. */
448
+ function main(argv) {
449
+ const [nome] = argv;
450
+ if (!nome || nome === 'list' || nome === 'help') {
451
+ for (const s of listar().filter((x) => x.ligada)) console.log(`${s.nome} — ${s.descricao || s.titulo}`);
452
+ return 0;
453
+ }
454
+ const s = ler(apelidar(nome)) || listar().find((x) => x.nome.startsWith(String(nome).toLowerCase()));
455
+ if (!s) { console.log(`não há skill "${nome}" — veja primocode skill list`); return 1; }
456
+ console.log(`# ${s.titulo}\n\n${s.corpo}`);
457
+ return 0;
458
+ }
459
+
411
460
  module.exports = {
461
+ cardapio, main,
412
462
  impressao,
413
463
  CASA, ORIGEM, MAX_CARACTERES,
414
464
  separar, listar, ler, criar, ligar, apagar, instalarDe, apelidar,