primocode 9.0.0 → 9.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 v9.0.0
1
+ # PrimoCode v9.2.0
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v9.0.0
12
+ ▐▛███▜▌ Primo Code v9.2.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -33,25 +33,19 @@ Em uma frase: **é o poder de um agente de código no terminal, em português, c
33
33
 
34
34
  ## Instalação
35
35
 
36
- **macOS / Linux / WSL** — um comando, e ele resolve tudo (inclusive instalar o Node, se faltar):
37
-
38
- ```bash
39
- curl -fsSL https://raw.githubusercontent.com/ConectaPrimoAI/primocode/main/install.sh | bash
40
- ```
41
-
42
- **Windows** — baixe o repositório e rode:
43
-
44
- ```cmd
45
- install.cmd
46
- ```
47
-
48
- Já tem Node 18.17+? Então basta:
49
-
50
36
  ```bash
51
37
  npm install -g primocode
52
38
  ```
53
39
 
54
- Requer **Node.js 18.17+**. O CLI não tem dependências — só a stdlib do Node, por isso instala em segundos.
40
+ Requer **Node.js 18.17+**. O CLI não tem dependência nenhuma — só a stdlib do
41
+ Node — e por isso instala em segundos. Depois é só `primocode`: ele abre o
42
+ login no navegador e, voltando, está pronto.
43
+
44
+ Não tem Node? Instale em [nodejs.org](https://nodejs.org) e repita o comando
45
+ acima. Quem já clonou este repositório pode usar os instaladores que moram
46
+ aqui (`install.sh` no macOS/Linux/WSL, `install.cmd` no Windows): eles detectam
47
+ o Node, oferecem instalar, e apontam o npm para `~/.npm-global` em vez da pasta
48
+ do sistema — que é o que evita o `EACCES` da tabela abaixo.
55
49
 
56
50
  ### Se der erro
57
51
 
package/bin/primocode.js CHANGED
@@ -151,7 +151,8 @@ const SLASH_COMANDOS = [
151
151
  ['/account', 'sua conta, plano e computadores conectados'],
152
152
  ['/chats', 'suas conversas salvas, as mesmas do app e do celular'],
153
153
  ['/logout', 'desconecta este computador da conta'],
154
- ['/voice', 'o Primo Code em modo voz: você fala, ele faz'],
154
+ ['/voice', 'fica ouvindo em segundo plano — diga "ei, primo" e a orb aparece'],
155
+ ['/voice parar', 'desliga o modo voz e fecha o microfone'],
155
156
  ['/skills', 'o que ele sabe fazer bem — ligue e desligue'],
156
157
  ['/marketplace', 'a tela das skills: navegue, ligue, crie'],
157
158
  ['/install-skill', 'instala uma skill de um arquivo'],
@@ -966,6 +967,11 @@ async function comprimirConversa(forcado = false) {
966
967
  // na tela depois de o usuário ter mandado parar.
967
968
  let chatEmVoo = null;
968
969
 
970
+ /* O ouvinte de voz em segundo plano.
971
+ Um por sessão: dois processos disputando o mesmo microfone dão um vazio
972
+ intermitente que parece defeito de hardware e não é. */
973
+ let vozEmEspera = null;
974
+
969
975
  async function runChat(promptForModel, userTextForMemory) {
970
976
  chatEmVoo = new AbortController();
971
977
  const sp = new Spinner(ui.PENSANDO).start();
@@ -1405,6 +1411,24 @@ async function handleSlash(text) {
1405
1411
  * garantia de que um deles está errado.
1406
1412
  */
1407
1413
  case 'voice': case 'voz': {
1414
+ /* ── /voice PARA ─────────────────────────────────────────────
1415
+ Encerrar é o primeiro caso tratado de propósito: quem quer
1416
+ desligar o microfone quer desligar AGORA, e não deve esbarrar
1417
+ em checagem de dependência nem em download de modelo. */
1418
+ if (/^(parar|stop|off|sair)$/i.test((rest || '').trim())) {
1419
+ if (!vozEmEspera) { console.log(c.muted(' O modo voz não está ligado.')); break; }
1420
+ try { vozEmEspera.kill(); } catch {}
1421
+ vozEmEspera = null;
1422
+ console.log(c.muted(' Modo voz desligado. O microfone fechou.'));
1423
+ break;
1424
+ }
1425
+
1426
+ if (vozEmEspera) {
1427
+ console.log(c.muted(' O modo voz já está ouvindo. Diga "ei, primo".'));
1428
+ console.log(c.dim(' /voice parar desliga.'));
1429
+ break;
1430
+ }
1431
+
1408
1432
  const pronto = vozModo.conferir();
1409
1433
  if (!pronto.ok && !pronto.instalavel) {
1410
1434
  console.log(c.err(` ${pronto.error}`));
@@ -1430,29 +1454,59 @@ async function handleSlash(text) {
1430
1454
  const conferido = vozModo.conferir();
1431
1455
  if (!conferido.ok) {
1432
1456
  console.log(c.err(` ainda falta: ${conferido.error}`));
1433
- if (conferido.comoResolver) console.log(c.muted(` ${conferido.comoResolver}`));
1434
1457
  break;
1435
1458
  }
1436
1459
  }
1437
1460
 
1438
1461
  const py = (pronto.ok ? pronto : vozModo.conferir()).python;
1462
+
1463
+ /* ── O RECONHECEDOR LOCAL ────────────────────────────────────
1464
+ Ele é o que permite o microfone ficar aberto sem o áudio sair
1465
+ da máquina: o nome é reconhecido AQUI, e só depois disso a
1466
+ frase viaja. São ~31 MB, uma vez, e ficam em ~/.primocode.
1467
+
1468
+ Se a pessoa recusar, o modo voz ainda abre — mas já ouvindo,
1469
+ sem espera. O que NÃO acontece em hipótese nenhuma é cair para
1470
+ "manda tudo para o servidor o dia inteiro": isso seria trocar
1471
+ a privacidade dela por conveniência sem perguntar. */
1472
+ const espera = await vozModo.conferirEspera(py);
1473
+ let esperando = espera.ok;
1474
+ if (!espera.ok && espera.baixavel) {
1475
+ console.log('');
1476
+ console.log(' ' + c.muted('Para ficar ouvindo só o nome, sem mandar áudio para lugar nenhum,'));
1477
+ console.log(' ' + c.muted('ele precisa do reconhecedor local — ') + c.dim('~31 MB, uma vez.'));
1478
+ if (await confirm('Baixar agora?')) {
1479
+ const sp = new Spinner('baixando o reconhecedor').start();
1480
+ const r = await vozModo.baixarEspera(py);
1481
+ sp.stop();
1482
+ if (r.ok) esperando = true;
1483
+ else {
1484
+ console.log(c.err(` não deu: ${r.error}`));
1485
+ if (r.comoResolver) console.log(c.dim(` ${r.comoResolver}`));
1486
+ }
1487
+ }
1488
+ } else if (!espera.ok && espera.error) {
1489
+ console.log(c.dim(` ${espera.error}`));
1490
+ }
1491
+
1439
1492
  console.log('');
1440
1493
  console.log(' ' + c.bold(c.brand('Primo Code Voice')));
1441
- console.log(' ' + c.muted('É só falar. A orb responde: ')
1442
- + c.dim('parada espera · pulsa ouvindo · respira pensando · brilha falando'));
1443
- console.log(' ' + c.dim('Falar por cima corta a resposta · Esc ou fechar a janela encerra'));
1494
+ if (esperando) {
1495
+ console.log(' ' + c.muted('Ouvindo em segundo plano. Diga ') + c.white('"ei, primo"') + c.muted(' e a orb aparece.'));
1496
+ console.log(' ' + c.dim('Nada sai da máquina até você chamar · /voice parar desliga'));
1497
+ } else {
1498
+ console.log(' ' + c.muted('É só falar. A orb responde: ')
1499
+ + c.dim('parada espera · pulsa ouvindo · respira pensando · brilha falando'));
1500
+ console.log(' ' + c.dim('Falar por cima corta a resposta · /voice parar desliga'));
1501
+ }
1444
1502
  console.log('');
1445
1503
 
1446
- // A caixa de digitar sai da frente: quem está no modo voz não
1447
- // está digitando, e uma caixa piscando embaixo é ruído.
1448
- if (rl && rl.suspender) rl.suspender();
1449
- sliderAtivo = true;
1450
-
1451
- const r = await vozModo.abrir({
1504
+ const sessao = vozModo.abrir({
1452
1505
  python: py,
1453
1506
  servidor: state.config.server,
1454
1507
  token: state.config.token,
1455
1508
  voz: state.config.voz || 'antonio',
1509
+ esperando,
1456
1510
  mostrar: (texto, deErro) => {
1457
1511
  if (deErro) console.log(' ' + c.dim(texto));
1458
1512
  else console.log(' ' + c.brand('❯ ') + c.white(texto));
@@ -1485,10 +1539,17 @@ async function handleSlash(text) {
1485
1539
  },
1486
1540
  });
1487
1541
 
1488
- sliderAtivo = false;
1489
- if (rl && rl.retomar) rl.retomar();
1490
- if (!r.ok) console.log(c.err(` o modo voz fechou: ${r.error}`));
1491
- else console.log(c.muted(' Modo voz encerrado.'));
1542
+ /* O TERMINAL CONTINUA SEU.
1543
+ Aqui NÃO se espera o fim. Era isso que obrigava a digitar
1544
+ /voice antes de cada pedido: o comando bloqueava o prompt, e a
1545
+ única forma de voltar a digitar era encerrar o modo voz. Agora
1546
+ ele vive ao lado — a pessoa fala quando quiser e digita quando
1547
+ quiser, sem desligar um para usar o outro. */
1548
+ vozEmEspera = sessao.processo;
1549
+ sessao.pronto.then((r) => {
1550
+ vozEmEspera = null;
1551
+ if (r && !r.ok) console.log(c.err(` o modo voz fechou: ${r.error}`));
1552
+ });
1492
1553
  break;
1493
1554
  }
1494
1555
 
@@ -1971,6 +2032,18 @@ async function main() {
1971
2032
  function sayBye() {
1972
2033
  if (saidBye) return;
1973
2034
  saidBye = true;
2035
+
2036
+ /* O MICROFONE FECHA JUNTO.
2037
+ O ouvinte de voz é um processo filho, e filho no POSIX não morre
2038
+ porque o pai saiu: ficaria um Python segurando o microfone depois
2039
+ de o terminal fechar — sem janela, sem jeito de desligar a não ser
2040
+ pelo gerenciador de tarefas, e com a luzinha da câmera do lado
2041
+ acesa. Matar aqui é o que faz "fechei o terminal" significar
2042
+ "fechei tudo". */
2043
+ if (vozEmEspera) {
2044
+ try { vozEmEspera.kill(); } catch { /* já morreu */ }
2045
+ vozEmEspera = null;
2046
+ }
1974
2047
  // O último turno pode ainda estar subindo — gravar é de propósito
1975
2048
  // solto, para não segurar o prompt. Fechar o terminal em cima dele
1976
2049
  // perderia justamente a resposta que a pessoa acabou de ler, e ela
package/lib/act.js CHANGED
@@ -539,7 +539,7 @@ async function runAct(userPrompt, opts) {
539
539
  * Num subagente não entra: ele já está fazendo uma parte de um plano, e
540
540
  * um diretor dentro do diretor é uma volta a mais para o mesmo lugar. */
541
541
  if (!subagent && profundidade === 0) {
542
- const producao = require('./diretor.js').precisaDeProducao(pedido);
542
+ const producao = require('./estudio/diretor.js').precisaDeProducao(pedido);
543
543
  if (producao.producao) {
544
544
  messages.push({ role: 'system', content:
545
545
  'ESTE PEDIDO É DE PRODUÇÃO AUDIOVISUAL. Antes de criar qualquer peça, chame '
@@ -1108,7 +1108,7 @@ async function rodarDiretor(args, ctx) {
1108
1108
  return { ok: false, error: 'Recusado: o diretor não roda dentro de um subagente. '
1109
1109
  + 'Produza com o plano que você já recebeu.' };
1110
1110
  }
1111
- const diretor = require('./diretor.js');
1111
+ const diretor = require('./estudio/diretor.js');
1112
1112
  const correr = async (prompt, rotulo) => {
1113
1113
  if (PARAR) return '';
1114
1114
  fala.agente('Diretor', rotulo, profundidade);
@@ -122,8 +122,12 @@ async function rodar({ perguntar, mostrar, cores: c, interativo = true,
122
122
  return { liberado: false, motivo: 'sem-plano', plano };
123
123
  }
124
124
 
125
- mostrar('');
126
- mostrar(c.ok(' Conta liberada.') + c.muted(` Plano ${plano}.`));
125
+ /* Nada de "Conta liberada." na abertura.
126
+ Estar logado é o estado NORMAL do produto — anunciar isso toda vez é
127
+ ruído, e ruído no primeiro instante empurra para baixo o que importa,
128
+ que é a caixa de digitar. O selo do plano continua existindo onde quem
129
+ procura vai olhar (/account, /status); a abertura só diz alguma coisa
130
+ quando há algo a resolver. */
127
131
 
128
132
  /* Não há etapa 3. O que vinha aqui era a escolha do provedor de IA, e
129
133
  ela morreu junto com o `/claude` e o `/gpt`: a API já vem com o
@@ -133,9 +137,13 @@ async function rodar({ perguntar, mostrar, cores: c, interativo = true,
133
137
  const primeira = !jaEscolheu();
134
138
  if (primeira) marcar({ plano });
135
139
 
136
- mostrar('');
137
- mostrar(c.muted(primeira ? ' Tudo pronto. Pode pedir a primeira coisa.'
138
- : ' Pode pedir.'));
140
+ /* Na primeira vez a frase serve: a pessoa acabou de instalar e não sabe
141
+ que já pode pedir. Da segunda em diante ela é só mais uma linha entre
142
+ a pessoa e o cursor. */
143
+ if (primeira) {
144
+ mostrar('');
145
+ mostrar(c.muted(' Tudo pronto. Pode pedir a primeira coisa.'));
146
+ }
139
147
  mostrar('');
140
148
  return { liberado: true, plano, primeira, perguntou: false };
141
149
  }
package/lib/catalogo.js CHANGED
@@ -206,4 +206,43 @@ function escreveArquivo(tool) {
206
206
  return ESCREVEM.has(tool);
207
207
  }
208
208
 
209
- module.exports = { GRUPOS, TODAS, ESCREVEM, escreveArquivo };
209
+
210
+ /**
211
+ * As ferramentas de nome mais parecido com o que foi chamado.
212
+ *
213
+ * Serve a um caso só, e ele é real: o modelo chamou uma tool que não existe.
214
+ * Isso acontece quando a lista que o servidor oferece diverge deste catálogo
215
+ * — foi o que houve quando o Estúdio trocou de vocabulário e o servidor
216
+ * continuou oferecendo `studio_criar` e `studio_listar`. Sem esta lista, a
217
+ * resposta ao modelo era "Tool não implementada", ele não tinha como saber o
218
+ * que existe, e dizia ao usuário que a ferramenta "não está disponível".
219
+ *
220
+ * A semelhança é por prefixo e por pedaço de nome, não por distância de
221
+ * edição: os erros reais são de FAMÍLIA ("studio_criar" quando o certo é
222
+ * "estudio_plano"), não de digitação.
223
+ */
224
+ function parecidasCom(nome) {
225
+ const alvo = String(nome || '').toLowerCase().replace(/^estudio_|^studio_/, '');
226
+ const familia = /^e?studio_/.test(String(nome || '').toLowerCase());
227
+ const pontos = TODAS.map((t) => {
228
+ let p = 0;
229
+ if (familia && /^estudio_|^planejar_/.test(t)) p += 3;
230
+ const semPrefixo = t.replace(/^estudio_/, '');
231
+ if (semPrefixo === alvo) p += 10;
232
+ else if (semPrefixo.includes(alvo) || alvo.includes(semPrefixo)) p += 5;
233
+ // pedaços em comum (criar/listar/plano/midia…)
234
+ for (const parte of alvo.split('_')) {
235
+ if (parte.length >= 4 && t.includes(parte)) p += 2;
236
+ }
237
+ return { t, p };
238
+ }).filter((x) => x.p > 0).sort((a, b) => b.p - a.p);
239
+ // Sem nada parecido, a resposta honesta é o grupo inteiro a que ele
240
+ // provavelmente queria chegar — melhor que uma lista vazia.
241
+ if (!pontos.length) {
242
+ return familia ? TODAS.filter((t) => /^estudio_|^planejar_/.test(t)) : TODAS.slice(0, 8);
243
+ }
244
+ return pontos.slice(0, 6).map((x) => x.t);
245
+ }
246
+
247
+ module.exports = {
248
+ parecidasCom, GRUPOS, TODAS, ESCREVEM, escreveArquivo };
package/lib/entrada.js CHANGED
@@ -93,7 +93,7 @@ function montar(e) {
93
93
  const colunas = Math.max(24, Math.min(e.colunas || 80, 200));
94
94
  const largura = colunas - 1; // a última coluna fica livre: escrever nela
95
95
  // faz o terminal quebrar a linha sozinho
96
- const w = Math.max(8, largura - 6); // │ + espaço + recuo(2) + … + espaço + │
96
+ const w = Math.max(8, largura - 3); // espaço + '❯ ' — e mais nada à direita
97
97
 
98
98
  const cursor = Math.max(0, Math.min(e.cursor | 0, String(e.texto).length));
99
99
  const linhas = quebrar(e.texto, w);
@@ -108,9 +108,16 @@ function montar(e) {
108
108
  if (cursor >= l.de && cursor <= fim) { cursorLinha = i; cursorColuna = cursor - l.de; }
109
109
  }
110
110
 
111
+ /* DUAS RÉGUAS, NÃO UMA CAIXA.
112
+ Era uma caixa fechada — cantos e barras laterais. O Claude Code, que é a
113
+ referência pedida, usa só a linha de cima e a de baixo, e a diferença
114
+ não é gosto: a barra lateral direita obriga a reservar duas colunas em
115
+ toda linha e a preencher o resto com espaço, e é ela que faz o texto
116
+ colado parecer desalinhado quando o terminal é estreito. Sem as laterais
117
+ o campo ganha as colunas de volta e a emenda some. */
111
118
  const out = [];
112
- const regua = c.dim(BORDA.h.repeat(largura - 2));
113
- out.push(c.dim(BORDA.ne) + regua + c.dim(BORDA.no));
119
+ const regua = c.dim(BORDA.h.repeat(largura));
120
+ out.push(regua);
114
121
 
115
122
  const vazio = !String(e.texto).length;
116
123
  linhas.forEach((l, i) => {
@@ -124,10 +131,12 @@ function montar(e) {
124
131
  conteudo += c.dim(e.fantasma);
125
132
  }
126
133
  if (i === 0 && vazio && e.vazio) conteudo = c.dim(e.vazio);
127
- out.push(c.dim(BORDA.v) + ' ' + marca + encher(conteudo, w) + ' ' + c.dim(BORDA.v));
134
+ // Sem `encher`: sem barra à direita não há nada para alinhar, e o
135
+ // espaço sobrando só apagaria o que estivesse embaixo.
136
+ out.push(' ' + marca + conteudo);
128
137
  });
129
138
 
130
- out.push(c.dim(BORDA.se) + regua + c.dim(BORDA.so));
139
+ out.push(regua);
131
140
 
132
141
  // ── o menu dos comandos ──────────────────────────────────────────────
133
142
  // Fica ABAIXO da caixa, não dentro: dentro, ele empurraria o campo de
@@ -157,8 +166,8 @@ function montar(e) {
157
166
 
158
167
  return {
159
168
  linhas: out,
160
- cursorLinha: 1 + cursorLinha, // +1 pela borda de cima
161
- cursorColuna: 1 + 1 + 2 + cursorColuna, // │ + espaço + recuo
169
+ cursorLinha: 1 + cursorLinha, // +1 pela régua de cima
170
+ cursorColuna: 1 + 2 + cursorColuna, // espaço + '❯ '
162
171
  };
163
172
  }
164
173
 
@@ -221,10 +230,27 @@ class Entrada extends EventEmitter {
221
230
  this._stdoutOriginal = process.stdout.write;
222
231
  const eu = this;
223
232
  process.stdout.write = function (texto, ...resto) {
224
- if (eu._pintando || !eu.desenhado) return eu._stdoutOriginal.call(this, texto, ...resto);
233
+ if (eu._pintando) return eu._stdoutOriginal.call(this, texto, ...resto);
225
234
  eu._apagar();
226
235
  const r = eu._stdoutOriginal.call(this, texto, ...resto);
227
- eu._desenhar();
236
+ /* ── O TEXTO QUE CHEGA EM PEDAÇOS ────────────────────────────
237
+ A resposta do chat sai em fluxo: dezenas de `write` pequenos,
238
+ nenhum deles terminando em quebra de linha. Cada um deixa o
239
+ cursor NO MEIO da linha — e o desenho da caixa começa com
240
+ "\r\x1b[0J", que volta ao início da linha e limpa dali para
241
+ baixo. Ou seja: redesenhar ali apagava o pedaço que acabara de
242
+ ser escrito, e o seguinte apagava o próximo.
243
+
244
+ O efeito para quem usa era o pior possível: no modo chat a
245
+ mensagem ia, o modelo respondia, e a tela não mostrava NADA —
246
+ parecia que o programa tinha ignorado a pessoa.
247
+
248
+ Então a caixa espera a linha fechar. Enquanto o cursor estiver
249
+ no meio de uma, ela não é redesenhada; quando a quebra de linha
250
+ chega (o `fechar()` do fluxo manda uma), ela volta. */
251
+ const t = String(texto == null ? '' : texto);
252
+ eu._meiaLinha = t.length > 0 && !t.endsWith('\n');
253
+ if (!eu._meiaLinha) eu._desenhar();
228
254
  return r;
229
255
  };
230
256
  return this;
@@ -311,6 +337,11 @@ class Entrada extends EventEmitter {
311
337
  if (!this.tty || this.suspenso || this.fechado) return;
312
338
  const q = this._quadro();
313
339
  this._pintando = true;
340
+ /* Cursor no meio de uma linha (fluxo que ainda não fechou): desce uma
341
+ antes de desenhar. Vale para quem chama `_desenhar` por fora do
342
+ embrulho do stdout — `prompt()`, o fim de uma tela suspensa — e é o
343
+ que garante que a caixa nunca começa em cima de texto pela metade. */
344
+ if (this._meiaLinha) { this.escreverCru('\n'); this._meiaLinha = false; }
314
345
  // `\x1b[0J` limpa daqui até o fim da tela: sem isso, um quadro mais
315
346
  // curto que o anterior (menu que fechou) deixa lixo embaixo.
316
347
  this.escreverCru('\r\x1b[0J' + q.linhas.join('\n'));
@@ -73,7 +73,7 @@ function buscarJSON(url, { timeout = 15000, cabecalhos = {} } = {}) {
73
73
  política deles pede. */
74
74
  function agente() {
75
75
  let v = '?';
76
- try { v = require('../package.json').version; } catch { /* sem package: tanto faz */ }
76
+ try { v = require('../../package.json').version; } catch { /* sem package: tanto faz */ }
77
77
  return `PrimoCode/${v} (https://github.com/parisgroup-ai/primocode)`;
78
78
  }
79
79
 
@@ -58,7 +58,7 @@ function pegar(url, { json = false, binario = false, cabecalhos = {} } = {}) {
58
58
  path: u.pathname + u.search,
59
59
  headers: {
60
60
  // O Openverse recusa quem não se identifica, e com razão.
61
- 'User-Agent': 'PrimoCode-Studio/1.0 (+https://github.com/ConectaPrimoAI/primocode)',
61
+ 'User-Agent': 'PrimoCode-Studio/1.0 (+https://www.npmjs.com/package/primocode)',
62
62
  Accept: json ? 'application/json' : '*/*',
63
63
  ...cabecalhos,
64
64
  },
@@ -36,8 +36,8 @@ const path = require('path');
36
36
  const https = require('https');
37
37
  const referencia = require('../referencia.js');
38
38
  const assets = require('./assets.js');
39
- const marca = require('../marca.js');
40
- const acervo = require('../acervo.js');
39
+ const marca = require('./marca.js');
40
+ const acervo = require('./acervo.js');
41
41
  const http = require('http');
42
42
  const { URL } = require('url');
43
43
 
@@ -33,7 +33,7 @@ const path = require('path');
33
33
  const { spawn } = require('child_process');
34
34
 
35
35
  const projeto = require('./projeto.js');
36
- const acervo = require('../acervo.js');
36
+ const acervo = require('./acervo.js');
37
37
  const plano = require('./plano.js');
38
38
  const remotion = require('./remotion.js');
39
39
  const assets = require('./assets.js');
@@ -33,7 +33,7 @@
33
33
 
34
34
  const fs = require('fs');
35
35
  const path = require('path');
36
- const referencia = require('./referencia.js');
36
+ const referencia = require('../referencia.js');
37
37
 
38
38
  const GENERICAS_FONTE = /^(sans-serif|serif|monospace|system-ui|-apple-system|blinkmacsystemfont|segoe ui|arial|helvetica( neue)?|roboto|inherit|initial|ui-sans-serif|ui-serif|ui-monospace|apple color emoji|segoe ui emoji|noto color emoji|times new roman|courier new|verdana|tahoma|georgia)$/i;
39
39
 
@@ -570,6 +570,20 @@ function fontesCSS(plano) {
570
570
  * `npm install` roda uma vez por projeto e demora minutos — é o preço do
571
571
  * Remotion, e está dito no README. Daí em diante, só o render.
572
572
  */
573
+ /**
574
+ * Cita um argumento para a linha de comando do cmd.exe.
575
+ *
576
+ * Só serve ao Windows, e só porque o `shell: true` é obrigatório ali (o Node
577
+ * recusa spawnar npm.cmd/npx.cmd sem ele desde a correção do CVE-2024-27980).
578
+ * Quem não tem espaço nem metacaractere passa intacto: citar tudo deixaria a
579
+ * linha ilegível no log sem ganhar nada.
580
+ */
581
+ function citarWin(a) {
582
+ const t = String(a);
583
+ if (!/[\s&|<>^"()]/.test(t)) return t;
584
+ return '"' + t.replace(/"/g, '""') + '"';
585
+ }
586
+
573
587
  function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
574
588
  return new Promise((resolve) => {
575
589
  const temNodeModules = fs.existsSync(path.join(raiz, 'node_modules'));
@@ -582,7 +596,17 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
582
596
  aoFalar(i === 0 && !temNodeModules
583
597
  ? 'instalando o Remotion nesta pasta (uma vez só, alguns minutos)'
584
598
  : 'renderizando quadro a quadro');
585
- const p = spawn(cmd, args, { cwd: raiz, shell: process.platform === 'win32' });
599
+ /* NO WINDOWS O `shell: true` NÃO CITA NADA.
600
+ Ele monta a linha do cmd.exe concatenando os argumentos crus, e
601
+ o `saida` é um caminho de arquivo que quase sempre tem espaço
602
+ (C:\Users\Joel Silva\primocode\...\saida\video.mp4). Sem
603
+ aspas, aquilo vira dois argumentos e o render falha — ou, pior,
604
+ escreve num lugar que ninguém pediu.
605
+ O `shell: true` não dá para tirar: desde a correção do
606
+ CVE-2024-27980 o Node recusa spawnar npm.cmd/npx.cmd sem shell.
607
+ Então as aspas entram na mão, só onde o shell existe. */
608
+ const citados = process.platform === 'win32' ? args.map(citarWin) : args;
609
+ const p = spawn(cmd, citados, { cwd: raiz, shell: process.platform === 'win32' });
586
610
  let erro = '';
587
611
  p.stderr.on('data', (d) => { erro += d.toString().slice(0, 2000); });
588
612
  p.stdout.on('data', (d) => {
@@ -597,7 +621,21 @@ function renderizar({ raiz, saida, minutos = 30, aoFalar = () => {} }) {
597
621
  }
598
622
  correr(i + 1);
599
623
  });
600
- p.on('error', (e) => { clearTimeout(relogio); resolve({ ok: false, error: e.message }); });
624
+ p.on('error', (e) => {
625
+ clearTimeout(relogio);
626
+ /* "spawn npx ENOENT" não diz nada a quem instalou pelo ícone.
627
+ Aplicativo aberto pela interface gráfica não herda o PATH do
628
+ terminal: o node vem por caminho absoluto (lib/instalar-app.js),
629
+ mas o npm e o npx não estão em lugar nenhum que ele enxergue. */
630
+ if (e.code === 'ENOENT') {
631
+ return resolve({ ok: false,
632
+ error: `não achei o "${cmd}" nesta máquina — o Remotion precisa dele para renderizar.`,
633
+ comoResolver: 'instale o Node.js completo em nodejs.org (o npm vem junto). '
634
+ + 'Se ele já está instalado e isto apareceu com o PrimoCode aberto pelo ícone, '
635
+ + 'abra pelo terminal: o aplicativo não herda o PATH.' });
636
+ }
637
+ resolve({ ok: false, error: e.message });
638
+ });
601
639
  };
602
640
  correr(0);
603
641
  });
package/lib/fala.js CHANGED
@@ -38,6 +38,7 @@
38
38
  'use strict';
39
39
 
40
40
  const { c, actionHeader, friendlyError } = require('./ui');
41
+ const markdown = require('./markdown.js');
41
42
 
42
43
  /* Até este tamanho, o texto que o modelo escreve antes de chamar uma tool é
43
44
  guardado e sai de uma vez, como pensamento. Passou disso sem tool à vista,
@@ -89,10 +90,18 @@ function formatar(texto, marca) {
89
90
  return linhas(texto).map((l, i) => (i === 0 ? `${marca} ${c.white(l)}` : ` ${c.white(l)}`)).join('\n');
90
91
  }
91
92
 
92
- /** A resposta final, pronta. Espelha o ❯ de quem perguntou. */
93
+ /** A resposta final, pronta. Espelha o ❯ de quem perguntou.
94
+ *
95
+ * Passa pelo desenhador de Markdown pelo mesmo motivo do fluxo: o modelo
96
+ * escreve com negrito, lista, tabela e bloco de código, e sem desenhar isso
97
+ * a resposta chega com a marcação à mostra. `formatar` continua servindo ao
98
+ * que NÃO é resposta do modelo (aviso, erro), que é texto nosso e já vem
99
+ * pronto. */
93
100
  function resposta(texto) {
94
101
  if (!String(texto || '').trim()) return '';
95
- const saida = '\n' + formatar(texto, c.brand('●'));
102
+ const largura = Math.max(40, (process.stdout.columns || 80) - 4);
103
+ const linhasMd = markdown.renderizar(texto, largura);
104
+ const saida = '\n' + linhasMd.map((l, i) => (i === 0 ? `${c.brand('●')} ${l}` : ` ${l}`)).join('\n');
96
105
  console.log(saida);
97
106
  return saida;
98
107
  }
@@ -120,15 +129,37 @@ const frase = friendlyError;
120
129
  * recuo, para o bloco continuar alinhado ao marcador.
121
130
  */
122
131
  function fluxo() {
132
+ /* A resposta do modelo vem em MARKDOWN — é o que ele escreve, e é o que o
133
+ prompt pede. Antes ela saía crua: "**importante**" aparecia com os
134
+ asteriscos, a tabela virava um monte de barras desalinhadas e o bloco de
135
+ código vinha com as três crases à mostra. Quem lia recebia o material
136
+ bruto e formatava de cabeça.
137
+
138
+ O desenho é por LINHA, não no fim: a resposta continua aparecendo
139
+ enquanto chega. Só a tabela e o bloco de código esperam fechar, porque
140
+ meia tabela na tela é pior que a tabela um segundo depois. */
123
141
  let escreveu = false;
142
+ const largura = Math.max(40, (process.stdout.columns || 80) - 4);
143
+ const desenho = markdown.fluxo(largura);
144
+
145
+ const sair = (linhas) => {
146
+ for (const linha of linhas) {
147
+ if (!escreveu) { process.stdout.write('\n' + c.brand('●') + ' '); escreveu = true; }
148
+ else process.stdout.write('\n ');
149
+ process.stdout.write(linha);
150
+ }
151
+ };
152
+
124
153
  return {
125
154
  escrever(delta) {
126
155
  const t = String(delta || '');
127
156
  if (!t) return;
128
- if (!escreveu) { process.stdout.write('\n' + c.brand('●') + ' '); escreveu = true; }
129
- process.stdout.write(c.white(t.replace(/\n/g, '\n ')));
157
+ sair(desenho.pedaco(t));
158
+ },
159
+ fechar() {
160
+ sair(desenho.fim());
161
+ if (escreveu) process.stdout.write('\n');
130
162
  },
131
- fechar() { if (escreveu) process.stdout.write('\n'); },
132
163
  escreveu: () => escreveu,
133
164
  };
134
165
  }