primocode 9.1.0 → 9.2.1

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 CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v9.0.0
1
+ # PrimoCode v9.2.1
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v9.0.0
12
+ ▐▛███▜▌ Primo Code v9.2.1
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -33,25 +33,19 @@ Em uma frase: **é o poder de um agente de código no terminal, em português, c
33
33
 
34
34
  ## Instalação
35
35
 
36
- **macOS / Linux / WSL** — um comando, e ele resolve tudo (inclusive instalar o Node, se faltar):
37
-
38
- ```bash
39
- curl -fsSL https://raw.githubusercontent.com/ConectaPrimoAI/primocode/main/install.sh | bash
40
- ```
41
-
42
- **Windows** — baixe o repositório e rode:
43
-
44
- ```cmd
45
- install.cmd
46
- ```
47
-
48
- Já tem Node 18.17+? Então basta:
49
-
50
36
  ```bash
51
37
  npm install -g primocode
52
38
  ```
53
39
 
54
- Requer **Node.js 18.17+**. O CLI não tem dependências — só a stdlib do Node, por isso instala em segundos.
40
+ Requer **Node.js 18.17+**. O CLI não tem dependência nenhuma — só a stdlib do
41
+ Node — e por isso instala em segundos. Depois é só `primocode`: ele abre o
42
+ login no navegador e, voltando, está pronto.
43
+
44
+ Não tem Node? Instale em [nodejs.org](https://nodejs.org) e repita o comando
45
+ acima. Quem já clonou este repositório pode usar os instaladores que moram
46
+ aqui (`install.sh` no macOS/Linux/WSL, `install.cmd` no Windows): eles detectam
47
+ o Node, oferecem instalar, e apontam o npm para `~/.npm-global` em vez da pasta
48
+ do sistema — que é o que evita o `EACCES` da tabela abaixo.
55
49
 
56
50
  ### Se der erro
57
51
 
package/bin/primocode.js CHANGED
@@ -151,7 +151,8 @@ const SLASH_COMANDOS = [
151
151
  ['/account', 'sua conta, plano e computadores conectados'],
152
152
  ['/chats', 'suas conversas salvas, as mesmas do app e do celular'],
153
153
  ['/logout', 'desconecta este computador da conta'],
154
- ['/voice', 'o Primo Code em modo voz: você fala, ele faz'],
154
+ ['/voice', 'fica ouvindo em segundo plano — diga "ei, primo" e a orb aparece'],
155
+ ['/voice parar', 'desliga o modo voz e fecha o microfone'],
155
156
  ['/skills', 'o que ele sabe fazer bem — ligue e desligue'],
156
157
  ['/marketplace', 'a tela das skills: navegue, ligue, crie'],
157
158
  ['/install-skill', 'instala uma skill de um arquivo'],
@@ -966,6 +967,11 @@ async function comprimirConversa(forcado = false) {
966
967
  // na tela depois de o usuário ter mandado parar.
967
968
  let chatEmVoo = null;
968
969
 
970
+ /* O ouvinte de voz em segundo plano.
971
+ Um por sessão: dois processos disputando o mesmo microfone dão um vazio
972
+ intermitente que parece defeito de hardware e não é. */
973
+ let vozEmEspera = null;
974
+
969
975
  async function runChat(promptForModel, userTextForMemory) {
970
976
  chatEmVoo = new AbortController();
971
977
  const sp = new Spinner(ui.PENSANDO).start();
@@ -1405,6 +1411,24 @@ async function handleSlash(text) {
1405
1411
  * garantia de que um deles está errado.
1406
1412
  */
1407
1413
  case 'voice': case 'voz': {
1414
+ /* ── /voice PARA ─────────────────────────────────────────────
1415
+ Encerrar é o primeiro caso tratado de propósito: quem quer
1416
+ desligar o microfone quer desligar AGORA, e não deve esbarrar
1417
+ em checagem de dependência nem em download de modelo. */
1418
+ if (/^(parar|stop|off|sair)$/i.test((rest || '').trim())) {
1419
+ if (!vozEmEspera) { console.log(c.muted(' O modo voz não está ligado.')); break; }
1420
+ try { vozEmEspera.kill(); } catch {}
1421
+ vozEmEspera = null;
1422
+ console.log(c.muted(' Modo voz desligado. O microfone fechou.'));
1423
+ break;
1424
+ }
1425
+
1426
+ if (vozEmEspera) {
1427
+ console.log(c.muted(' O modo voz já está ouvindo. Diga "ei, primo".'));
1428
+ console.log(c.dim(' /voice parar desliga.'));
1429
+ break;
1430
+ }
1431
+
1408
1432
  const pronto = vozModo.conferir();
1409
1433
  if (!pronto.ok && !pronto.instalavel) {
1410
1434
  console.log(c.err(` ${pronto.error}`));
@@ -1430,29 +1454,59 @@ async function handleSlash(text) {
1430
1454
  const conferido = vozModo.conferir();
1431
1455
  if (!conferido.ok) {
1432
1456
  console.log(c.err(` ainda falta: ${conferido.error}`));
1433
- if (conferido.comoResolver) console.log(c.muted(` ${conferido.comoResolver}`));
1434
1457
  break;
1435
1458
  }
1436
1459
  }
1437
1460
 
1438
1461
  const py = (pronto.ok ? pronto : vozModo.conferir()).python;
1462
+
1463
+ /* ── O RECONHECEDOR LOCAL ────────────────────────────────────
1464
+ Ele é o que permite o microfone ficar aberto sem o áudio sair
1465
+ da máquina: o nome é reconhecido AQUI, e só depois disso a
1466
+ frase viaja. São ~31 MB, uma vez, e ficam em ~/.primocode.
1467
+
1468
+ Se a pessoa recusar, o modo voz ainda abre — mas já ouvindo,
1469
+ sem espera. O que NÃO acontece em hipótese nenhuma é cair para
1470
+ "manda tudo para o servidor o dia inteiro": isso seria trocar
1471
+ a privacidade dela por conveniência sem perguntar. */
1472
+ const espera = await vozModo.conferirEspera(py);
1473
+ let esperando = espera.ok;
1474
+ if (!espera.ok && espera.baixavel) {
1475
+ console.log('');
1476
+ console.log(' ' + c.muted('Para ficar ouvindo só o nome, sem mandar áudio para lugar nenhum,'));
1477
+ console.log(' ' + c.muted('ele precisa do reconhecedor local — ') + c.dim('~31 MB, uma vez.'));
1478
+ if (await confirm('Baixar agora?')) {
1479
+ const sp = new Spinner('baixando o reconhecedor').start();
1480
+ const r = await vozModo.baixarEspera(py);
1481
+ sp.stop();
1482
+ if (r.ok) esperando = true;
1483
+ else {
1484
+ console.log(c.err(` não deu: ${r.error}`));
1485
+ if (r.comoResolver) console.log(c.dim(` ${r.comoResolver}`));
1486
+ }
1487
+ }
1488
+ } else if (!espera.ok && espera.error) {
1489
+ console.log(c.dim(` ${espera.error}`));
1490
+ }
1491
+
1439
1492
  console.log('');
1440
1493
  console.log(' ' + c.bold(c.brand('Primo Code Voice')));
1441
- console.log(' ' + c.muted('É só falar. A orb responde: ')
1442
- + c.dim('parada espera · pulsa ouvindo · respira pensando · brilha falando'));
1443
- console.log(' ' + c.dim('Falar por cima corta a resposta · Esc ou fechar a janela encerra'));
1494
+ if (esperando) {
1495
+ console.log(' ' + c.muted('Ouvindo em segundo plano. Diga ') + c.white('"ei, primo"') + c.muted(' e a orb aparece.'));
1496
+ console.log(' ' + c.dim('Nada sai da máquina até você chamar · /voice parar desliga'));
1497
+ } else {
1498
+ console.log(' ' + c.muted('É só falar. A orb responde: ')
1499
+ + c.dim('parada espera · pulsa ouvindo · respira pensando · brilha falando'));
1500
+ console.log(' ' + c.dim('Falar por cima corta a resposta · /voice parar desliga'));
1501
+ }
1444
1502
  console.log('');
1445
1503
 
1446
- // A caixa de digitar sai da frente: quem está no modo voz não
1447
- // está digitando, e uma caixa piscando embaixo é ruído.
1448
- if (rl && rl.suspender) rl.suspender();
1449
- sliderAtivo = true;
1450
-
1451
- const r = await vozModo.abrir({
1504
+ const sessao = vozModo.abrir({
1452
1505
  python: py,
1453
1506
  servidor: state.config.server,
1454
1507
  token: state.config.token,
1455
1508
  voz: state.config.voz || 'antonio',
1509
+ esperando,
1456
1510
  mostrar: (texto, deErro) => {
1457
1511
  if (deErro) console.log(' ' + c.dim(texto));
1458
1512
  else console.log(' ' + c.brand('❯ ') + c.white(texto));
@@ -1485,10 +1539,17 @@ async function handleSlash(text) {
1485
1539
  },
1486
1540
  });
1487
1541
 
1488
- sliderAtivo = false;
1489
- if (rl && rl.retomar) rl.retomar();
1490
- if (!r.ok) console.log(c.err(` o modo voz fechou: ${r.error}`));
1491
- else console.log(c.muted(' Modo voz encerrado.'));
1542
+ /* O TERMINAL CONTINUA SEU.
1543
+ Aqui NÃO se espera o fim. Era isso que obrigava a digitar
1544
+ /voice antes de cada pedido: o comando bloqueava o prompt, e a
1545
+ única forma de voltar a digitar era encerrar o modo voz. Agora
1546
+ ele vive ao lado — a pessoa fala quando quiser e digita quando
1547
+ quiser, sem desligar um para usar o outro. */
1548
+ vozEmEspera = sessao.processo;
1549
+ sessao.pronto.then((r) => {
1550
+ vozEmEspera = null;
1551
+ if (r && !r.ok) console.log(c.err(` o modo voz fechou: ${r.error}`));
1552
+ });
1492
1553
  break;
1493
1554
  }
1494
1555
 
@@ -1971,6 +2032,18 @@ async function main() {
1971
2032
  function sayBye() {
1972
2033
  if (saidBye) return;
1973
2034
  saidBye = true;
2035
+
2036
+ /* O MICROFONE FECHA JUNTO.
2037
+ O ouvinte de voz é um processo filho, e filho no POSIX não morre
2038
+ porque o pai saiu: ficaria um Python segurando o microfone depois
2039
+ de o terminal fechar — sem janela, sem jeito de desligar a não ser
2040
+ pelo gerenciador de tarefas, e com a luzinha da câmera do lado
2041
+ acesa. Matar aqui é o que faz "fechei o terminal" significar
2042
+ "fechei tudo". */
2043
+ if (vozEmEspera) {
2044
+ try { vozEmEspera.kill(); } catch { /* já morreu */ }
2045
+ vozEmEspera = null;
2046
+ }
1974
2047
  // O último turno pode ainda estar subindo — gravar é de propósito
1975
2048
  // solto, para não segurar o prompt. Fechar o terminal em cima dele
1976
2049
  // perderia justamente a resposta que a pessoa acabou de ler, e ela
package/lib/act.js CHANGED
@@ -539,7 +539,7 @@ async function runAct(userPrompt, opts) {
539
539
  * Num subagente não entra: ele já está fazendo uma parte de um plano, e
540
540
  * um diretor dentro do diretor é uma volta a mais para o mesmo lugar. */
541
541
  if (!subagent && profundidade === 0) {
542
- const producao = require('./diretor.js').precisaDeProducao(pedido);
542
+ const producao = require('./estudio/diretor.js').precisaDeProducao(pedido);
543
543
  if (producao.producao) {
544
544
  messages.push({ role: 'system', content:
545
545
  'ESTE PEDIDO É DE PRODUÇÃO AUDIOVISUAL. Antes de criar qualquer peça, chame '
@@ -1108,7 +1108,7 @@ async function rodarDiretor(args, ctx) {
1108
1108
  return { ok: false, error: 'Recusado: o diretor não roda dentro de um subagente. '
1109
1109
  + 'Produza com o plano que você já recebeu.' };
1110
1110
  }
1111
- const diretor = require('./diretor.js');
1111
+ const diretor = require('./estudio/diretor.js');
1112
1112
  const correr = async (prompt, rotulo) => {
1113
1113
  if (PARAR) return '';
1114
1114
  fala.agente('Diretor', rotulo, profundidade);
@@ -73,7 +73,7 @@ function buscarJSON(url, { timeout = 15000, cabecalhos = {} } = {}) {
73
73
  política deles pede. */
74
74
  function agente() {
75
75
  let v = '?';
76
- try { v = require('../package.json').version; } catch { /* sem package: tanto faz */ }
76
+ try { v = require('../../package.json').version; } catch { /* sem package: tanto faz */ }
77
77
  return `PrimoCode/${v} (https://github.com/parisgroup-ai/primocode)`;
78
78
  }
79
79
 
@@ -58,7 +58,7 @@ function pegar(url, { json = false, binario = false, cabecalhos = {} } = {}) {
58
58
  path: u.pathname + u.search,
59
59
  headers: {
60
60
  // O Openverse recusa quem não se identifica, e com razão.
61
- 'User-Agent': 'PrimoCode-Studio/1.0 (+https://github.com/ConectaPrimoAI/primocode)',
61
+ 'User-Agent': 'PrimoCode-Studio/1.0 (+https://www.npmjs.com/package/primocode)',
62
62
  Accept: json ? 'application/json' : '*/*',
63
63
  ...cabecalhos,
64
64
  },
@@ -36,8 +36,8 @@ const path = require('path');
36
36
  const https = require('https');
37
37
  const referencia = require('../referencia.js');
38
38
  const assets = require('./assets.js');
39
- const marca = require('../marca.js');
40
- const acervo = require('../acervo.js');
39
+ const marca = require('./marca.js');
40
+ const acervo = require('./acervo.js');
41
41
  const http = require('http');
42
42
  const { URL } = require('url');
43
43
 
@@ -33,7 +33,7 @@ const path = require('path');
33
33
  const { spawn } = require('child_process');
34
34
 
35
35
  const projeto = require('./projeto.js');
36
- const acervo = require('../acervo.js');
36
+ const acervo = require('./acervo.js');
37
37
  const plano = require('./plano.js');
38
38
  const remotion = require('./remotion.js');
39
39
  const assets = require('./assets.js');
@@ -33,7 +33,7 @@
33
33
 
34
34
  const fs = require('fs');
35
35
  const path = require('path');
36
- const referencia = require('./referencia.js');
36
+ const referencia = require('../referencia.js');
37
37
 
38
38
  const GENERICAS_FONTE = /^(sans-serif|serif|monospace|system-ui|-apple-system|blinkmacsystemfont|segoe ui|arial|helvetica( neue)?|roboto|inherit|initial|ui-sans-serif|ui-serif|ui-monospace|apple color emoji|segoe ui emoji|noto color emoji|times new roman|courier new|verdana|tahoma|georgia)$/i;
39
39
 
package/lib/repeticao.js CHANGED
@@ -33,7 +33,12 @@
33
33
  'use strict';
34
34
 
35
35
  /** Tools cuja resposta é documento fixo — repetir é sempre desperdício. */
36
- const REFERENCIAS = new Set(['studio_manual', 'studio_spec']);
36
+ /* O documento fixo que NÃO pode encolher na conversa. Eram o manual e a spec
37
+ do Estúdio antigo; o Estúdio novo não tem manual porque não tem formato a
38
+ decorar. Quem ocupa esse lugar é a PAUTA do diretor, escrita uma vez, que o
39
+ produtor relê enquanto monta as cenas — encolhê-la faz o modelo chamar o
40
+ diretor de novo, que é o laço que isto existe para evitar. */
41
+ const REFERENCIAS = new Set(['planejar_producao', 'estudio_site']);
37
42
 
38
43
  /** Acima disto, duas buscas são a mesma busca. */
39
44
  const LIMITE_SEMELHANCA = 0.6;
package/lib/tools.js CHANGED
@@ -429,8 +429,9 @@ async function despachar(name, args, ctx) {
429
429
  return {
430
430
  ok: false,
431
431
  error: `"${args.path}" é arquivo de vídeo/áudio, e write_file só escreve TEXTO — isso criaria um arquivo falso que não abre. `
432
- + 'Vídeo se faz no Studio: studio_criar com ferramenta "corte" (leia studio_spec antes). '
433
- + 'O Studio devolve o link "ver" — é ESSE o vídeo. Não use ffmpeg: não é preciso.',
432
+ + 'Vídeo se faz no Estúdio: estudio_projeto (criar) → planejar_producao → estudio_plano → '
433
+ + 'estudio_render. O render devolve o CAMINHO do arquivo — é esse o vídeo. '
434
+ + 'Não monte vídeo com ffmpeg à mão.',
434
435
  };
435
436
  }
436
437
  const filePath = resolveInProject(args.path, projectDir);
@@ -610,14 +611,38 @@ async function despachar(name, args, ctx) {
610
611
  divergir do catálogo daqui, e ela se conserta lá. Mas a rede
611
612
  é o que separa uma tarefa que se recupera de uma que morre
612
613
  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
- };
614
+ {
615
+ /* O CASO QUE ACONTECE DE VERDADE tem nome: o servidor
616
+ está desatualizado e continua oferecendo o Estúdio
617
+ antigo (studio_criar, studio_spec…). Visto ao vivo, e a
618
+ reação do modelo foi a pior possível: chutou nomes de
619
+ outro produto (`repo_browser.read_file`), tentou
620
+ escrever um .mp4 com write_file, e desistiu com "parei
621
+ sem concluir" — sem nunca dizer ao usuário qual era o
622
+ problema.
623
+
624
+ Então a resposta separa os dois casos. Para o Estúdio
625
+ velho ela EXPLICA o que houve e manda diagnosticar em
626
+ voz alta se as novas não estiverem na lista dele: um
627
+ "o servidor está desatualizado" é acionável; um "parei
628
+ sem concluir" não é. */
629
+ const velhoEstudio = /^studio_/.test(String(name));
630
+ return {
631
+ ok: false,
632
+ error: `A ferramenta "${name}" não existe.`,
633
+ parecidas: catalogo.parecidasCom(name),
634
+ comoResolver: velhoEstudio
635
+ ? 'O Estúdio foi TROCADO: studio_* não existe mais. As ferramentas de hoje são '
636
+ + 'planejar_producao, estudio_projeto, estudio_plano, estudio_midia, estudio_narrar, '
637
+ + 'estudio_video, estudio_render e estudio_site. Use-as. '
638
+ + 'Se ELAS não estiverem na sua lista de ferramentas, o servidor está desatualizado: '
639
+ + 'PARE de tentar outros nomes e diga isso ao usuário em uma frase — é o que ele '
640
+ + 'precisa saber para resolver.'
641
+ : 'Chame uma das ferramentas que existem de verdade; as "parecidas" acima são as de '
642
+ + 'nome mais próximo. NÃO invente nome de ferramenta e não tente variações — se '
643
+ + 'nenhuma servir, diga ao usuário o que faltou, em vez de desistir calado.',
644
+ };
645
+ }
621
646
  }
622
647
  }
623
648
  }
package/lib/ui.js CHANGED
@@ -295,12 +295,18 @@ const ROTULOS = {
295
295
  desktop_drag:'Desktop·drag', desktop_type:'Desktop·type', desktop_key:'Desktop·key',
296
296
  desktop_scroll:'Desktop·scroll', desktop_size:'Desktop·size',
297
297
  desktop_double_click:'Desktop·2click', desktop_open_app:'Desktop·app',
298
- studio_manual:'Estúdio·manual', studio_spec:'Estúdio·spec', studio_criar:'Estúdio·criar',
299
- studio_ler:'Estúdio·ler', studio_atualizar:'Estúdio·ajustar', studio_listar:'Estúdio·lista',
300
- studio_versoes:'Estúdio·histórico',
298
+ /* Os nomes do Estúdio ATUAL. Os antigos (studio_manual, studio_spec,
299
+ studio_criar…) saíram daqui de propósito: enquanto estavam, uma
300
+ chamada a ferramenta que não existe mais aparecia na tela com
301
+ rótulo bonito ("Estúdio·spec corte"), como se fosse trabalho
302
+ legítimo — e só a linha de baixo revelava que tinha falhado. */
303
+ planejar_producao:'Diretor', estudio_projeto:'Estúdio·projeto',
304
+ estudio_plano:'Estúdio·plano', estudio_narrar:'Estúdio·narração',
305
+ estudio_video:'Estúdio·vídeo', estudio_render:'Estúdio·render',
306
+ estudio_site:'Estúdio·marca',
301
307
  desktop_windows:'Desktop·janelas', desktop_focus:'Desktop·foco',
302
308
  desktop_elements:'Desktop·elementos', desktop_click_element:'Desktop·click',
303
- studio_midia:'Estúdio·mídia', studio_analisar:'Estúdio·analisar', construir_app:'Construir',
309
+ estudio_midia:'Estúdio·mídia', construir_app:'Construir',
304
310
  remember_general:'Lembrar',
305
311
  };
306
312
 
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.1.0",
3
+ "version": "9.2.1",
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 && node test/terminal-novo.test.js && node test/markdown.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"
@@ -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 escuta, orbe # noqa: E402
53
+ from primo_voz import desperta, escuta, orbe # noqa: E402
54
54
 
55
55
 
56
56
  def falar_para_o_node(obj: dict) -> None:
@@ -106,11 +106,34 @@ PS_TOCAR_MP3 = (
106
106
  class Voz:
107
107
  """A costura: microfone → transcrição → Node → voz → microfone."""
108
108
 
109
- def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio"):
109
+ def __init__(self, servidor: str, token: str, voz_escolhida: str = "antonio",
110
+ esperando: bool = False):
110
111
  self.servidor = servidor
111
112
  self.token = token
112
113
  self.voz = voz_escolhida
113
114
 
115
+ # ── MODO DE ESPERA ───────────────────────────────────────────
116
+ #
117
+ # Ligado, ele fica dormindo com a orbe escondida até ouvir o nome, e
118
+ # volta a dormir depois de atender. Desligado (o padrão antigo), ele
119
+ # já entra ouvindo — que é o certo para quem digitou /voice e vai
120
+ # falar agora.
121
+ #
122
+ # A espera exige o reconhecedor local. Sem ele instalado, não cai
123
+ # para "mandar tudo para o servidor": cai para o modo de sempre, e
124
+ # DIZ por quê. Silenciosamente transformar uma coisa na outra seria
125
+ # abrir o microfone da pessoa para a internet sem ela saber.
126
+ #
127
+ self.esperando = bool(esperando)
128
+ self.desperta = None
129
+ if self.esperando:
130
+ self.desperta = desperta.Desperta(ao_despertar=lambda: None)
131
+ if not self.desperta.pronto:
132
+ avisar(f"modo de espera indisponível: {self.desperta.motivo} — "
133
+ "abrindo no modo normal, já ouvindo.")
134
+ self.esperando = False
135
+ self.desperta = None
136
+
114
137
  self.orb = orbe.Orbe(ao_fechar=self.parar)
115
138
  self.gravador = escuta.Gravador()
116
139
  self.corte = escuta.Corte()
@@ -122,6 +145,19 @@ class Voz:
122
145
  self._parar_fala = threading.Event()
123
146
  self._do_node = queue.Queue()
124
147
 
148
+ def _esvaziar_blocos(self) -> None:
149
+ """Joga fora o áudio que está na fila.
150
+
151
+ Depois de despertar, os blocos do próprio "ei primo" ainda estão
152
+ enfileirados — e sem isto eles entram na gravação do PEDIDO, que chega
153
+ ao agente com o nome colado na frente.
154
+ """
155
+ while True:
156
+ try:
157
+ self._blocos.get_nowait()
158
+ except queue.Empty:
159
+ return
160
+
125
161
  # ── microfone ────────────────────────────────────────────────────────
126
162
  def _ao_receber(self, bloco) -> None:
127
163
  if not self._vivo:
@@ -153,6 +189,31 @@ class Voz:
153
189
  return
154
190
 
155
191
  while self._vivo:
192
+ # ── O SONO ───────────────────────────────────────────────
193
+ # No modo de espera a orbe some e o microfone fica ouvindo SÓ o
194
+ # nome, aqui dentro, sem nada sair da máquina (ver desperta.py).
195
+ # Sem isso, o modo de espera seria o microfone da pessoa aberto
196
+ # para a internet o dia inteiro.
197
+ if self.esperando and self.desperta is not None:
198
+ self.orb.esconder()
199
+ self.desperta.zerar()
200
+ acordou = False
201
+ while self._vivo and not acordou:
202
+ try:
203
+ bloco = self._blocos.get(timeout=0.3)
204
+ except queue.Empty:
205
+ continue
206
+ acordou = self.desperta.alimentar(bloco)
207
+ if not self._vivo:
208
+ break
209
+ # Chamou: a orbe aparece e ele passa a ouvir o PEDIDO.
210
+ self.orb.mostrar()
211
+ self.orb.mudar(estado="ouvindo", nivel=0)
212
+ # Sem esvaziar a fila, os blocos do próprio "ei primo" entram
213
+ # na gravação do pedido — e o pedido chega com o nome colado
214
+ # na frente.
215
+ self._esvaziar_blocos()
216
+
156
217
  self.orb.mudar(estado="esperando", nivel=0)
157
218
  self.gravador.comecar()
158
219
 
@@ -362,6 +423,9 @@ def main() -> int:
362
423
  servidor = os.environ.get("PRIMOCODE_SERVER", "").strip()
363
424
  token = os.environ.get("PRIMOCODE_TOKEN", "").strip()
364
425
  voz = os.environ.get("PRIMOCODE_VOZ", "antonio").strip() or "antonio"
426
+ # O modo de espera vem por variável, como o resto: quem abre este programa
427
+ # é o /voice do Node, não uma pessoa numa linha de comando.
428
+ esperando = os.environ.get("PRIMOCODE_VOZ_ESPERA", "") == "1"
365
429
 
366
430
  if not servidor:
367
431
  avisar("falta PRIMOCODE_SERVER — este programa é aberto pelo /voice do PrimoCode.")
@@ -375,7 +439,7 @@ def main() -> int:
375
439
  f"{sys.executable} -m pip install sounddevice numpy")
376
440
  return 3
377
441
 
378
- Voz(servidor, token, voz).rodar()
442
+ Voz(servidor, token, voz, esperando=esperando).rodar()
379
443
  return 0
380
444
 
381
445
 
@@ -0,0 +1,195 @@
1
+ """desperta.py — ouvir o nome, e só o nome, sem mandar áudio para lugar nenhum.
2
+
3
+ ── POR QUE OFFLINE, E POR QUE ISSO NÃO É DETALHE ───────────────────────────
4
+ No modo de espera o microfone fica ABERTO o tempo todo. Existem dois jeitos de
5
+ saber se alguém disse "ei, primo":
6
+
7
+ 1. Mandar tudo o que o microfone ouve para um servidor transcrever.
8
+ 2. Reconhecer a palavra aqui dentro, sem nada sair da máquina.
9
+
10
+ O primeiro funciona e é mais simples de escrever. Também significa que TODA
11
+ conversa perto do computador — a ligação, a reunião, a briga em casa — sobe
12
+ para um servidor, o dia inteiro, para que uma vez por hora alguém diga o nome
13
+ do programa. Isso não é uma troca aceitável, e não é decisão que um programa
14
+ toma pelo dono da máquina.
15
+
16
+ Então é o segundo. O Vosk roda local, o modelo mora no disco da pessoa, e
17
+ NADA sai daqui enquanto o nome não for dito. Depois do nome, sim: a frase vai
18
+ para a transcrição, como no modo normal — mas aí foi a pessoa que chamou.
19
+
20
+ ── A GRAMÁTICA RESTRITA ────────────────────────────────────────────────────
21
+ O reconhecedor é criado sabendo APENAS as palavras de despertar. Não é
22
+ otimização: é o que impede ele de "ouvir" o nome no meio de uma frase
23
+ qualquer. Um reconhecedor livre transcreve tudo e depois se procura a palavra
24
+ no texto — e aí "prima", "primeiro" e "exprimo" acordam o assistente. Com a
25
+ gramática restrita ele só pode devolver uma das palavras da lista ou "[unk]".
26
+
27
+ Herdado do Jarvis (src/jarvis/core/wake.py), que resolveu isto primeiro; o
28
+ modelo aqui é o de PORTUGUÊS, porque "primo" é palavra portuguesa — ao
29
+ contrário de "Jarvis", que é nome inglês e por isso lá usava o modelo de
30
+ inglês.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import threading
37
+ import urllib.request
38
+ import zipfile
39
+ from pathlib import Path
40
+ from typing import Callable, Optional
41
+
42
+ TAXA = 16000
43
+
44
+ # Onde o modelo mora. Fora do pacote npm de propósito: são 31 MB, e um pacote
45
+ # que engorda 31 MB para todo mundo por causa de um modo que nem todos usam é
46
+ # um pacote que demora a instalar para todo mundo.
47
+ PASTA_MODELO = Path.home() / ".primocode" / "modelos"
48
+ NOME_MODELO = "vosk-model-small-pt-0.3"
49
+ URL_MODELO = f"https://alphacephei.com/vosk/models/{NOME_MODELO}.zip"
50
+
51
+ # O que ACORDA. Variantes porque o reconhecedor pequeno erra a vogal átona, e
52
+ # porque as pessoas falam de jeitos diferentes ("ei primo", "oi primo", "ô
53
+ # primo"). O "primo" sozinho entra: dentro da gramática restrita ele não
54
+ # dispara em conversa comum, porque o reconhecedor não transcreve conversa
55
+ # comum — só consegue devolver estas palavras.
56
+ VOCABULARIO = ("primo", "ei", "oi", "olá", "ô")
57
+ DESPERTA = ("primo",)
58
+
59
+
60
+ def caminho_do_modelo() -> Path:
61
+ return PASTA_MODELO / NOME_MODELO
62
+
63
+
64
+ def tem_modelo() -> bool:
65
+ caminho = caminho_do_modelo()
66
+ return caminho.is_dir() and any(caminho.iterdir())
67
+
68
+
69
+ def baixar_modelo(aoFalar: Optional[Callable[[str], None]] = None) -> dict:
70
+ """Traz o modelo, uma vez. ~31 MB.
71
+
72
+ Baixa para um arquivo temporário e só então move para o lugar: interromper
73
+ no meio deixaria uma pasta pela metade que `tem_modelo()` daria por boa, e
74
+ o erro apareceria depois, como "falha ao carregar o Vosk".
75
+ """
76
+ fala = aoFalar or (lambda _t: None)
77
+ if tem_modelo():
78
+ return {"ok": True, "jaTinha": True}
79
+ try:
80
+ PASTA_MODELO.mkdir(parents=True, exist_ok=True)
81
+ temporario = PASTA_MODELO / (NOME_MODELO + ".zip.parcial")
82
+ fala("baixando o reconhecedor de voz (uma vez, ~31 MB)")
83
+ with urllib.request.urlopen(URL_MODELO, timeout=120) as resposta, open(temporario, "wb") as saida:
84
+ while True:
85
+ pedaco = resposta.read(1 << 16)
86
+ if not pedaco:
87
+ break
88
+ saida.write(pedaco)
89
+ fala("descompactando")
90
+ with zipfile.ZipFile(temporario) as z:
91
+ z.extractall(PASTA_MODELO)
92
+ temporario.unlink(missing_ok=True)
93
+ if not tem_modelo():
94
+ return {"ok": False, "error": "o modelo baixou mas não apareceu onde devia"}
95
+ return {"ok": True}
96
+ except Exception as e:
97
+ return {"ok": False, "error": str(e),
98
+ "comoResolver": f"baixe {URL_MODELO} à mão e descompacte em {PASTA_MODELO}"}
99
+
100
+
101
+ def contem_nome(texto: str) -> bool:
102
+ """O texto que o reconhecedor devolveu contém o nome?
103
+
104
+ Comparação por PALAVRA INTEIRA. Com `in` de substring, "prima" e
105
+ "primeiro" acordariam o assistente — e a gramática restrita não protege
106
+ disso sozinha, porque ela devolve as palavras da lista e o resto vira
107
+ "[unk]", que pode vir colado.
108
+ """
109
+ palavras = set(str(texto or "").lower().replace("[unk]", " ").split())
110
+ return any(n in palavras for n in DESPERTA)
111
+
112
+
113
+ class Desperta:
114
+ """Escuta o nome. Nada sai da máquina até ele ser dito."""
115
+
116
+ def __init__(self, ao_despertar: Callable[[], None]) -> None:
117
+ self.ao_despertar = ao_despertar
118
+ self._rec = None
119
+ self._modelo = None
120
+ self.pronto = False
121
+ self.ligado = True
122
+ self.motivo = ""
123
+ # O reconhecedor do Vosk NÃO é seguro entre threads: a do áudio chama
124
+ # process() dez vezes por segundo enquanto o laço principal pode
125
+ # chamar reset() ao despertar. Sem trava, o estado interno corrompe e
126
+ # ele passa a levantar "Failed to process waveform" — sintoma que não
127
+ # fala de thread nenhuma, e por isso custa caro de achar.
128
+ self._trava = threading.Lock()
129
+ self._carregar()
130
+
131
+ def _carregar(self) -> None:
132
+ if not tem_modelo():
133
+ self.motivo = "o reconhecedor de voz não está baixado"
134
+ return
135
+ try:
136
+ import vosk
137
+ vosk.SetLogLevel(-1)
138
+ self._modelo = vosk.Model(str(caminho_do_modelo()))
139
+ self._rec = self._novo()
140
+ self.pronto = True
141
+ except ImportError:
142
+ self.motivo = "falta o vosk (pip install vosk)"
143
+ except Exception as e:
144
+ self.motivo = f"não consegui carregar o reconhecedor: {e}"
145
+
146
+ def _novo(self):
147
+ import vosk
148
+ gramatica = json.dumps(sorted({*VOCABULARIO, "[unk]"}))
149
+ return vosk.KaldiRecognizer(self._modelo, TAXA, gramatica)
150
+
151
+ def alimentar(self, bloco) -> bool:
152
+ """Um bloco de áudio. Devolve True se o nome foi dito."""
153
+ if not self.pronto or self._rec is None or not self.ligado:
154
+ return False
155
+ with self._trava:
156
+ try:
157
+ if self._rec.AcceptWaveform(bloco.tobytes()):
158
+ texto = json.loads(self._rec.Result()).get("text", "")
159
+ else:
160
+ texto = json.loads(self._rec.PartialResult()).get("partial", "")
161
+ except Exception:
162
+ # Reconhecedor travado: recria e segue. Desligar o modo de
163
+ # espera por causa de um bloco ruim seria perder o recurso
164
+ # inteiro por um soluço de áudio.
165
+ try:
166
+ self._rec = self._novo()
167
+ except Exception:
168
+ self.pronto = False
169
+ self.motivo = "o reconhecedor parou e não voltou"
170
+ return False
171
+
172
+ achou = bool(texto and contem_nome(texto))
173
+ if achou:
174
+ self._rec.Reset()
175
+
176
+ if achou:
177
+ try:
178
+ self.ao_despertar()
179
+ except Exception:
180
+ pass
181
+ return achou
182
+
183
+ def zerar(self) -> None:
184
+ """Esquece o que ouviu até agora.
185
+
186
+ Chamado ao voltar do atendimento: sem isso, o resto da frase que a
187
+ pessoa falou DEPOIS do nome ainda está no reconhecedor, e ele desperta
188
+ de novo no instante seguinte.
189
+ """
190
+ with self._trava:
191
+ if self._rec is not None:
192
+ try:
193
+ self._rec.Reset()
194
+ except Exception:
195
+ pass
@@ -20,10 +20,11 @@ mas CONTINUAR gravando exige bem menos. Sem isso, a sílaba fraca no fim ("…pr
20
20
  mim") cai abaixo do limiar e a frase é cortada antes de acabar.
21
21
 
22
22
  ── O QUE SAIU DO JARVIS ────────────────────────────────────────────────────
23
- A wake word. Lá ela existia porque o assistente ficava ligado o dia inteiro
24
- esperando ser chamado. Aqui a pessoa abriu o modo voz de propósito: ele já
25
- está ouvindo, e não há nome para dizer. Some com isso o Vosk e os 40 MB de
26
- modelo que ele baixava.
23
+ A wake word saiu DAQUI, não do produto. Este módulo cuida de uma coisa só:
24
+ achar o começo e o fim de uma frase. Quem reconhece o nome é o desperta.py, e
25
+ ele só entra no modo de espera — quando a pessoa pediu para ele ficar ouvindo
26
+ em segundo plano. Quem digita /voice e vai falar agora não diz nome nenhum: a
27
+ orbe já está ouvindo.
27
28
  """
28
29
  from __future__ import annotations
29
30
 
@@ -39,6 +39,7 @@ from __future__ import annotations
39
39
 
40
40
  import math
41
41
  import queue
42
+ import sys
42
43
  import threading
43
44
  import tkinter as tk
44
45
  from typing import Callable, Optional
@@ -63,6 +64,7 @@ class Orbe:
63
64
  self.raiz = tk.Tk()
64
65
  self.raiz.title("Primo Code Voice")
65
66
  self.estado = "esperando"
67
+ self.visivel = True
66
68
  self.nivel = 0.0 # 0..1, o quanto a pessoa está falando
67
69
  self._fase = 0.0
68
70
  self._fila = queue.Queue()
@@ -130,6 +132,12 @@ class Orbe:
130
132
  while True:
131
133
  try:
132
134
  estado, nivel = self._fila.get_nowait()
135
+ if estado == '__esconder__':
136
+ self._aplicar_visibilidade(False)
137
+ continue
138
+ if estado == '__mostrar__':
139
+ self._aplicar_visibilidade(True)
140
+ continue
133
141
  except queue.Empty:
134
142
  break
135
143
  if estado:
@@ -197,6 +205,40 @@ class Orbe:
197
205
  principal, então o que chega vai para uma fila e é lido no desenho."""
198
206
  self._fila.put((estado, nivel))
199
207
 
208
+ def esconder(self) -> None:
209
+ """Some da tela sem morrer.
210
+
211
+ O modo de espera fica MINUTOS ou horas sem nada acontecer, e uma orbe
212
+ parada no meio do monitor todo esse tempo é um estorvo — ela cobre o
213
+ que a pessoa está fazendo. Então ela some e volta quando chamada.
214
+
215
+ Destruir e recriar a janela a cada despertar seria o caminho óbvio, e é
216
+ o errado: recriar uma Tk leva centenas de milissegundos, pisca, e em
217
+ alguns sistemas rouba o foco da janela onde a pessoa estava digitando.
218
+ `withdraw` tira da tela e guarda tudo de pé.
219
+ """
220
+ self._fila.put(("__esconder__", None))
221
+
222
+ def mostrar(self) -> None:
223
+ """Volta para a tela, no centro, por cima de tudo."""
224
+ self._fila.put(("__mostrar__", None))
225
+
226
+ def _aplicar_visibilidade(self, visivel: bool) -> None:
227
+ # Só na thread da interface: tkinter não perdoa chamada de fora.
228
+ try:
229
+ if visivel:
230
+ self.veu.deiconify()
231
+ self.raiz.deiconify()
232
+ self.raiz.lift()
233
+ if sys.platform != "linux":
234
+ self.raiz.attributes("-topmost", True)
235
+ else:
236
+ self.raiz.withdraw()
237
+ self.veu.withdraw()
238
+ except Exception:
239
+ pass # janela já fechando: nada a fazer
240
+ self.visivel = visivel
241
+
200
242
  def fechar(self) -> None:
201
243
  if not self._vivo:
202
244
  return
File without changes