primocode 9.0.0 → 9.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.
@@ -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'));
@@ -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
  }
@@ -0,0 +1,376 @@
1
+ /**
2
+ * markdown.js — a resposta do modelo, desenhada no terminal.
3
+ *
4
+ * ── POR QUE ISTO PRECISOU EXISTIR ────────────────────────────────────────
5
+ * O modelo escreve em Markdown; é o que ele sabe fazer, e é o que o prompt
6
+ * pede. Só que a resposta saía CRUA na tela: `**importante**` aparecia com os
7
+ * asteriscos, a tabela virava um monte de barras verticais desalinhadas, e o
8
+ * bloco de código vinha com as três crases à mostra. Quem lê recebe o material
9
+ * bruto e faz a formatação de cabeça — que é justamente o trabalho que a
10
+ * ferramenta deveria poupar.
11
+ *
12
+ * ── O QUE ESTE ARQUIVO NÃO É ─────────────────────────────────────────────
13
+ * Não é um parser de Markdown. Markdown completo tem aninhamento, HTML embutido
14
+ * e ambiguidade de sobra, e nada disso aparece numa resposta de terminal. Aqui
15
+ * é linha a linha, com três exceções que PRECISAM de bloco: a cerca de código,
16
+ * a tabela e a lista que continua na linha seguinte. O resto é uma passada de
17
+ * expressões regulares sobre o texto da linha.
18
+ *
19
+ * A vantagem prática é o FLUXO: como quase tudo se resolve por linha, a
20
+ * resposta pode ser desenhada enquanto chega, sem esperar terminar. Quem usa
21
+ * vê o texto aparecendo, e não uma tela parada que de repente enche.
22
+ *
23
+ * ── A REGRA DE OURO DO ALINHAMENTO ───────────────────────────────────────
24
+ * Código de cor NÃO ocupa coluna. Toda conta de largura (tabela, régua, quebra)
25
+ * usa `visibleWidth`, nunca `.length` — senão a tabela com uma célula colorida
26
+ * sai torta, e ninguém entende por quê.
27
+ */
28
+
29
+ 'use strict';
30
+
31
+ const { c, visibleWidth, stripAnsi } = require('./ui.js');
32
+
33
+ /* Zero dependências, então as cores que faltam no ui.js nascem aqui. O
34
+ itálico e o sublinhado não estão lá porque nada mais no programa usa. */
35
+ const ANSI = (n) => (t) => `\x1b[${n}m${t}\x1b[0m`;
36
+ const italico = ANSI(3);
37
+ const riscado = ANSI(9);
38
+ const inverso = ANSI(7);
39
+
40
+ // ── o que se reconhece numa linha ────────────────────────────────────────
41
+
42
+ const CERCA = /^\s{0,3}(```|~~~)\s*([a-zA-Z0-9+#._-]*)\s*$/;
43
+ const TITULO = /^\s{0,3}(#{1,6})\s+(.*)$/;
44
+ const MARCADOR = /^(\s*)([-*+])\s+(.*)$/;
45
+ const NUMERADA = /^(\s*)(\d{1,3})([.)])\s+(.*)$/;
46
+ const TAREFA = /^(\s*)[-*+]\s+\[([ xX])\]\s+(.*)$/;
47
+ const CITACAO = /^\s{0,3}>\s?(.*)$/;
48
+ const REGUA = /^\s{0,3}([-*_])\s*(?:\1\s*){2,}$/;
49
+ const LINHA_TABELA = /^\s*\|.*\|\s*$/;
50
+ const SEPARADOR_TABELA = /^\s*\|(?:\s*:?-{2,}:?\s*\|)+\s*$/;
51
+
52
+ /**
53
+ * As marcas DENTRO de uma linha: negrito, itálico, código, link, riscado.
54
+ *
55
+ * O código inline é resolvido PRIMEIRO e sai de cena: um `**` dentro de
56
+ * `código` é código, não negrito, e resolver na ordem errada come as marcas do
57
+ * próprio trecho literal. Os pedaços saem, o resto é formatado, e eles voltam.
58
+ */
59
+ function inline(texto) {
60
+ const guardados = [];
61
+ let t = String(texto)
62
+ .replace(/`([^`\n]+)`/g, (_, codigo) => {
63
+ guardados.push(inverso(' ' + codigo + ' '));
64
+ return `\u0000${guardados.length - 1}\u0000`;
65
+ });
66
+
67
+ t = t
68
+ // Link: o texto fica, o endereço vai junto em cinza — num terminal
69
+ // ninguém clica no texto, então esconder a URL é esconder a fonte.
70
+ .replace(/\[([^\]\n]+)\]\(([^)\s]+)[^)]*\)/g, (_, rotulo, url) => c.white(rotulo) + ' ' + c.dim(`(${url})`))
71
+ .replace(/(\*\*\*|___)([^\s].*?[^\s]|\S)\1/g, (_, __, x) => c.bold(italico(x)))
72
+ .replace(/(\*\*|__)([^\s].*?[^\s]|\S)\1/g, (_, __, x) => c.bold(c.white(x)))
73
+ .replace(/~~([^\s].*?[^\s]|\S)~~/g, (_, x) => riscado(x))
74
+ // Itálico com UM asterisco/sublinhado. O sublinhado só conta entre
75
+ // limites de palavra: nome_de_variavel_assim não é itálico, e tratá-lo
76
+ // como itálico embaralha todo texto sobre código.
77
+ .replace(/(^|[^*\w])\*([^\s*][^*]*?)\*(?![*\w])/g, (_, antes, x) => antes + italico(x))
78
+ .replace(/(^|[^_\w])_([^\s_][^_]*?)_(?![_\w])/g, (_, antes, x) => antes + italico(x));
79
+
80
+ return t.replace(/\u0000(\d+)\u0000/g, (_, i) => guardados[Number(i)]);
81
+ }
82
+
83
+ // ── blocos ───────────────────────────────────────────────────────────────
84
+
85
+ /* As palavras que ganham cor dentro de um bloco de código. Não é um
86
+ analisador de sintaxe: é a lista das palavras que aparecem em quase toda
87
+ linguagem, mais os literais. Colorir demais fica pior que não colorir, e
88
+ inventar um parser por linguagem é trabalho sem fim para ganho pequeno. */
89
+ const PALAVRAS = new RegExp('\\b(' + [
90
+ 'function', 'return', 'const', 'let', 'var', 'class', 'extends', 'new', 'this',
91
+ 'if', 'else', 'for', 'while', 'switch', 'case', 'break', 'continue', 'try', 'catch',
92
+ 'finally', 'throw', 'async', 'await', 'import', 'from', 'export', 'default',
93
+ 'def', 'elif', 'lambda', 'pass', 'with', 'as', 'in', 'is', 'not', 'and', 'or',
94
+ 'self', 'None', 'True', 'False', 'null', 'undefined', 'true', 'false',
95
+ 'public', 'private', 'static', 'void', 'int', 'string', 'bool', 'struct', 'type',
96
+ 'func', 'package', 'interface', 'require', 'module',
97
+ ].join('|') + ')\\b', 'g');
98
+
99
+ /* UMA passada só, com alternância — e isso não é preciosismo.
100
+ Na primeira versão eram três `replace` em sequência, e o terceiro (números)
101
+ pintava os dígitos DENTRO dos códigos de cor que os dois primeiros tinham
102
+ acabado de inserir: o "38;5;214" de um escape virava três números coloridos,
103
+ o escape se desmanchava e o terminal cuspia "[38;5;45m" como texto. Com uma
104
+ expressão só, cada pedaço da linha é consumido uma vez e nada pinta o que já
105
+ foi pintado. */
106
+ const CODIGO = new RegExp([
107
+ '(["\'`])(?:\\\\.|(?!\\1)[^\\\\])*\\1', // texto entre aspas
108
+ '(?://|#|--)[^\\n]*$', // comentário até o fim da linha
109
+ PALAVRAS.source, // palavra-chave
110
+ '\\b\\d+(?:\\.\\d+)?\\b', // número
111
+ ].join('|'), 'g');
112
+
113
+ function pintarCodigo(linha) {
114
+ return String(linha).replace(CODIGO, (achado) => {
115
+ if (/^["'`]/.test(achado)) return c.ok(achado);
116
+ if (/^(\/\/|#|--)/.test(achado)) return c.dim(achado);
117
+ if (/^\d/.test(achado)) return c.cyan(achado);
118
+ return c.magenta(achado);
119
+ });
120
+ }
121
+
122
+ /** O bloco de código, com a linguagem no canto e uma barra à esquerda. */
123
+ function blocoDeCodigo(linhas, linguagem, largura) {
124
+ const fora = [];
125
+ const barra = c.dim('│');
126
+ if (linguagem) fora.push(' ' + c.dim(linguagem));
127
+ for (const l of linhas) {
128
+ // Corta o que passa da largura em vez de deixar quebrar sozinho: uma
129
+ // linha de código quebrada no meio pela borda do terminal engana quem
130
+ // lê sobre onde ela termina.
131
+ const util = Math.max(20, largura - 4);
132
+ const corpo = visibleWidth(l) > util ? l.slice(0, util - 1) + '…' : l;
133
+ fora.push(' ' + barra + ' ' + pintarCodigo(corpo));
134
+ }
135
+ return fora;
136
+ }
137
+
138
+ /**
139
+ * A tabela, com as colunas alinhadas.
140
+ *
141
+ * A largura de cada coluna é a do conteúdo VISÍVEL mais largo — e por isso a
142
+ * conta é com `visibleWidth`: a célula em negrito tem código ANSI dentro, e
143
+ * medir por `.length` faz a coluna crescer sem motivo e desalinhar as outras.
144
+ */
145
+ function tabela(linhas, largura) {
146
+ const celulas = linhas.map((l) => l.trim().replace(/^\||\|$/g, '').split('|').map((x) => x.trim()));
147
+ const alinhamentos = [];
148
+ let corpo = celulas;
149
+ if (celulas.length > 1 && SEPARADOR_TABELA.test(linhas[1])) {
150
+ for (const s of celulas[1]) {
151
+ alinhamentos.push(/^:.*:$/.test(s) ? 'centro' : /:$/.test(s) ? 'direita' : 'esquerda');
152
+ }
153
+ corpo = [celulas[0], ...celulas.slice(2)];
154
+ }
155
+ const formatadas = corpo.map((linha) => linha.map(inline));
156
+ const colunas = Math.max(...formatadas.map((l) => l.length));
157
+ const larguras = [];
158
+ for (let i = 0; i < colunas; i++) {
159
+ larguras.push(Math.max(...formatadas.map((l) => visibleWidth(l[i] || ''))));
160
+ }
161
+
162
+ // Tabela mais larga que o terminal: encolhe a coluna maior até caber. É
163
+ // melhor que deixar a linha quebrar — quebrada, a tabela deixa de ser
164
+ // tabela e vira um monte de texto solto.
165
+ const gasto = () => larguras.reduce((s, w) => s + w + 3, 1);
166
+ while (gasto() > largura - 2 && Math.max(...larguras) > 6) {
167
+ larguras[larguras.indexOf(Math.max(...larguras))] -= 1;
168
+ }
169
+
170
+ const encaixar = (txt, w, alinhamento) => {
171
+ let t = txt;
172
+ if (visibleWidth(t) > w) {
173
+ // Corta contando só o que é visível, para não decepar um código de
174
+ // cor no meio e vazar o escape para a tela.
175
+ const cru = stripAnsi(t).slice(0, Math.max(1, w - 1)) + '…';
176
+ t = cru;
177
+ }
178
+ const falta = w - visibleWidth(t);
179
+ if (alinhamento === 'direita') return ' '.repeat(falta) + t;
180
+ if (alinhamento === 'centro') {
181
+ const e = Math.floor(falta / 2);
182
+ return ' '.repeat(e) + t + ' '.repeat(falta - e);
183
+ }
184
+ return t + ' '.repeat(falta);
185
+ };
186
+
187
+ const risco = (e, meio, d) => c.dim(e + larguras.map((w) => '─'.repeat(w + 2)).join(meio) + d);
188
+ const fora = [risco('╭', '┬', '╮')];
189
+ formatadas.forEach((linha, i) => {
190
+ const cel = larguras.map((w, j) => encaixar(
191
+ i === 0 ? c.bold(c.white(stripAnsi(linha[j] || ''))) : (linha[j] || ''),
192
+ w, alinhamentos[j] || 'esquerda'));
193
+ fora.push(c.dim('│') + ' ' + cel.join(' ' + c.dim('│') + ' ') + ' ' + c.dim('│'));
194
+ if (i === 0) fora.push(risco('├', '┼', '┤'));
195
+ });
196
+ fora.push(risco('╰', '┴', '╯'));
197
+ return fora;
198
+ }
199
+
200
+ /** Quebra um parágrafo na largura, sem partir palavra e sem contar cor. */
201
+ function dobrar(texto, largura, recuo = '') {
202
+ const palavras = String(texto).split(/\s+/).filter(Boolean);
203
+ if (!palavras.length) return [''];
204
+ const fora = [];
205
+ let linha = '';
206
+ for (const p of palavras) {
207
+ const teste = linha ? linha + ' ' + p : p;
208
+ if (visibleWidth(teste) + visibleWidth(recuo) > largura && linha) {
209
+ fora.push(linha);
210
+ linha = p;
211
+ } else {
212
+ linha = teste;
213
+ }
214
+ }
215
+ if (linha) fora.push(linha);
216
+ return fora;
217
+ }
218
+
219
+ /**
220
+ * O texto inteiro, pronto para a tela.
221
+ *
222
+ * @param {string} texto o Markdown
223
+ * @param {number} largura colunas úteis
224
+ * @returns {string[]} as linhas, já com cor
225
+ */
226
+ function renderizar(texto, largura = 80) {
227
+ const w = Math.max(24, Number(largura) || 80);
228
+ const linhas = String(texto == null ? '' : texto).split('\n');
229
+ const fora = [];
230
+
231
+ let i = 0;
232
+ while (i < linhas.length) {
233
+ const linha = linhas[i];
234
+
235
+ const cerca = linha.match(CERCA);
236
+ if (cerca) {
237
+ const fim = cerca[1];
238
+ const dentro = [];
239
+ i++;
240
+ while (i < linhas.length && !linhas[i].trim().startsWith(fim)) { dentro.push(linhas[i]); i++; }
241
+ i++; // pula a cerca de baixo
242
+ fora.push(...blocoDeCodigo(dentro, cerca[2], w));
243
+ continue;
244
+ }
245
+
246
+ if (LINHA_TABELA.test(linha)) {
247
+ const bloco = [];
248
+ while (i < linhas.length && LINHA_TABELA.test(linhas[i])) { bloco.push(linhas[i]); i++; }
249
+ // Uma linha só entre barras não é tabela: é texto com barra.
250
+ if (bloco.length >= 2) { fora.push(...tabela(bloco, w)); continue; }
251
+ fora.push(inline(bloco[0]));
252
+ continue;
253
+ }
254
+
255
+ if (REGUA.test(linha)) { fora.push(c.dim('─'.repeat(Math.min(w, 60)))); i++; continue; }
256
+
257
+ const titulo = linha.match(TITULO);
258
+ if (titulo) {
259
+ const nivel = titulo[1].length;
260
+ const texto1 = stripAnsi(inline(titulo[2]));
261
+ fora.push('');
262
+ fora.push(nivel === 1 ? c.bold(c.brand(texto1.toUpperCase())) : c.bold(c.white(texto1)));
263
+ if (nivel === 1) fora.push(c.dim('─'.repeat(Math.min(w, visibleWidth(texto1)))));
264
+ i++;
265
+ continue;
266
+ }
267
+
268
+ const tarefa = linha.match(TAREFA);
269
+ if (tarefa) {
270
+ const marca = /[xX]/.test(tarefa[2]) ? c.ok('✔') : c.dim('○');
271
+ fora.push(...dobrarItem(tarefa[1] + marca + ' ', inline(tarefa[3]), w));
272
+ i++;
273
+ continue;
274
+ }
275
+
276
+ const numerada = linha.match(NUMERADA);
277
+ if (numerada) {
278
+ fora.push(...dobrarItem(numerada[1] + c.brand(numerada[2] + '.') + ' ', inline(numerada[4]), w));
279
+ i++;
280
+ continue;
281
+ }
282
+
283
+ const marcador = linha.match(MARCADOR);
284
+ if (marcador) {
285
+ // O ponto acompanha o nível: subitem não pode ter a mesma marca do
286
+ // item de cima, ou a hierarquia some.
287
+ const nivel = Math.floor(marcador[1].length / 2);
288
+ const ponto = [c.brand('•'), c.brand('◦'), c.brand('▪')][Math.min(nivel, 2)];
289
+ fora.push(...dobrarItem(marcador[1] + ponto + ' ', inline(marcador[3]), w));
290
+ i++;
291
+ continue;
292
+ }
293
+
294
+ const citacao = linha.match(CITACAO);
295
+ if (citacao) {
296
+ for (const l of dobrar(inline(citacao[1]), w - 4)) fora.push(c.dim('▏ ') + c.muted(l));
297
+ i++;
298
+ continue;
299
+ }
300
+
301
+ if (!linha.trim()) { fora.push(''); i++; continue; }
302
+
303
+ for (const l of dobrar(inline(linha), w)) fora.push(l);
304
+ i++;
305
+ }
306
+
307
+ // Linha em branco no fim é espaço morto no terminal.
308
+ while (fora.length && !fora[fora.length - 1].trim()) fora.pop();
309
+ return fora;
310
+ }
311
+
312
+ /** Item de lista: a primeira linha leva a marca, as seguintes se alinham a ela. */
313
+ function dobrarItem(marca, conteudo, largura) {
314
+ const recuo = ' '.repeat(visibleWidth(marca));
315
+ const partes = dobrar(conteudo, largura - visibleWidth(marca));
316
+ return partes.map((p, i) => (i === 0 ? marca : recuo) + p);
317
+ }
318
+
319
+ /**
320
+ * O desenhador de FLUXO: recebe a resposta em pedaços e devolve o que já dá
321
+ * para mostrar.
322
+ *
323
+ * A resposta do chat chega em dezenas de pedaços, e esperar o fim para
324
+ * formatar deixaria a tela parada até tudo chegar. Quase tudo aqui se resolve
325
+ * por linha, então a linha é a unidade: fechou, sai formatada.
326
+ *
327
+ * As exceções são os blocos que só fazem sentido inteiros — a cerca de código
328
+ * e a tabela. Enquanto um deles está aberto, as linhas ficam retidas; elas
329
+ * saem juntas quando ele fecha. É o comportamento certo: meia tabela na tela
330
+ * é pior que a tabela um segundo depois.
331
+ */
332
+ function fluxo(largura = 80) {
333
+ let resto = '';
334
+ let retido = [];
335
+ let dentroDaCerca = false;
336
+ let fimDaCerca = '';
337
+
338
+ const soltar = () => {
339
+ if (!retido.length) return [];
340
+ const saida = renderizar(retido.join('\n'), largura);
341
+ retido = [];
342
+ return saida;
343
+ };
344
+
345
+ return {
346
+ /** Devolve as linhas prontas para escrever agora. */
347
+ pedaco(texto) {
348
+ resto += String(texto == null ? '' : texto);
349
+ const partes = resto.split('\n');
350
+ resto = partes.pop(); // a última fica para a próxima
351
+ const fora = [];
352
+ for (const linha of partes) {
353
+ const cerca = linha.match(CERCA);
354
+ if (dentroDaCerca) {
355
+ retido.push(linha);
356
+ if (linha.trim().startsWith(fimDaCerca)) { dentroDaCerca = false; fora.push(...soltar()); }
357
+ continue;
358
+ }
359
+ if (cerca) { dentroDaCerca = true; fimDaCerca = cerca[1]; retido.push(linha); continue; }
360
+ if (LINHA_TABELA.test(linha)) { retido.push(linha); continue; }
361
+ // Saiu da tabela: agora ela está inteira.
362
+ if (retido.length) fora.push(...soltar());
363
+ fora.push(...renderizar(linha, largura));
364
+ }
365
+ return fora;
366
+ },
367
+ /** O que sobrou quando a resposta terminou. */
368
+ fim() {
369
+ const pendente = [...retido, ...(resto ? [resto] : [])];
370
+ retido = []; resto = ''; dentroDaCerca = false;
371
+ return pendente.length ? renderizar(pendente.join('\n'), largura) : [];
372
+ },
373
+ };
374
+ }
375
+
376
+ module.exports = { renderizar, fluxo, inline, tabela, dobrar, pintarCodigo, blocoDeCodigo };
package/lib/pasta.js CHANGED
@@ -64,16 +64,27 @@ function detectarPasta(texto) {
64
64
  const s = String(texto || '');
65
65
  if (!s.trim()) return null;
66
66
 
67
- // 1. Caminho explícito — `~/...` ou absoluto com pelo menos dois segmentos.
68
- // Um `/algo` solto não conta: seria confundido com comando (`/dir`).
69
- const explicito = s.match(new RegExp(`(?:^|\\s)(~[${CARACTERES_DE_CAMINHO}]*|/[${CARACTERES_DE_CAMINHO}]+/[${CARACTERES_DE_CAMINHO}]*)`));
67
+ /* 1. Caminho explícito — `~/...`, absoluto unix com pelo menos dois
68
+ segmentos, ou o caminho do Windows com letra de unidade.
69
+ Um `/algo` solto não conta: seria confundido com comando (`/dir`).
70
+
71
+ O ramo do Windows (`C:\\Users\\joel\\loja`) não existia, e o dois-pontos
72
+ NÃO pode entrar em CARACTERES_DE_CAMINHO para resolver isso: ali ele
73
+ estragaria outros casamentos, como "veja isto: src/app", onde o
74
+ dois-pontos é pontuação da frase e não faz parte do caminho. Por isso
75
+ a unidade é um padrão à parte, ancorado na forma "letra + : + barra". */
76
+ const DRIVE_WIN = `[A-Za-z]:[\\\\/][${CARACTERES_DE_CAMINHO}]*`;
77
+ const explicito = s.match(new RegExp(`(?:^|\\s)(${DRIVE_WIN}|~[${CARACTERES_DE_CAMINHO}]*|/[${CARACTERES_DE_CAMINHO}]+/[${CARACTERES_DE_CAMINHO}]*)`));
70
78
  if (explicito) {
71
79
  const bruto = limpar(explicito[1]);
72
80
  if (bruto && !TEM_EXTENSAO.test(bruto)) return bruto;
73
81
  }
74
82
 
75
83
  // 2. Marca explícita de pasta.
76
- const marcado = s.match(new RegExp(`\\b(?:pasta|diret[óo]rio|dir)\\s+(?:d[eoa]s?\\s+|no\\s+|na\\s+|em\\s+)?["'\`]?([${CARACTERES_DE_CAMINHO}]+)["'\`]?`, 'i'));
84
+ // A unidade do Windows entra aqui também: sem ela, "abre a pasta
85
+ // C:\\Users\\joel\\loja" casava só o "C" e o agente ia trabalhar numa pasta
86
+ // chamada "C".
87
+ const marcado = s.match(new RegExp(`\\b(?:pasta|diret[óo]rio|dir)\\s+(?:d[eoa]s?\\s+|no\\s+|na\\s+|em\\s+)?["'\`]?(${DRIVE_WIN}|[${CARACTERES_DE_CAMINHO}]+)["'\`]?`, 'i'));
77
88
  if (marcado) {
78
89
  const bruto = limpar(marcado[1]);
79
90
  if (bruto && !TEM_EXTENSAO.test(bruto) && !NAO_E_NOME.has(bruto.toLowerCase())) return bruto;
package/lib/tools.js CHANGED
@@ -7,6 +7,8 @@
7
7
 
8
8
  'use strict';
9
9
 
10
+ const catalogo = require('./catalogo.js');
11
+
10
12
  const fs = require('fs');
11
13
  const path = require('path');
12
14
  const os = require('os');
@@ -193,14 +195,43 @@ async function runInNewTerminal(command, cwd) {
193
195
  // mesmo tempo (osascript, cmd.exe, sh) — "command" continua livre de
194
196
  // propósito, é o próprio contrato desta tool (abrir terminal com um
195
197
  // comando de shell de verdade).
196
- if (/["'`;&|$\\]/.test(pastaAlvo)) {
198
+ /* A CONTRABARRA NÃO PODE ENTRAR NESTA LISTA NO WINDOWS.
199
+ Ela estava, e com isso a tool NUNCA funcionou lá: a pasta de trabalho é
200
+ sempre C:\Users\<nome>\primocode\<projeto> (path.join no win32 usa "\"),
201
+ então TODO caminho casava e a resposta era "Pasta com caractere não
202
+ permitido" — culpando a pasta criada pelo próprio produto, antes de
203
+ tentar abrir terminal nenhum. "sobe o servidor" quebrava 100% das vezes.
204
+ A contrabarra só é perigosa onde ela é ESCAPE (sh, osascript); no cmd.exe
205
+ ela é separador de caminho e nada mais. */
206
+ const proibidos = p === 'win32' ? /["'`;&|%^<>\r\n]/ : /["'`;&|$\\\r\n]/;
207
+ if (proibidos.test(pastaAlvo)) {
197
208
  return { ok: false, error: `Pasta com caractere não permitido: ${pastaAlvo}` };
198
209
  }
199
210
  let cmd;
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'"`;
203
- const r = await runShell(cmd);
211
+ let pastaDoProcesso;
212
+ if (p === 'darwin') {
213
+ /* O Terminal.app é outro processo, já rodando: ele NÃO herda a pasta
214
+ daqui, então o `cd` tem de ir no script mesmo. O espaço é escapado
215
+ com contrabarra — "\\ " neste template vira "\ " dentro da string do
216
+ AppleScript, que é o escape que o shell entende. Sem isso, uma pasta
217
+ como ~/Documents/Meus Projetos virava dois argumentos e o cd falhava
218
+ calado: o terminal abria na pasta errada e o comando rodava no lugar
219
+ errado, que é pior que não abrir. */
220
+ cmd = `osascript -e 'tell application "Terminal" to do script "cd ${pastaAlvo.replace(/ /g, '\\\\ ')} && ${command}"'`;
221
+ } else if (p === 'win32') {
222
+ // Aspas em volta do caminho: "C:\Users\Joel Silva\..." é comum, e sem
223
+ // elas o cmd corta no espaço. A aspa dupla já está barrada acima, então
224
+ // não há como sair delas.
225
+ cmd = `start cmd /k "cd /d "${pastaAlvo}" && ${command}"`;
226
+ } else {
227
+ /* No Linux o terminal é filho DESTE processo e herda a pasta — então
228
+ o caminho não precisa entrar na string de shell nenhuma. É a solução
229
+ mais segura das três: o que não é interpolado não pode ser mal
230
+ escapado. */
231
+ cmd = `x-terminal-emulator -e "bash -c '${command}; exec bash'"`;
232
+ pastaDoProcesso = pastaAlvo;
233
+ }
234
+ const r = await runShell(cmd, 60000, pastaDoProcesso);
204
235
  return r.ok ? { ok: true, opener: cmd.split(' ')[0] } : { ok: false, error: r.stderr || 'falhou' };
205
236
  }
206
237
 
@@ -568,7 +599,25 @@ async function despachar(name, args, ctx) {
568
599
  });
569
600
 
570
601
  default:
571
- return { ok: false, error: `Tool não implementada: ${name}` };
602
+ /* O MODELO PRECISA CONSEGUIR SE CORRIGIR AQUI.
603
+ "Tool não implementada: studio_criar" não diz o que existe,
604
+ e o que acontecia era o agente desistir e responder ao
605
+ usuário que a ferramenta necessária "não está disponível".
606
+ Dizendo QUAIS existem — e a mais parecida — ele chama a
607
+ certa na volta seguinte, na mesma tarefa.
608
+
609
+ Isto é rede, não conserto: a causa é a lista do servidor
610
+ divergir do catálogo daqui, e ela se conserta lá. Mas a rede
611
+ é o que separa uma tarefa que se recupera de uma que morre
612
+ com o usuário esperando. */
613
+ return {
614
+ ok: false,
615
+ error: `A ferramenta "${name}" não existe.`,
616
+ parecidas: catalogo.parecidasCom(name),
617
+ comoResolver: 'chame uma das ferramentas que existem de verdade — as "parecidas" '
618
+ + 'acima são as de nome mais próximo. Não invente nome de ferramenta, e não '
619
+ + 'desista da tarefa por causa disto.',
620
+ };
572
621
  }
573
622
  }
574
623
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.0.0",
3
+ "version": "9.1.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/skills.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/pareamento.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/skills.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/pareamento.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
package/studio/pessoa.py CHANGED
@@ -36,10 +36,30 @@ Uso:
36
36
 
37
37
  import argparse
38
38
  import json
39
+ import shutil
40
+ import os
39
41
  import subprocess
40
42
  import sys
41
43
 
42
44
 
45
+ def achar_ffmpeg():
46
+ """Onde está o ffmpeg — pelo PATH, e nos lugares onde ele costuma cair.
47
+
48
+ O PATH de um processo aberto pela interface gráfica não é o PATH do
49
+ terminal: no macOS um app aberto pelo ícone não enxerga /opt/homebrew/bin,
50
+ onde o Homebrew instala. Procurar nos lugares conhecidos evita um "não
51
+ achei" para quem tem o programa instalado.
52
+ """
53
+ achado = shutil.which("ffmpeg")
54
+ if achado:
55
+ return achado
56
+ for c in ("/opt/homebrew/bin/ffmpeg", "/usr/local/bin/ffmpeg", "/usr/bin/ffmpeg",
57
+ r"C:\Program Files\ffmpeg\bin\ffmpeg.exe"):
58
+ if os.path.exists(c):
59
+ return c
60
+ return None
61
+
62
+
43
63
  def faltando():
44
64
  """O que precisa estar instalado, e não está."""
45
65
  falta = []
@@ -76,8 +96,20 @@ def recortar(entrada, saida, suavizar=7, encolher=2, limiar=0.6):
76
96
 
77
97
  # VP9 num .webm é o formato de vídeo com transparência que o Chromium do
78
98
  # Remotion abre — e é o Chromium dele que vai desenhar isto.
99
+ # O ffmpeg pelo nome cru estoura FileNotFoundError, e o que chega ao
100
+ # usuário é o traceback do Python — que não diz o que instalar. Pior: aqui
101
+ # o vídeo já foi ABERTO e a segmentação está prestes a começar, então a
102
+ # falha aparece depois da espera, não antes dela.
103
+ binario = achar_ffmpeg()
104
+ if not binario:
105
+ captura.release()
106
+ return {"ok": False,
107
+ "error": "o recorte de pessoa precisa do ffmpeg, e não achei nenhum nesta máquina.",
108
+ "comoResolver": "macOS: brew install ffmpeg · Linux: sudo apt install ffmpeg · "
109
+ "Windows: winget install ffmpeg"}
110
+
79
111
  escritor = subprocess.Popen(
80
- ["ffmpeg", "-hide_banner", "-loglevel", "error", "-y",
112
+ [binario, "-hide_banner", "-loglevel", "error", "-y",
81
113
  "-f", "rawvideo", "-pix_fmt", "bgra",
82
114
  "-s", f"{largura}x{altura}", "-r", str(fps), "-i", "-",
83
115
  "-c:v", "libvpx-vp9", "-pix_fmt", "yuva420p", "-b:v", "3M",
@@ -65,6 +65,44 @@ def avisar(texto: str) -> None:
65
65
  sys.stderr.flush()
66
66
 
67
67
 
68
+ # O PowerShell que toca MP3 no Windows. MediaPlayer é assíncrono: Play()
69
+ # devolve na hora, então é preciso esperar a duração — e esperar com teto, ou
70
+ # um Open que falha deixaria a thread da fala pendurada para sempre.
71
+ def motivo_da_narracao(r) -> str:
72
+ """O que o narrar.py disse que houve — em vez do código de saída.
73
+
74
+ Ele responde JSON em stdout, com "error" e "comoResolver". Quando esse
75
+ JSON não vem (o processo morreu antes de escrever), sobra o stderr, que
76
+ ainda diz mais que "returned non-zero exit status 1".
77
+ """
78
+ try:
79
+ d = json.loads((r.stdout or b"").decode("utf-8", "replace"))
80
+ if isinstance(d, dict) and d.get("error"):
81
+ comoResolver = d.get("comoResolver") or ""
82
+ return "não deu para gerar a voz: " + str(d["error"]) + (
83
+ " — " + str(comoResolver) if comoResolver else "")
84
+ except Exception:
85
+ pass
86
+ erro = (r.stderr or b"").decode("utf-8", "replace").strip()
87
+ ultima = erro.splitlines()[-1] if erro else ""
88
+ return "não deu para gerar a voz" + (f": {ultima[:300]}" if ultima else
89
+ f" (o narrar.py saiu com {r.returncode})")
90
+
91
+
92
+ PS_TOCAR_MP3 = (
93
+ "Add-Type -AssemblyName PresentationCore; "
94
+ "$p = New-Object System.Windows.Media.MediaPlayer; "
95
+ "$p.Open([uri]'{arquivo}'); "
96
+ "$n = 0; "
97
+ "while (-not $p.NaturalDuration.HasTimeSpan -and $n -lt 100) "
98
+ "{{ Start-Sleep -Milliseconds 50; $n++ }}; "
99
+ "if (-not $p.NaturalDuration.HasTimeSpan) {{ exit 1 }}; "
100
+ "$p.Play(); "
101
+ "Start-Sleep -Seconds $p.NaturalDuration.TimeSpan.TotalSeconds; "
102
+ "$p.Close()"
103
+ )
104
+
105
+
68
106
  class Voz:
69
107
  """A costura: microfone → transcrição → Node → voz → microfone."""
70
108
 
@@ -227,13 +265,17 @@ class Voz:
227
265
 
228
266
  mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
229
267
  narrar = AQUI.parent.parent / "studio" / "narrar.py"
230
- try:
231
- subprocess.run(
232
- [sys.executable, str(narrar), "falar", "--texto", limpo,
233
- "--saida", str(mp3), "--voz", self.voz],
234
- capture_output=True, timeout=180, check=True)
235
- except Exception as e:
236
- avisar(f"não deu para gerar a voz: {e}")
268
+ # O narrar.py IMPRIME um JSON dizendo o que faltou e como resolver
269
+ # ("instale o edge-tts com… "). Com check=True isso virava uma
270
+ # CalledProcessError cujo texto é "Command '[...]' returned non-zero
271
+ # exit status 1" — e era ISSO que chegava ao usuário, com o conselho
272
+ # útil jogado fora dentro do capture_output. Agora a saída é lida.
273
+ r = subprocess.run(
274
+ [sys.executable, str(narrar), "falar", "--texto", limpo,
275
+ "--saida", str(mp3), "--voz", self.voz],
276
+ capture_output=True, timeout=180)
277
+ if r.returncode != 0 or not mp3.exists():
278
+ avisar(motivo_da_narracao(r))
237
279
  return
238
280
 
239
281
  self._falando = True
@@ -260,8 +302,15 @@ class Voz:
260
302
  if sys.platform == "darwin":
261
303
  tentativas.append(["afplay", str(arquivo)])
262
304
  elif sys.platform == "win32":
263
- tentativas.append(["powershell", "-c",
264
- f"(New-Object Media.SoundPlayer '{arquivo}').PlaySync()"])
305
+ # Media.SoundPlayer NÃO toca MP3 — ele só lê WAV, e o que o
306
+ # narrar.py entrega é MP3. Era a primeira tentativa da fila, e
307
+ # levantava exceção sempre; como o laço abaixo aceitava qualquer
308
+ # processo que tivesse CONSEGUIDO INICIAR, ele dava por tocado e
309
+ # voltava. Resultado: o modo voz era MUDO no Windows, sem nenhum
310
+ # aviso — a orb piscava "falando" e não saía som nenhum.
311
+ # MediaPlayer decodifica MP3; a espera é limitada para não travar
312
+ # a thread se o Open falhar.
313
+ tentativas.append(["powershell", "-NoProfile", "-Command", PS_TOCAR_MP3.format(arquivo=arquivo)])
265
314
  tentativas += [
266
315
  ["ffplay", "-nodisp", "-autoexit", "-loglevel", "quiet", str(arquivo)],
267
316
  ["mpg123", "-q", str(arquivo)],
@@ -271,7 +320,7 @@ class Voz:
271
320
  for cmd in tentativas:
272
321
  try:
273
322
  p = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
274
- except FileNotFoundError:
323
+ except (FileNotFoundError, OSError):
275
324
  continue
276
325
  while p.poll() is None:
277
326
  if self._parar_fala.is_set() or not self._vivo:
@@ -279,10 +328,19 @@ class Voz:
279
328
  p.terminate()
280
329
  except Exception:
281
330
  pass
282
- return
331
+ return # calado de propósito: falaram por cima
283
332
  time.sleep(0.05)
284
- return
285
- avisar("não achei um tocador de áudio (ffplay, mpg123, mpv ou afplay).")
333
+ if p.returncode == 0:
334
+ return
335
+ # INICIAR não é TOCAR. O tocador existia e falhou (formato que ele
336
+ # não lê, dispositivo de áudio ocupado): a fila continua. Sair aqui
337
+ # era o que transformava "o primeiro tocador não serve" em silêncio
338
+ # absoluto, com quatro alternativas por tentar logo abaixo.
339
+ continue
340
+ avisar("não achei um tocador de áudio que funcione neste computador."
341
+ + (" Instale o ffmpeg: winget install ffmpeg" if sys.platform == "win32"
342
+ else " Instale o ffmpeg: brew install ffmpeg" if sys.platform == "darwin"
343
+ else " Instale o ffmpeg: sudo apt install ffmpeg"))
286
344
 
287
345
  # ── ciclo de vida ────────────────────────────────────────────────────
288
346
  def parar(self) -> None: