primocode 8.1.0 → 8.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v7.10.1
1
+ # PrimoCode v8.1.1
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 v7.10.1
12
+ ▐▛███▜▌ Primo Code v8.1.1
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -263,7 +263,7 @@ primocode -f app.js "onde está o bug?" # com arquivo anexado
263
263
  | `/model top\|main\|fast` | **Todos grátis.** top = o mais capaz (550B, 1M de contexto), main = agente de código (padrão), fast = o mais rápido. Aceita qualquer ID do OpenRouter |
264
264
  | `/attach <arquivo>` | Anexa arquivo ao próximo pedido |
265
265
  | `/detach` · `/files` | Remove / lista anexos |
266
- | `/tudo` | Lista as 30 ferramentas que ele sabe usar, por grupo |
266
+ | `/tudo` | Lista as 44 ferramentas que ele sabe usar, por grupo |
267
267
  | `/tree [n]` | Árvore do projeto atual |
268
268
  | `/read <arquivo>` | Mostra um arquivo do projeto |
269
269
  | `/run <comando>` | Executa no shell, dentro da pasta do projeto |
package/bin/primocode.js CHANGED
@@ -516,7 +516,9 @@ async function sendPrompt(userText) {
516
516
  // tokens). No auto, quem não vai usar ferramenta não paga por ela.
517
517
  let modo = state.mode;
518
518
  if (modo === 'auto') {
519
- const escolha = escolherModo(userText);
519
+ // O modo do pedido ANTERIOR entra na decisão: no meio de uma tarefa,
520
+ // "refaz" e "agora põe música" são continuação, não papo novo.
521
+ const escolha = escolherModo(userText, { ultimoModo: state.modoEfetivo });
520
522
  modo = escolha.modo;
521
523
  state.modoEfetivo = modo;
522
524
  const rotulo = modo === 'primocode' ? c.warn('Primo Code') : c.brandDim('Chat');
package/lib/browser.js CHANGED
@@ -88,8 +88,9 @@ const APPS_MAC = [
88
88
  *
89
89
  * Um app rodando de dentro do `.dmg` funciona, mas é frágil: o disco é
90
90
  * somente-leitura, o macOS o executa por AppTranslocation (caminho temporário
91
- * aleatório) e ele desaparece na hora que o disco for ejetado. Achamos e usamos —
92
- * e `avisoDeLocal()` diz ao usuário para arrastar o app para Aplicativos.
91
+ * aleatório) e ele desaparece na hora que o disco for ejetado. Achamos e
92
+ * usamos mesmo assim — sem aviso adicional, o disco ejetado já falha na hora
93
+ * de abrir o navegador, o que já é sinal suficiente do que aconteceu.
93
94
  */
94
95
  function procurarEmVolumes() {
95
96
  const bases = [];
@@ -107,24 +108,6 @@ function procurarEmVolumes() {
107
108
  return null;
108
109
  }
109
110
 
110
- /**
111
- * O navegador achado está num lugar problemático? Devolve o aviso, ou null.
112
- *
113
- * Não bloqueia nada: o controle funciona de dentro do disco montado. Só avisa,
114
- * porque quem ejetar o disco vai ver a ferramenta parar de funcionar sem entender
115
- * o motivo.
116
- */
117
- function avisoDeLocal(caminho) {
118
- if (!caminho) return null;
119
- if (caminho.startsWith('/Volumes/')) {
120
- const app = (caminho.match(/\/Volumes\/[^/]+\/([^/]+\.app)/) || [, 'o navegador'])[1];
121
- return `${app} está rodando do disco de instalação, não instalado. `
122
- + 'Funciona, mas para de funcionar quando você ejetar esse disco — '
123
- + 'arraste o app para a pasta Aplicativos.';
124
- }
125
- return null;
126
- }
127
-
128
111
  function httpGetJson(url, timeoutMs = 3000) {
129
112
  return new Promise((resolve, reject) => {
130
113
  const req = http.get(url, (res) => {
@@ -450,8 +433,6 @@ async function open(url) {
450
433
  return { ok: true, url };
451
434
  }
452
435
 
453
- async function navigate(url) { return open(url); }
454
-
455
436
  async function evaluate(js) {
456
437
  await ensure();
457
438
  const r = await currentSession.send('Runtime.evaluate', {
@@ -639,11 +620,6 @@ async function screenshot(saveTo) {
639
620
  return { ok: true, path: filename, bytes: Buffer.byteLength(r.data, 'base64') };
640
621
  }
641
622
 
642
- async function currentUrl() {
643
- await ensure();
644
- return evaluate('location.href');
645
- }
646
-
647
623
  async function waitFor(selector, timeoutMs = 15000) {
648
624
  await ensure();
649
625
  const deadline = Date.now() + timeoutMs;
@@ -669,8 +645,6 @@ function close() {
669
645
  }
670
646
 
671
647
  module.exports = {
672
- open, navigate, click, clickText, clickSelector, type, typeInSelector,
673
- key, screenshot, evaluate, currentUrl, waitFor, wait, scroll, close,
674
- avisoDeLocal,
675
- _internal: { findChrome, procurarEmVolumes, PROFILE_DIR, DEBUG_PORT },
648
+ open, click, clickText, clickSelector, type, typeInSelector,
649
+ key, screenshot, evaluate, waitFor, wait, scroll, close,
676
650
  };
package/lib/catalogo.js CHANGED
@@ -85,6 +85,8 @@ const GRUPOS = [
85
85
  { tool: 'studio_atualizar', escreve: false, faz: 'ajusta o que já existe, sem refazer do zero',
86
86
  args: 'id. titulo (opcional). doc (opcional).' },
87
87
  { tool: 'studio_listar', escreve: false, faz: 'lista o que já existe no estúdio', args: null },
88
+ { tool: 'studio_midia', escreve: false, faz: 'manda imagem, música ou vídeo seu para usar nas peças',
89
+ args: 'arquivo (opcional): caminho no projeto — sem ele, lista a galeria. nome (opcional).' },
88
90
  ],
89
91
  },
90
92
  {
package/lib/config.js CHANGED
@@ -73,10 +73,15 @@ function ensureStructure() {
73
73
  return { rootDir: ROOT_DIR };
74
74
  }
75
75
 
76
+ // Tira acento (NFD + remove os diacríticos combinantes). Compartilhado com
77
+ // lib/desktop.js — as duas versões faziam a mesma conta separadas.
78
+ function stripAccents(s) {
79
+ return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '');
80
+ }
81
+
76
82
  // "Jogo de Carro!" -> "jogo-de-carro"
77
83
  function slugify(name) {
78
- return String(name)
79
- .normalize('NFD').replace(/[̀-ͯ]/g, '')
84
+ return stripAccents(name)
80
85
  .toLowerCase()
81
86
  .replace(/[^a-z0-9]+/g, '-')
82
87
  .replace(/^-+|-+$/g, '')
@@ -208,7 +213,7 @@ function appendHistory(entry) {
208
213
  module.exports = {
209
214
  loadConfig, saveConfig, appendHistory,
210
215
  CONFIG_DIR, CONFIG_FILE, HISTORY_FILE, ROOT_DIR, GENERAL_MEMORY,
211
- ensureStructure, slugify,
216
+ ensureStructure, slugify, stripAccents,
212
217
  createProjectDir, getProjectDir, listProjects, findSimilarProject,
213
218
  readGeneralMemory, readProjectMemory, appendProjectMemory, appendGeneralMemory,
214
219
  };
package/lib/desktop.js CHANGED
@@ -15,6 +15,7 @@
15
15
 
16
16
  const { execSync } = require('child_process');
17
17
  const os = require('os');
18
+ const { stripAccents } = require('./config');
18
19
 
19
20
  const PLATFORM = os.platform();
20
21
 
@@ -344,7 +345,7 @@ const APP_ALIASES = {
344
345
  };
345
346
 
346
347
  function normaliza(s) {
347
- return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
348
+ return stripAccents(s).toLowerCase().trim();
348
349
  }
349
350
 
350
351
  function aliasDoApp(nome, platform = PLATFORM) {
@@ -371,7 +372,17 @@ function openApp(nome) {
371
372
  } else {
372
373
  const bin = app.split(/\s+/)[0];
373
374
  if (hasCmd('gtk-launch') && run(`gtk-launch ${sh1(bin)}`).ok) r = { ok: true };
374
- else if (hasCmd(bin)) r = run(`bash -c ${sh1(`setsid ${app} >/dev/null 2>&1 < /dev/null &`)}`);
375
+ else if (hasCmd(bin)) {
376
+ // sh1() aqui só protegia o script INTEIRO como um argumento pro
377
+ // `bash -c` — dentro do script, bash reparsa o texto, e "app" cru
378
+ // continuava vulnerável a injeção (achado de segurança: um app tipo
379
+ // "a;touch /tmp/pwned;" executava o comando extra). Cada palavra de
380
+ // "app" agora entra entre aspas simples individualmente — preserva
381
+ // "programa --flag" como dois argumentos, mas nenhuma delas deixa
382
+ // metacaractere de shell passar como sintaxe.
383
+ const appEscapado = app.split(/\s+/).map(sh1).join(' ');
384
+ r = run(`bash -c ${sh1(`setsid ${appEscapado} >/dev/null 2>&1 < /dev/null &`)}`);
385
+ }
375
386
  else r = { ok: false, error: `Não achei o programa "${pedido}" no PATH.` };
376
387
  }
377
388
 
package/lib/modo.js CHANGED
@@ -1,19 +1,28 @@
1
1
  /**
2
2
  * Escolha automática de modo.
3
3
  *
4
- * O modo primocode manda o schema das 30 ferramentas em todo pedido — ~4.700
5
- * tokens antes de você digitar a primeira letra. Um "oi" gasta o mesmo que uma
6
- * refatoração, e num plano com teto diário isso é dinheiro jogado fora (foi
4
+ * O modo primocode manda o schema das 45 ferramentas em todo pedido — milhares
5
+ * de tokens antes de você digitar a primeira letra. Um "oi" gasta o mesmo que
6
+ * uma refatoração, e num plano com teto diário isso é dinheiro jogado fora (foi
7
7
  * assim que a cota de 100 mil tokens/dia acabou às 15h).
8
8
  *
9
9
  * O modo chat não manda schema nenhum. Então a regra é: só paga o preço das
10
10
  * ferramentas quem vai usar ferramenta.
11
11
  *
12
12
  * A decisão é local e sem custo — nenhuma chamada ao modelo para descobrir a
13
- * intenção, senão o remédio custaria como a doença. É um classificador de
14
- * pistas, deliberadamente conservador: na dúvida, chat, porque errar para chat
15
- * custa uma repetição do pedido, e errar para primocode custa tokens e pode
16
- * escrever arquivo que ninguém pediu.
13
+ * intenção, senão o remédio custaria como a doença.
14
+ *
15
+ * ── POR QUE ELE OLHA O TURNO ANTERIOR ────────────────────────────────────
16
+ * A primeira versão julgava cada frase sozinha, e errava feio no uso real: o
17
+ * usuário pedia um vídeo (tarefa), e o "refaz", o "continua" e o "agora põe
18
+ * música de fundo" logo depois caíam no chat — o agente perdia o fio no meio
19
+ * do trabalho. Medido na conversa do dono: 7 erros em 18 frases, quase todos
20
+ * de continuação.
21
+ *
22
+ * Conversa humana não repete o objeto a cada frase. Então, QUANDO O TURNO
23
+ * ANTERIOR FOI UMA TAREFA, a régua abaixa: o que não é claramente cumprimento
24
+ * ou dúvida conceitual continua sendo trabalho. Fora de uma tarefa, a régua
25
+ * volta a subir — na dúvida, chat.
17
26
  */
18
27
 
19
28
  'use strict';
@@ -34,6 +43,13 @@ const VERBOS = [
34
43
  // "pesquisa no google", "aperta enter", "arrasta o arquivo".
35
44
  'clic[ao]r?', 'clique', 'digit[ae]r?', 'pesquis[ae]r?', 'apert[ae]r?',
36
45
  'arrast[ae]r?', 'rol[ae]r?', 'minimiz[ae]r?', 'maximiz[ae]r?', 'fech[ae]r?',
46
+ // Ajuste do que já foi criado — o vocabulário do estúdio. Sem estes, "agora
47
+ // põe música de fundo" caía no chat, no meio de um vídeo.
48
+ 'p[õo]e', 'ponha', 'p[oô]r', 'coloc[ae]r?', 'tir[ae]r?', 'troc[ae]r?',
49
+ 'inclu[ai]r?', 'insir[ae]', 'insert', 'aument[ae]r?', 'diminu[ai]r?',
50
+ 'grav[ae]r?', 'narr[ae]r?', 'export[ae]r?', 'salv[ae]r?', 'envi[ae]r?',
51
+ 'convert[ae]r?', 'transform[ae]r?', 'divid[ai]r?', 'junt[ae]r?',
52
+ 'desenh[ae]r?', 'ilustr[ae]r?', 'resum[ae]r?', 'traduz[ai]r?',
37
53
  ];
38
54
  const RE_VERBO = new RegExp(`(^|\\s)(${VERBOS.join('|')})\\b`, 'i');
39
55
 
@@ -49,33 +65,75 @@ const RE_ARTEFATO = /\.(js|ts|tsx|jsx|html|css|json|md|py|sh|yml|yaml|txt|csv|en
49
65
  const RE_FERRAMENTA = /\b(navegador|browser|desktop|terminal|shell|git|npm|node|deploy|servidor|localhost|porta \d+|screenshot|print da tela|mouse|teclado|janela|[áa]rea de trabalho|aplicativo|[íi]cone)\b/i;
50
66
  const RE_PASTA = /\b(pasta|diret[óo]rio|arquivo|arquivos|reposit[óo]rio|repo|projeto)\b/i;
51
67
 
68
+ // O que o estúdio produz. "faz uns slides", "quero um post", "monta a planilha"
69
+ // — o substantivo sozinho já denuncia a tarefa, mesmo sem verbo claro.
70
+ const RE_PECA = /\b(slides?|apresenta[çc][ãa]o|deck|v[íi]deo|reels?|anima[çc][ãa]o|post|banner|capa|thumbnail|arte|cartaz|planilha|tabela|documento|relat[óo]rio|artigo|proposta|contrato|diagrama|fluxograma|organograma|mapa mental|narra[çc][ãa]o|trilha|legenda)\b/i;
71
+
52
72
  // Conversa: cumprimento, agradecimento, pergunta conceitual.
53
- const RE_SAUDACAO = /^\s*(oi+|ol[áa]|e?a[íi]|bom dia|boa tarde|boa noite|tudo bem|beleza|valeu|obrigad[oa]|brigad[oa]|tchau|até|ok|okay|entendi|legal|show|massa)\b[\s!.?]*$/i;
73
+ const RE_SAUDACAO = /^\s*(oi+|ol[áa]|e?a[íi]|bom dia|boa tarde|boa noite|tudo bem|beleza|valeu|obrigad[oa]|brigad[oa]|tchau|at[ée]|ok|okay|entendi|legal|show|massa|top|perfeito|isso|isso a[íi]|ta bom|t[áa] bom|certo)\b[\s!.?]*$/i;
54
74
  const RE_CONCEITUAL = /\b(o que [ée]|o q [ée]|que [ée]|qual (a|o|as|os)?\s*(diferen[çc]a|melhor|sentido)|por qu[êe]|pra que serve|para que serve|como funciona|como (fa[çc]o|posso|se faz|fazer)|explica|explique|me explica|significa|vale a pena|devo usar|qual usar|prefere)\b/i;
75
+ // "o que você faz?", "o que vc sabe fazer?" — é pergunta sobre ele, não pedido.
76
+ // Sem esta linha o "faz" batia no verbo e virava tarefa.
77
+ const RE_SOBRE_ELE = /\bo que (voc[êe]|vc|tu)\s+(faz|sabe|consegue|pode|[ée])\b|\bquem [ée] voc[êe]\b|\bcomo voc[êe] funciona\b/i;
78
+
79
+ // Satisfação: "ficou bom", "tá bom assim", "pode parar" — o usuário está
80
+ // dizendo que ACABOU, não pedindo mais trabalho. Sem isto, no meio de uma
81
+ // tarefa a régua abaixada mandava um elogio de volta para o agente.
82
+ const RE_SATISFEITO = /^\s*(t[áa]|ta|ficou|est[áa]|ta[' ]?)?\s*(bom|[óo]timo|legal|show|massa|perfeito|lindo|maneiro|isso)\b(\s+(assim|mesmo|ent[ãa]o|demais))?[\s!.]*$|^\s*(pode parar|chega|s[óo] isso|era isso|nada mais)\b/i;
83
+
84
+ // Continuação forte: sozinhas já significam "siga com o que estava fazendo".
85
+ const RE_CONTINUACAO = /^\s*(refa[çz]a?|refaz|de novo|novamente|outra vez|mais uma vez|continu[ae]|prossiga|segue|siga|vai|manda|tenta( de novo| outra vez)?|tente( de novo| outra vez)?|repete|repita)\b/i;
86
+
87
+ // Continuação fraca: só vale quando o turno anterior foi tarefa.
88
+ // "agora …", "e o …?", "também …", "mais …", "sem …", "com …".
89
+ const RE_CONTINUACAO_FRACA = /^\s*(agora|depois|ent[ãa]o|e (o|a|os|as|no|na)\b|tamb[ée]m|mais|menos|sem|com|s[óo]|apenas|troca|muda)\b/i;
55
90
 
56
91
  /**
57
92
  * Decide o modo de um pedido.
58
- * Devolve { modo: 'primocode'|'chat', motivo: string }.
93
+ *
94
+ * @param {string} texto o que o usuário digitou
95
+ * @param {{ultimoModo?: string}} ctx contexto da conversa; `ultimoModo` é o
96
+ * modo que o pedido ANTERIOR usou de fato ('primocode' | 'chat').
97
+ * @returns {{modo:'primocode'|'chat', motivo:string}}
98
+ *
59
99
  * O motivo existe para o CLI mostrar por que escolheu — automode que decide em
60
100
  * silêncio parece defeito na primeira vez que erra.
61
101
  */
62
- function escolherModo(texto) {
102
+ function escolherModo(texto, ctx = {}) {
63
103
  const t = String(texto || '').trim();
64
104
  if (!t) return { modo: 'chat', motivo: 'nada a fazer' };
65
105
 
106
+ const noMeioDeUmaTarefa = (ctx && ctx.ultimoModo) === 'primocode';
107
+
66
108
  // Cumprimento é chat mesmo que traga um verbo depois ("oi, tudo bem?").
67
109
  if (RE_SAUDACAO.test(t)) return { modo: 'chat', motivo: 'cumprimento' };
110
+ if (RE_SATISFEITO.test(t)) return { modo: 'chat', motivo: 'está satisfeito' };
68
111
 
69
- // Pergunta conceitual ganha do verbo: "como faço para criar um site?" é
70
- // dúvida, não pedido de execução.
112
+ // Pergunta sobre ele mesmo, e pergunta conceitual, ganham do verbo:
113
+ // "como faço para criar um site?" é dúvida, não pedido de execução.
114
+ if (RE_SOBRE_ELE.test(t)) return { modo: 'chat', motivo: 'pergunta sobre ele' };
71
115
  if (RE_CONCEITUAL.test(t)) return { modo: 'chat', motivo: 'pergunta conceitual' };
72
116
 
117
+ // "refaz", "de novo", "continua" — retomada, com ou sem contexto.
118
+ if (RE_CONTINUACAO.test(t)) return { modo: 'primocode', motivo: 'retoma a tarefa' };
119
+
120
+ // Pistas diretas.
73
121
  if (RE_ARTEFATO.test(t)) return { modo: 'primocode', motivo: 'cita arquivo ou caminho' };
74
122
  if (RE_VERBO.test(t)) return { modo: 'primocode', motivo: 'pede uma ação' };
123
+ if (RE_PECA.test(t)) return { modo: 'primocode', motivo: 'fala de algo para criar' };
75
124
  if (RE_DESEJO.test(t)) return { modo: 'primocode', motivo: 'expressa o que quer que exista' };
76
125
  if (RE_FERRAMENTA.test(t)) return { modo: 'primocode', motivo: 'menciona ferramenta ou máquina' };
77
126
  if (RE_PASTA.test(t)) return { modo: 'primocode', motivo: 'fala de arquivo ou projeto' };
78
127
 
128
+ // No meio de uma tarefa a régua abaixa: conversa não repete o objeto a cada
129
+ // frase, e o custo de errar aqui é o agente perder o fio.
130
+ if (noMeioDeUmaTarefa) {
131
+ if (RE_CONTINUACAO_FRACA.test(t)) return { modo: 'primocode', motivo: 'continua o que estava fazendo' };
132
+ // Pergunta curta no meio do trabalho costuma ser sobre o trabalho
133
+ // ("e não dá pra deixar rodando?"), não papo novo.
134
+ if (t.split(/\s+/).length <= 12) return { modo: 'primocode', motivo: 'segue a tarefa em andamento' };
135
+ }
136
+
79
137
  return { modo: 'chat', motivo: 'sem pista de tarefa' };
80
138
  }
81
139
 
package/lib/referencia.js CHANGED
@@ -29,6 +29,33 @@ const net = require('net');
29
29
  const tls = require('tls');
30
30
  const { URL } = require('url');
31
31
 
32
+ // Achado de segurança: redirect sem checar destino é SSRF — uma fonte de
33
+ // busca (conteúdo de terceiro, não confiável) pode responder com um Location
34
+ // pra localhost ou pro metadado de nuvem (169.254.169.254), e pegar() seguia
35
+ // sem checar host nenhum. Bloqueia loopback/link-local/rede privada antes de
36
+ // abrir a conexão, tanto na URL original quanto em cada redirect seguido.
37
+ function hostBloqueado(hostname) {
38
+ const h = String(hostname || '').toLowerCase();
39
+ if (h === 'localhost' || h.endsWith('.localhost')) return true;
40
+ const versao = net.isIP(h);
41
+ if (versao === 4) {
42
+ const partes = h.split('.').map(Number);
43
+ const [a, b] = partes;
44
+ if (a === 127 || a === 10 || a === 0) return true; // loopback, privada, "esta rede"
45
+ if (a === 169 && b === 254) return true; // link-local — inclui metadado de nuvem
46
+ if (a === 172 && b >= 16 && b <= 31) return true; // privada
47
+ if (a === 192 && b === 168) return true; // privada
48
+ return false;
49
+ }
50
+ if (versao === 6) {
51
+ if (h === '::1' || h === '::') return true; // loopback / unspecified
52
+ if (h.startsWith('fc') || h.startsWith('fd')) return true; // unique local (fc00::/7)
53
+ if (h.startsWith('fe80:') || h.startsWith('fe8') || h.startsWith('fe9') || h.startsWith('fea') || h.startsWith('feb')) return true; // link-local
54
+ return false;
55
+ }
56
+ return false; // hostname comum (DNS) — não dá pra saber o IP sem resolver
57
+ }
58
+
32
59
  const TIMEOUT_MS = 12000;
33
60
  const MAX_POR_FONTE = 4;
34
61
  const MAX_TEXTO = 4500;
@@ -72,6 +99,7 @@ function pegar(url, { timeout = TIMEOUT_MS, saltos = 0 } = {}) {
72
99
  // 5 saltos cobre qualquer doc real; sem teto, um redirect em loop
73
100
  // (A→B→A→...) trava essa fonte — e a pesquisa inteira — para sempre.
74
101
  if (saltos > 5) return resolve(null);
102
+ if (hostBloqueado(alvo.hostname)) return resolve(null);
75
103
  const cliente = alvo.protocol === 'http:' ? http : https;
76
104
 
77
105
  const opcoes = {
package/lib/studio.js CHANGED
@@ -191,8 +191,43 @@ const atualizar = ({ id, titulo, doc }) => comStudio(async () => {
191
191
  return { ok: true, ...r };
192
192
  });
193
193
 
194
+ // ── Mídia: a imagem de fundo e a música do usuário ───────────────────────
195
+ // Sem isto o agente não tinha como pôr foto de fundo nem trilha própria: a
196
+ // API pede base64, e ele não ia montar data URI na mão dentro de um curl.
197
+ const TIPOS_MIDIA = {
198
+ '.png': 1, '.jpg': 1, '.jpeg': 1, '.gif': 1, '.webp': 1, '.svg': 1, '.avif': 1,
199
+ '.mp3': 1, '.m4a': 1, '.wav': 1, '.ogg': 1, '.aac': 1, '.flac': 1, '.opus': 1, '.weba': 1,
200
+ '.mp4': 1, '.webm': 1, '.mov': 1,
201
+ };
202
+
203
+ const midiaListar = () => comStudio(async () => ({ ok: true, midia: await pedir('GET', '/api/midia') }));
204
+
205
+ const midiaEnviar = ({ arquivo, nome }) => comStudio(async () => {
206
+ if (!arquivo) return { ok: false, error: 'Diga o caminho do arquivo (arquivo: "foto.jpg").' };
207
+ const caminho = path.resolve(arquivo);
208
+ if (!fs.existsSync(caminho)) return { ok: false, error: `Arquivo não existe: ${arquivo}` };
209
+
210
+ const ext = path.extname(caminho).toLowerCase();
211
+ if (!TIPOS_MIDIA[ext]) {
212
+ return { ok: false, error: `Formato não aceito: ${ext}`, aceitos: Object.keys(TIPOS_MIDIA).join(' ') };
213
+ }
214
+ const bytes = fs.statSync(caminho).size;
215
+ if (bytes > 60 * 1024 * 1024) return { ok: false, error: `Arquivo tem ${(bytes / 1048576).toFixed(1)} MB; o limite é 60 MB.` };
216
+
217
+ const tipo = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
218
+ '.webp': 'image/webp', '.svg': 'image/svg+xml', '.avif': 'image/avif', '.mp3': 'audio/mpeg',
219
+ '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.aac': 'audio/aac',
220
+ '.flac': 'audio/flac', '.opus': 'audio/opus', '.weba': 'audio/webm', '.mp4': 'video/mp4',
221
+ '.webm': 'video/webm', '.mov': 'video/quicktime' }[ext] || 'application/octet-stream';
222
+
223
+ const dados = `data:${tipo};base64,` + fs.readFileSync(caminho).toString('base64');
224
+ const r = await pedir('POST', '/api/midia', { nome: nome || path.basename(caminho), dados }, 120000);
225
+ return { ok: true, ...r, comoUsar: `Use "${r.url}" em fundo.imagem, em src de uma imagem, ou em trilha.src.` };
226
+ });
227
+
194
228
  module.exports = {
195
229
  BASE, PORTA, CASA,
196
230
  garantirNoAr, estaNoAr, prepararCasa, pythonCmd,
197
231
  manual, ferramentas, spec, listar, ler, criar, atualizar,
232
+ midiaListar, midiaEnviar,
198
233
  };
package/lib/tools.js CHANGED
@@ -10,7 +10,7 @@
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const os = require('os');
13
- const { exec } = require('child_process');
13
+ const { exec, execFile } = require('child_process');
14
14
  const browser = require('./browser');
15
15
  const desktop = require('./desktop');
16
16
  const referencia = require('./referencia');
@@ -165,19 +165,41 @@ async function runShell(command, timeout = 60000, cwd) {
165
165
  });
166
166
  }
167
167
 
168
- async function openInBrowser(target) {
168
+ // `target` vem do modelo (args.target) — que por sua vez pode ter lido de uma
169
+ // página não confiável (prompt injection via pesquisar_referencia/browser_read).
170
+ // execFile com array de argumentos nunca passa por shell nenhum: "target" vira
171
+ // UM argumento, não texto que um shell reinterpreta — sem isso, um valor tipo
172
+ // `https://x/"; touch /tmp/pwned; echo "` injetava comando (achado de segurança).
173
+ function openInBrowser(target) {
169
174
  const p = os.platform();
170
- const cmd = p === 'darwin' ? `open "${target}"` : p === 'win32' ? `start "" "${target}"` : `xdg-open "${target}"`;
171
- const r = await runShell(cmd);
172
- return r.ok ? { ok: true, opener: cmd.split(' ')[0] } : { ok: false, error: r.stderr || 'falhou ao abrir' };
175
+ const [cmd, args, opener] = p === 'darwin' ? ['open', [target], 'open']
176
+ : p === 'win32' ? ['cmd.exe', ['/c', 'start', '', target], 'start']
177
+ : ['xdg-open', [target], 'xdg-open'];
178
+ return new Promise((resolve) => {
179
+ execFile(cmd, args, { timeout: 10000 }, (error, stdout, stderr) => {
180
+ resolve(error ? { ok: false, error: (stderr || error.message || '').toString() } : { ok: true, opener });
181
+ });
182
+ });
173
183
  }
174
184
 
175
185
  async function runInNewTerminal(command, cwd) {
176
186
  const p = os.platform();
187
+ const pastaAlvo = cwd || '.';
188
+ // pastaAlvo normalmente é a pasta do projeto, mas pode vir de input do
189
+ // modelo — sem checar, um valor malicioso quebra pra fora do "cd" embutido
190
+ // no comando que abre o terminal (AppleScript/cmd/x-terminal-emulator),
191
+ // que reinterpreta a string inteira como shell (achado de segurança).
192
+ // Recusa em vez de tentar escapar certo em três dialetos de shell ao
193
+ // mesmo tempo (osascript, cmd.exe, sh) — "command" continua livre de
194
+ // propósito, é o próprio contrato desta tool (abrir terminal com um
195
+ // comando de shell de verdade).
196
+ if (/["'`;&|$\\]/.test(pastaAlvo)) {
197
+ return { ok: false, error: `Pasta com caractere não permitido: ${pastaAlvo}` };
198
+ }
177
199
  let cmd;
178
- if (p === 'darwin') cmd = `osascript -e 'tell application "Terminal" to do script "cd ${cwd || '.'} && ${command}"'`;
179
- else if (p === 'win32') cmd = `start cmd /k "cd /d ${cwd || '.'} && ${command}"`;
180
- else cmd = `x-terminal-emulator -e "bash -c 'cd ${cwd || '.'} && ${command}; exec bash'"`;
200
+ if (p === 'darwin') cmd = `osascript -e 'tell application "Terminal" to do script "cd ${pastaAlvo} && ${command}"'`;
201
+ else if (p === 'win32') cmd = `start cmd /k "cd /d ${pastaAlvo} && ${command}"`;
202
+ else cmd = `x-terminal-emulator -e "bash -c 'cd ${pastaAlvo} && ${command}; exec bash'"`;
181
203
  const r = await runShell(cmd);
182
204
  return r.ok ? { ok: true, opener: cmd.split(' ')[0] } : { ok: false, error: r.stderr || 'falhou' };
183
205
  }
@@ -476,6 +498,11 @@ async function despachar(name, args, ctx) {
476
498
  return studio.atualizar({ id: args.id, titulo: args.titulo, doc: args.doc });
477
499
  case 'studio_listar':
478
500
  return studio.listar();
501
+ case 'studio_midia':
502
+ // Sem arquivo, lista o que já foi enviado; com arquivo, envia.
503
+ return args.arquivo
504
+ ? studio.midiaEnviar({ arquivo: resolveInProject(args.arquivo, projectDir), nome: args.nome })
505
+ : studio.midiaListar();
479
506
 
480
507
  case 'desktop_double_click':
481
508
  return desktop.doubleClick(args.x, args.y);
package/lib/ui.js CHANGED
@@ -4,7 +4,6 @@
4
4
 
5
5
  'use strict';
6
6
 
7
- const readline = require('readline');
8
7
 
9
8
  // ── Paleta ───────────────────────────────────────────────────────────────
10
9
  // Vem do design system do produto (product-plan/design-system): primária cyan,
@@ -264,39 +263,6 @@ class Spinner {
264
263
  }
265
264
  }
266
265
 
267
- // Barra de progresso estilo Claude Code — silenciosa, elegante
268
- class ProgressBar {
269
- constructor(label = 'Instalando') {
270
- this.label = label;
271
- this.width = 30;
272
- this.progress = 0;
273
- this.timer = null;
274
- this.done = false;
275
- }
276
- start() {
277
- process.stdout.write(` ${c.muted(this.label)} [`);
278
- this.timer = setInterval(() => {
279
- if (this.done) return;
280
- this.progress = Math.min(this.progress + 1, this.width);
281
- const filled = '█'.repeat(this.progress);
282
- const empty = '░'.repeat(this.width - this.progress);
283
- process.stdout.write(`\r ${c.muted(this.label)} [${c.brand(filled)}${c.dim(empty)}] ${c.muted(Math.round((this.progress/this.width)*100) + '%')}`);
284
- }, 200);
285
- return this;
286
- }
287
- tick() {
288
- this.progress = Math.min(this.progress + 2, this.width);
289
- const filled = '█'.repeat(this.progress);
290
- const empty = '░'.repeat(this.width - this.progress);
291
- process.stdout.write(`\r ${c.muted(this.label)} [${c.brand(filled)}${c.dim(empty)}] ${c.muted(Math.round((this.progress/this.width)*100) + '%')}`);
292
- }
293
- stop() {
294
- this.done = true;
295
- if (this.timer) { clearInterval(this.timer); this.timer = null; }
296
- process.stdout.write(`\r ${c.muted(this.label)} [${c.ok('██████████ DONE ██████████')}] ${c.ok('100%')}\n`);
297
- }
298
- }
299
-
300
266
  function actionHeader(name, args) {
301
267
  const nice = {
302
268
  read_file:'Read', write_file:'Write', edit_file:'Edit', multi_edit:'MultiEdit',
@@ -422,38 +388,5 @@ function friendlyError(raw) {
422
388
  return clean.length > 200 ? clean.slice(0, 200) + '…' : clean || 'Algo deu errado.';
423
389
  }
424
390
 
425
- function toolLine(text) { return ` ${c.dim('⎿')} ${c.muted(text)}`; }
426
-
427
- function toolResult(result) {
428
- if (!result) return '';
429
- if (result.ok === false) return c.err(` ✗ ${result.error || 'failed'}`);
430
- if (result.created !== undefined) return c.ok(` ${result.created?'created':'updated'} · ${fmtBytes(result.bytes||0)}`);
431
- return '';
432
- }
433
-
434
- function fmtBytes(n) {
435
- if (n < 1024) return `${n} B`;
436
- if (n < 1024*1024) return `${(n/1024).toFixed(1)} KB`;
437
- return `${(n/(1024*1024)).toFixed(1)} MB`;
438
- }
439
-
440
- function renderMarkdown(text) {
441
- return text.replace(/\*\*([^*]+)\*\*/g,'$1').replace(/__([^_]+)__/g,'$1').replace(/`([^`]+)`/g,'$1').replace(/^#{1,6}\s+/gm,'');
442
- }
443
-
444
- // Pergunta Yes/No — aguarda resposta real do usuário
445
- function askYesNo(question) {
446
- return new Promise((resolve) => {
447
- const rl = readline.createInterface({
448
- input: process.stdin,
449
- output: process.stdout,
450
- });
451
- rl.question(`${c.brand('?')} ${question} ${c.muted('(Y/n)')} `, (answer) => {
452
- rl.close();
453
- const a = (answer || '').trim().toLowerCase();
454
- resolve(a === 'y' || a === 'yes' || a === '');
455
- });
456
- });
457
- }
458
391
 
459
- module.exports = { c, mascot, welcomeBox, Spinner, ProgressBar, actionHeader, toolLine, toolResult, fmtBytes, renderMarkdown, askYesNo, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
392
+ module.exports = { c, mascot, welcomeBox, Spinner, actionHeader, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.1.0",
3
+ "version": "8.2.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. 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": {
@@ -60,6 +60,39 @@ livre de direitos, ou uma das trilhas geradas acima.
60
60
 
61
61
  ---
62
62
 
63
+ ## Narração
64
+
65
+ A voz que fala por cima do vídeo. Vai **na cena**, e o estúdio narra sozinho
66
+ na hora de tocar — sem gravar nada, sem arquivo:
67
+
68
+ ```json
69
+ { "layout": "centro", "duracao": 6,
70
+ "narracao": "O texto que a voz vai falar nesta cena.",
71
+ "elementos": [ ... ] }
72
+ ```
73
+
74
+ Ajuste da voz no **documento** (opcional):
75
+
76
+ ```json
77
+ "voz": { "velocidade": 1, "tom": 1, "volume": 1, "lang": "pt-BR" }
78
+ ```
79
+
80
+ Regras que fazem diferença:
81
+
82
+ - **Escreva para o ouvido, não para o olho.** A narração NÃO repete o texto da
83
+ tela: a tela dá o título, a voz dá o argumento. Repetir os dois é o erro mais
84
+ comum, e soa a leitura de slide.
85
+ - **Caiba na duração.** Fale ~2,5 palavras por segundo: uma cena de 6s aguenta
86
+ ~15 palavras. Texto mais longo que a cena é cortado no meio.
87
+ - **Uma ideia por cena**, em uma ou duas frases curtas. Ponto final ajuda a voz
88
+ a respirar.
89
+ - Para o vídeo ficar mudo de propósito, é só não pôr `narracao` em cena nenhuma.
90
+
91
+ O usuário também pode gravar a própria voz pelo editor; aí a cena ganha
92
+ `narracaoAudio` com o arquivo, e ela tem prioridade sobre a voz sintetizada.
93
+
94
+ ---
95
+
63
96
  ## Transições
64
97
 
65
98
  Na cena, `transicao` define como ela **entra**:
package/studio/server.py CHANGED
@@ -395,6 +395,24 @@ class Handler(SimpleHTTPRequestHandler):
395
395
  except Exception:
396
396
  return {}
397
397
 
398
+ # Achado de segurança: POST/PUT/DELETE sem checagem de Origin é CSRF — uma
399
+ # página de terceiro (aberta no MESMO navegador, sem relação nenhuma com o
400
+ # Studio) consegue disparar um fetch() com Content-Type: text/plain (que
401
+ # não exige preflight de CORS) contra http://127.0.0.1:7777 e o pedido
402
+ # chega igual a um legítimo. Bloqueia quando o Origin existe e não é o
403
+ # próprio servidor — sem Origin nenhum (cliente não-navegador, como o CLI
404
+ # do PrimoCode via http nativo do Node) continua liberado, já que isso
405
+ # nunca é o vetor de um navegador sendo usado contra o usuário.
406
+ def origem_ok(self):
407
+ origem = self.headers.get("Origin")
408
+ if not origem:
409
+ return True
410
+ esperados = {
411
+ "http://127.0.0.1:%d" % PORTA,
412
+ "http://localhost:%d" % PORTA,
413
+ }
414
+ return origem in esperados
415
+
398
416
  # -------------------------------------------------------------- rotas --
399
417
 
400
418
  @seguro
@@ -502,6 +520,8 @@ class Handler(SimpleHTTPRequestHandler):
502
520
 
503
521
  @seguro
504
522
  def do_POST(self):
523
+ if not self.origem_ok():
524
+ return self.js({"erro": "origem não permitida"}, 403)
505
525
  rota = urlparse(self.path).path
506
526
  if rota == "/api/midia":
507
527
  corpo, status = guardar_midia(self.corpo())
@@ -527,6 +547,8 @@ class Handler(SimpleHTTPRequestHandler):
527
547
 
528
548
  @seguro
529
549
  def do_PUT(self):
550
+ if not self.origem_ok():
551
+ return self.js({"erro": "origem não permitida"}, 403)
530
552
  m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
531
553
  if not m:
532
554
  return self.js({"erro": "não encontrado"}, 404)
@@ -547,6 +569,8 @@ class Handler(SimpleHTTPRequestHandler):
547
569
 
548
570
  @seguro
549
571
  def do_DELETE(self):
572
+ if not self.origem_ok():
573
+ return self.js({"erro": "origem não permitida"}, 403)
550
574
  rota = urlparse(self.path).path
551
575
  m = re.fullmatch(r"/api/midia/([^/]+)", rota)
552
576
  if m:
@@ -65,6 +65,10 @@
65
65
  document.getElementById("btn-pdf").innerHTML = svgIcone("tela");
66
66
  document.getElementById("btn-baixar").innerHTML = svgIcone("baixar");
67
67
  </script>
68
+ <script src="/js/audio.js"></script>
69
+ <script src="/js/narracao.js"></script>
70
+ <script src="/js/midia.js"></script>
71
+ <script src="/js/exportar.js"></script>
68
72
  <script src="/js/editor.js"></script>
69
73
  </body>
70
74
  </html>
@@ -15,6 +15,8 @@
15
15
  <script src="/js/render.js"></script>
16
16
  <script src="/js/render-prosa.js"></script>
17
17
  <script src="/js/render-grade.js"></script>
18
+ <script src="/js/audio.js"></script>
19
+ <script src="/js/narracao.js"></script>
18
20
  <script src="/js/player.js"></script>
19
21
  </body>
20
22
  </html>