primocode 9.3.1 → 9.5.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/entrada.js CHANGED
@@ -218,6 +218,7 @@ class Entrada extends EventEmitter {
218
218
  this.ocupado = false;
219
219
  this.fechado = false;
220
220
  this.colando = false;
221
+ this.oculto = false; // a próxima resposta é senha: ver `ocultarProxima`
221
222
  this.tty = !!(process.stdin.isTTY && process.stdout.isTTY);
222
223
  this.escreverCru = process.stdout.write.bind(process.stdout);
223
224
  }
@@ -341,14 +342,21 @@ class Entrada extends EventEmitter {
341
342
 
342
343
  limpar() { this.line = ''; this.cursor = 0; this.menu = null; this._mudou(); }
343
344
 
345
+ /**
346
+ * A PRÓXIMA linha é senha: a caixa desenha `•` no lugar das letras, não
347
+ * repete o texto na tela ao enviar e não o guarda no histórico — senão a
348
+ * seta para cima devolveria a senha. Vale para uma linha só.
349
+ */
350
+ ocultarProxima() { this.oculto = true; this.menu = null; this._mudou(); }
351
+
344
352
  // ── desenho ──────────────────────────────────────────────────────────
345
353
 
346
354
  _quadro() {
347
355
  return montar({
348
- texto: this.line,
356
+ texto: this.oculto ? '•'.repeat(this.line.length) : this.line,
349
357
  cursor: this.cursor,
350
358
  colunas: process.stdout.columns || 80,
351
- fantasma: this._fantasma(),
359
+ fantasma: this.oculto ? '' : this._fantasma(),
352
360
  vazio: this.op.vazio || '',
353
361
  status: this.op.status ? this.op.status() : [],
354
362
  menu: this.menu,
@@ -408,6 +416,7 @@ class Entrada extends EventEmitter {
408
416
  }
409
417
 
410
418
  _filtrarMenu() {
419
+ if (this.oculto) { this.menu = null; return; }
411
420
  const lista = this.op.comandos ? this.op.comandos() : [];
412
421
  const baixa = this.line.toLowerCase();
413
422
  const itens = this.line.startsWith('/') && !/\s/.test(this.line)
@@ -575,6 +584,8 @@ class Entrada extends EventEmitter {
575
584
  _enviar() {
576
585
  if (this.menu) return this._escolherDoMenu();
577
586
  const texto = this.line;
587
+ const oculto = this.oculto;
588
+ this.oculto = false;
578
589
  this.line = '';
579
590
  this.cursor = 0;
580
591
  this.menu = null;
@@ -584,7 +595,7 @@ class Entrada extends EventEmitter {
584
595
  senão o pedido some no instante em que a resposta começa, e quem
585
596
  rola para cima não acha o que perguntou. */
586
597
  this._apagar();
587
- if (texto.trim()) {
598
+ if (texto.trim() && !oculto) {
588
599
  const marca = c.brand('❯ ');
589
600
  this.escreverCru(texto.split('\n').map((l, i) => (i ? ' ' : marca) + c.white(l)).join('\n') + '\n');
590
601
  if (this.historico[this.historico.length - 1] !== texto) this.historico.push(texto);
package/lib/voz.js CHANGED
@@ -237,6 +237,10 @@ function abrir({ python: py, servidor, token, voz, pensar, mostrar = () => {}, e
237
237
  // Dormir até ouvir o nome, em vez de já entrar ouvindo.
238
238
  PRIMOCODE_VOZ_ESPERA: esperando ? '1' : '',
239
239
  PYTHONUNBUFFERED: '1',
240
+ // O Whisper deixa um semáforo para o sistema recolher na saída,
241
+ // e o Python avisa disso no stderr — que aqui vira texto no
242
+ // terminal da pessoa. É inofensivo; o aviso é que não é.
243
+ PYTHONWARNINGS: 'ignore::UserWarning:multiprocessing.resource_tracker',
240
244
  },
241
245
  });
242
246
 
@@ -387,4 +391,169 @@ print(json.dumps(desperta.baixar_modelo()))
387
391
  });
388
392
  }
389
393
 
390
- module.exports = { conferir, instalar, porqueOPipFalhou, abrir, conferirEspera, baixarEspera, python, COMO_FALAR, PASTA };
394
+ /* ── PREPARAR TUDO, SEM PERGUNTAR ────────────────────────────────────────
395
+ *
396
+ * "Instale tudo que precisa, rode tudo que precisa, então fica redondinho,
397
+ * já funcionando." (23/09/2026)
398
+ *
399
+ * O /voice perguntava duas vezes ("Instalar agora?", "Baixar agora?") e
400
+ * parava em cada falta com um comando para a pessoa copiar. Agora ele mesmo
401
+ * resolve o que dá para resolver, nesta ordem:
402
+ *
403
+ * 1. bibliotecas do SISTEMA (PortAudio e o Tk do Python) — pelo Homebrew no
404
+ * macOS; no Linux, `sudo -n` (só funciona sem senha; senão, o comando);
405
+ * 2. pacotes do Python — os obrigatórios (numpy, sounddevice) e os que
406
+ * deixam tudo bonito e local (faster-whisper, vosk, PyObjC no macOS);
407
+ * 3. os modelos — o Whisper (transcrição local) e o reconhecedor do nome.
408
+ *
409
+ * Só o que é OBRIGATÓRIO faltar impede o modo voz de abrir. Faltar um opcional
410
+ * vira um aviso de uma linha, e o modo voz abre mesmo assim.
411
+ */
412
+ const OPCIONAIS = () => [
413
+ ['faster_whisper', 'faster-whisper', 'a transcrição local (Whisper)'],
414
+ ['vosk', 'vosk', 'o reconhecedor local do "ei, primo"'],
415
+ ...(process.platform === 'darwin' ? [['AppKit', 'pyobjc-framework-Cocoa', 'a orb transparente do macOS']] : []),
416
+ ];
417
+
418
+ function temModulo(py, modulo) {
419
+ try {
420
+ execFileSync(py, ['-c', `import ${modulo}`], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 });
421
+ return true;
422
+ } catch { return false; }
423
+ }
424
+
425
+ function rodar(cmd, args, timeout = 15 * 60 * 1000) {
426
+ return new Promise((resolve) => {
427
+ let saida = '';
428
+ let p;
429
+ try { p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); }
430
+ catch (e) { return resolve({ ok: false, saida: e.message }); }
431
+ const t = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
432
+ p.stdout.on('data', (d) => { saida += d.toString(); });
433
+ p.stderr.on('data', (d) => { saida += d.toString(); });
434
+ p.on('close', (code) => { clearTimeout(t); resolve({ ok: code === 0, saida }); });
435
+ p.on('error', (e) => { clearTimeout(t); resolve({ ok: false, saida: e.message }); });
436
+ });
437
+ }
438
+
439
+ function brew() {
440
+ for (const b of ['brew', '/opt/homebrew/bin/brew', '/usr/local/bin/brew']) {
441
+ try { execFileSync(b, ['--version'], { stdio: 'ignore', timeout: 10000 }); return b; } catch { /* próximo */ }
442
+ }
443
+ return null;
444
+ }
445
+
446
+ /** As bibliotecas do sistema que faltam, instaladas por quem der. */
447
+ async function instalarSistema(py, falta, aoFalar) {
448
+ const avisos = [];
449
+ const precisa = [];
450
+ if (falta.some((f) => f.modulo === 'sounddevice' && f.sistema)) precisa.push('portaudio');
451
+ if (falta.some((f) => f.modulo === 'tkinter')) precisa.push('tkinter');
452
+ if (!precisa.length) return avisos;
453
+
454
+ if (process.platform === 'darwin') {
455
+ const b = brew();
456
+ if (!b) {
457
+ avisos.push('o Homebrew não está instalado, então não deu para instalar '
458
+ + precisa.join(' e ') + ' sozinho — instale em brew.sh e rode /voice de novo.');
459
+ return avisos;
460
+ }
461
+ // O Tk do Homebrew tem versão casada com a do Python: python-tk@3.14
462
+ // serve ao python3.14, e só a ele.
463
+ let versao = '';
464
+ try { versao = execFileSync(py, ['-c', 'import sys;print("%d.%d"%sys.version_info[:2])']).toString().trim(); } catch {}
465
+ const formulas = precisa.map((x) => (x === 'tkinter' ? (versao ? `python-tk@${versao}` : 'python-tk') : x));
466
+ aoFalar(`instalando ${formulas.join(', ')} pelo Homebrew`);
467
+ const r = await rodar(b, ['install', ...formulas]);
468
+ if (!r.ok) avisos.push(`o Homebrew não instalou ${formulas.join(', ')}: ${porqueOPipFalhou(r.saida)}`);
469
+ return avisos;
470
+ }
471
+ if (process.platform === 'linux') {
472
+ const pacotes = precisa.map((x) => (x === 'tkinter' ? 'python3-tk' : 'libportaudio2'));
473
+ aoFalar(`instalando ${pacotes.join(', ')}`);
474
+ const r = await rodar('sudo', ['-n', 'apt-get', 'install', '-y', ...pacotes]);
475
+ if (!r.ok) avisos.push(`precisa de senha para instalar: sudo apt install ${pacotes.join(' ')}`);
476
+ return avisos;
477
+ }
478
+ avisos.push(precisa.map((x) => COMO_SISTEMA[x === 'tkinter' ? 'tkinter' : 'portaudio'].win32).join(' · '));
479
+ return avisos;
480
+ }
481
+
482
+ /** O Whisper local, baixado antes da primeira frase. ~480 MB, uma vez. */
483
+ async function baixarWhisper(py) {
484
+ const r = await new Promise((resolve) => {
485
+ const p = spawn(py, ['-m', 'primo_voz.transcrever', 'baixar'], {
486
+ cwd: PASTA, env: { ...process.env, PYTHONPATH: PASTA },
487
+ });
488
+ let fora = '';
489
+ let erro = '';
490
+ p.stdout.on('data', (d) => { fora += d.toString(); });
491
+ p.stderr.on('data', (d) => { erro += d.toString(); });
492
+ p.on('close', () => {
493
+ try { resolve(JSON.parse(fora.trim().split('\n').pop())); }
494
+ catch { resolve({ ok: false, error: erro.trim().split('\n').pop() || 'o download não respondeu' }); }
495
+ });
496
+ p.on('error', (e) => resolve({ ok: false, error: e.message }));
497
+ });
498
+ return r;
499
+ }
500
+
501
+ /**
502
+ * Deixa o modo voz pronto: instala e baixa tudo o que falta, sem perguntar.
503
+ *
504
+ * @param {(msg:string)=>void} aoFalar uma linha de progresso por etapa
505
+ * @returns {Promise<{ok:boolean, python?:string, esperando?:boolean,
506
+ * avisos:string[], error?:string, comoResolver?:string}>}
507
+ */
508
+ async function preparar(aoFalar = () => {}) {
509
+ const py = python();
510
+ if (!py) {
511
+ return { ok: false, avisos: [], error: 'o modo voz precisa do Python 3, e não achei nenhum nesta máquina.',
512
+ comoResolver: process.platform === 'darwin' ? 'brew install python' : 'instale em python.org e abra o terminal de novo' };
513
+ }
514
+ const avisos = [];
515
+
516
+ // 1. sistema, depois 2. os obrigatórios pelo pip.
517
+ let falta = faltando(py);
518
+ avisos.push(...await instalarSistema(py, falta, aoFalar));
519
+ falta = faltando(py);
520
+ const pipObrigatorio = falta.filter((f) => f.pacote).map((f) => f.pacote);
521
+ if (pipObrigatorio.length) {
522
+ const r = await instalar(py, pipObrigatorio, aoFalar);
523
+ if (!r.ok) avisos.push(`o pip não instalou ${pipObrigatorio.join(', ')}: ${r.error}`);
524
+ falta = faltando(py);
525
+ }
526
+ if (falta.length) {
527
+ const c = conferir();
528
+ return { ok: false, python: py, avisos, error: c.error, comoResolver: c.comoResolver };
529
+ }
530
+
531
+ // 2b. os opcionais: faltar um deles não impede o modo voz de abrir.
532
+ const opcionais = OPCIONAIS().filter(([m]) => !temModulo(py, m));
533
+ if (opcionais.length) {
534
+ const r = await instalar(py, opcionais.map(([, pacote]) => pacote), aoFalar);
535
+ for (const [m, , para] of opcionais) {
536
+ if (!temModulo(py, m)) avisos.push(`sem ${para}: ${r.ok ? 'o pacote não carregou' : r.error}`);
537
+ }
538
+ }
539
+
540
+ // 3. os modelos.
541
+ if (temModulo(py, 'faster_whisper')) {
542
+ aoFalar('preparando a transcrição local (Whisper, ~480 MB na primeira vez)');
543
+ const w = await baixarWhisper(py);
544
+ if (!w.ok) avisos.push(`a transcrição local não ficou pronta (${w.error}) — vai pelo servidor.`);
545
+ }
546
+ let esperando = false;
547
+ const espera = conferirEspera(py);
548
+ if (espera.ok) esperando = true;
549
+ else if (espera.baixavel) {
550
+ aoFalar('baixando o reconhecedor do "ei, primo" (~31 MB)');
551
+ const r = await baixarEspera(py);
552
+ if (r.ok) esperando = true;
553
+ else avisos.push(`sem o "ei, primo": ${r.error} — o modo voz abre já ouvindo.`);
554
+ }
555
+
556
+ return { ok: true, python: py, esperando, avisos };
557
+ }
558
+
559
+ module.exports = { conferir, instalar, preparar, porqueOPipFalhou, abrir, conferirEspera, baixarEspera, python, COMO_FALAR, PASTA };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.3.1",
3
+ "version": "9.5.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/compatibilidade.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"
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/compatibilidade.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/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"
@@ -50,7 +50,7 @@ from pathlib import Path
50
50
  AQUI = Path(__file__).resolve().parent
51
51
  sys.path.insert(0, str(AQUI.parent))
52
52
 
53
- from primo_voz import desperta, escuta, orbe # noqa: E402
53
+ from primo_voz import desperta, escuta, orbe, transcrever # noqa: E402
54
54
 
55
55
 
56
56
  def falar_para_o_node(obj: dict) -> None:
@@ -135,6 +135,11 @@ class Voz:
135
135
  self.desperta = None
136
136
 
137
137
  self.orb = orbe.Orbe(ao_fechar=self.parar)
138
+ # O Whisper local carrega em segundo plano desde já: senão a primeira
139
+ # frase esperaria o modelo inteiro subir.
140
+ self.local = transcrever.disponivel()
141
+ if self.local:
142
+ transcrever.aquecer()
138
143
  self.gravador = escuta.Gravador()
139
144
  self.corte = escuta.Corte()
140
145
 
@@ -251,11 +256,18 @@ class Voz:
251
256
 
252
257
  # ── transcrição ──────────────────────────────────────────────────────
253
258
  def _transcrever(self, wav: Path) -> str:
254
- """Quem tem a chave é o servidor do PrimoCode, não este programa.
255
-
256
- É a mesma rota que a legenda de vídeo usa. Duas formas de transcrever
257
- seriam duas contas para pagar e duas para consertar.
259
+ """Primeiro aqui, com o Whisper local (ver transcrever.py). O servidor
260
+ só entra se o local não estiver instalado ou falhar — e diz que caiu.
258
261
  """
262
+ if self.local:
263
+ try:
264
+ return transcrever.transcrever(wav)
265
+ except Exception as e:
266
+ avisar(f"a transcrição local falhou ({e}); tentando pelo servidor.")
267
+ return self._transcrever_no_servidor(wav)
268
+
269
+ def _transcrever_no_servidor(self, wav: Path) -> str:
270
+ """A rota do servidor, a mesma que a legenda de vídeo usa."""
259
271
  import base64
260
272
  import urllib.request
261
273