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/README.md +2 -2
- package/app/index.html +1 -1
- package/bin/primocode.js +77 -111
- package/lib/boas-vindas.js +7 -5
- package/lib/conta.js +83 -136
- package/lib/entrada.js +14 -3
- package/lib/voz.js +170 -1
- package/package.json +2 -2
- package/voz/primo_voz/__main__.py +17 -5
- package/voz/primo_voz/orbe.py +234 -122
- package/voz/primo_voz/transcrever.py +106 -0
- package/lib/area.js +0 -104
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
|
-
|
|
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
|
+
"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/
|
|
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
|
-
"""
|
|
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
|
|