primocode 9.0.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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/lib/voz.js CHANGED
@@ -169,10 +169,13 @@ function instalar(py, pacotes, aoFalar = () => {}) {
169
169
  * @param {string} op.voz
170
170
  * @param {(texto:string)=>Promise<string>} op.pensar o agente de verdade
171
171
  * @param {(texto:string)=>void} op.mostrar o que aparece no terminal
172
+ * @param {boolean} op.esperando fica dormindo até ouvir o nome
173
+ * @returns {{processo, pronto: Promise}} o processo, e a promessa do fim
172
174
  */
173
- function abrir({ python: py, servidor, token, voz, pensar, mostrar = () => {} }) {
174
- return new Promise((resolve) => {
175
- const p = spawn(py, ['-m', 'primo_voz'], {
175
+ function abrir({ python: py, servidor, token, voz, pensar, mostrar = () => {}, esperando = false }) {
176
+ let p;
177
+ const pronto = new Promise((resolve) => {
178
+ p = spawn(py, ['-m', 'primo_voz'], {
176
179
  cwd: PASTA,
177
180
  env: {
178
181
  ...process.env,
@@ -180,6 +183,8 @@ function abrir({ python: py, servidor, token, voz, pensar, mostrar = () => {} })
180
183
  PRIMOCODE_SERVER: servidor,
181
184
  PRIMOCODE_TOKEN: token || '',
182
185
  PRIMOCODE_VOZ: voz || 'antonio',
186
+ // Dormir até ouvir o nome, em vez de já entrar ouvindo.
187
+ PRIMOCODE_VOZ_ESPERA: esperando ? '1' : '',
183
188
  PYTHONUNBUFFERED: '1',
184
189
  },
185
190
  });
@@ -234,6 +239,101 @@ function abrir({ python: py, servidor, token, voz, pensar, mostrar = () => {} })
234
239
  p.on('close', () => resolve({ ok: true }));
235
240
  p.on('error', (e) => resolve({ ok: false, error: e.message }));
236
241
  });
242
+
243
+ /* O PROCESSO VOLTA JUNTO COM A PROMESSA, e não é preciosismo de API.
244
+ No modo de espera o /voice não bloqueia o terminal: ele sobe o ouvinte
245
+ e devolve o prompt. Quem chamou precisa de um jeito de ENCERRAR aquilo
246
+ depois (/voice parar, ou a saída do programa) — e, sem o handle, o
247
+ único jeito seria matar pelo nome do processo, que atropela outra
248
+ sessão do PrimoCode aberta na mesma máquina. */
249
+ return { processo: p, pronto };
250
+ }
251
+
252
+
253
+ /* ── O RECONHECEDOR LOCAL DO NOME ────────────────────────────────────────
254
+ *
255
+ * Duas funções, e a razão de existirem é a mesma: no modo de espera o
256
+ * microfone fica ABERTO o tempo todo, e há dois jeitos de saber se alguém
257
+ * disse "ei, primo" — mandar tudo o que ele ouve para um servidor, ou
258
+ * reconhecer a palavra aqui dentro.
259
+ *
260
+ * O primeiro é mais fácil de escrever e significa a conversa da casa inteira
261
+ * subindo para a rede o dia inteiro. O segundo custa ~31 MB de modelo, uma
262
+ * vez. Não é escolha difícil, e não é escolha que o programa faça sozinho:
263
+ * `conferirEspera` só RESPONDE o que falta; quem pergunta à pessoa é o CLI.
264
+ */
265
+ function conferirEspera(py) {
266
+ const perguntar = `
267
+ import json, sys
268
+ sys.path.insert(0, ${JSON.stringify(PASTA)})
269
+ try:
270
+ from primo_voz import desperta
271
+ except Exception as e:
272
+ print(json.dumps({"ok": False, "error": "não consegui ler o módulo: %s" % e})); raise SystemExit
273
+ try:
274
+ import vosk # noqa: F401
275
+ temVosk = True
276
+ except ImportError:
277
+ temVosk = False
278
+ print(json.dumps({
279
+ "ok": bool(temVosk and desperta.tem_modelo()),
280
+ "temVosk": temVosk,
281
+ "temModelo": desperta.tem_modelo(),
282
+ "onde": str(desperta.caminho_do_modelo()),
283
+ }))
284
+ `;
285
+ try {
286
+ const saida = execFileSync(py, ['-c', perguntar],
287
+ { stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 }).toString();
288
+ const r = JSON.parse(saida.trim().split('\n').pop());
289
+ if (r.ok) return { ok: true };
290
+ // Falta só o modelo (ou o vosk, que o pip resolve): dá para oferecer.
291
+ return { ok: false, baixavel: true, temVosk: r.temVosk, onde: r.onde,
292
+ error: r.error || 'o reconhecedor local do nome não está instalado' };
293
+ } catch (e) {
294
+ return { ok: false, baixavel: false,
295
+ error: `não consegui conferir o reconhecedor: ${String(e.message || e).slice(-160)}` };
296
+ }
297
+ }
298
+
299
+ /** Instala o vosk (se faltar) e baixa o modelo. Uma vez, ~31 MB. */
300
+ function baixarEspera(py) {
301
+ return new Promise((resolve) => {
302
+ const script = `
303
+ import json, subprocess, sys
304
+ sys.path.insert(0, ${JSON.stringify(PASTA)})
305
+ try:
306
+ import vosk # noqa: F401
307
+ except ImportError:
308
+ # As mesmas estratégias do narrar.py: o pip do sistema recusa instalar
309
+ # em Debian/Ubuntu e no Homebrew (PEP 668), e uma tentativa só, calada,
310
+ # é a falha que ninguém vê.
311
+ for args in (["-m","pip","install","--user","--quiet","vosk"],
312
+ ["-m","pip","install","--user","--quiet","--break-system-packages","vosk"],
313
+ ["-m","pip","install","--quiet","vosk"],
314
+ ["-m","pip","install","--quiet","--break-system-packages","vosk"]):
315
+ r = subprocess.run([sys.executable] + args, capture_output=True)
316
+ if r.returncode == 0:
317
+ break
318
+ try:
319
+ import vosk # noqa: F401
320
+ except ImportError as e:
321
+ print(json.dumps({"ok": False, "error": "não consegui instalar o vosk: %s" % e}))
322
+ raise SystemExit
323
+ from primo_voz import desperta
324
+ print(json.dumps(desperta.baixar_modelo()))
325
+ `;
326
+ const p = spawn(py, ['-c', script]);
327
+ let fora = '';
328
+ let erro = '';
329
+ p.stdout.on('data', (d) => { fora += d.toString(); });
330
+ p.stderr.on('data', (d) => { erro += d.toString(); });
331
+ p.on('close', () => {
332
+ try { resolve(JSON.parse(fora.trim().split('\n').pop())); }
333
+ catch { resolve({ ok: false, error: erro.slice(-300).trim() || 'o download não devolveu resposta' }); }
334
+ });
335
+ p.on('error', (e) => resolve({ ok: false, error: e.message }));
336
+ });
237
337
  }
238
338
 
239
- module.exports = { conferir, instalar, abrir, python, COMO_FALAR, PASTA };
339
+ module.exports = { conferir, instalar, abrir, conferirEspera, baixarEspera, python, COMO_FALAR, PASTA };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.0.0",
3
+ "version": "9.2.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 && node test/coerencia.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"