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.
- package/lib/boas-vindas.js +13 -5
- package/lib/catalogo.js +40 -1
- package/lib/entrada.js +40 -9
- package/lib/estudio/remotion.js +40 -2
- package/lib/fala.js +36 -5
- package/lib/markdown.js +376 -0
- package/lib/pasta.js +15 -4
- package/lib/tools.js +55 -6
- package/package.json +2 -2
- package/studio/pessoa.py +33 -1
- package/voz/primo_voz/__main__.py +71 -13
package/lib/boas-vindas.js
CHANGED
|
@@ -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
|
-
|
|
126
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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 -
|
|
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
|
|
113
|
-
out.push(
|
|
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
|
-
|
|
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(
|
|
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
|
|
161
|
-
cursorColuna: 1 +
|
|
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
|
|
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
|
-
|
|
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'));
|
package/lib/estudio/remotion.js
CHANGED
|
@@ -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
|
-
|
|
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) => {
|
|
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
|
|
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
|
-
|
|
129
|
-
|
|
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
|
}
|
package/lib/markdown.js
ADDED
|
@@ -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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
[
|
|
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
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
285
|
-
|
|
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:
|