primocode 10.15.0 → 10.15.2

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 v10.15.0
1
+ # PrimoCode v10.15.2
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 v10.15.0
12
+ ▐▛███▜▌ Primo Code v10.15.2
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/bin/primocode.js CHANGED
@@ -2519,6 +2519,7 @@ async function handleSlash(text) {
2519
2519
  break;
2520
2520
  }
2521
2521
  for (const a of prep.avisos) console.log(c.dim(` ${a}`));
2522
+ if (process.platform === 'darwin') { try { vozModo.instalarApp({ python: prep.python }); } catch { /* o app é bônus */ } }
2522
2523
 
2523
2524
  const sessao = vozModo.abrir({
2524
2525
  python: prep.python,
@@ -2922,7 +2923,14 @@ async function main() {
2922
2923
  r.processo.on('close', () => process.exit(0));
2923
2924
  return;
2924
2925
  }
2925
- console.log('Uso: primocode voz nome <Nome> | primocode voz painel (o resto é /voice dentro da sessão)');
2926
+ if (sub === 'instalar-app') {
2927
+ const py = vz.python();
2928
+ if (!py) { console.log('O app precisa do Python 3 (rode /voice uma vez para preparar o ambiente).'); process.exit(1); }
2929
+ const r = vz.instalarApp({ python: py, dock: resto.includes('--dock') });
2930
+ console.log(r.ok ? `Primo.app instalado em ${r.app}. Abra pelo Launchpad, Spotlight ou Finder.` : `Não deu: ${r.erro || 'veja o log'}`);
2931
+ process.exit(r.ok ? 0 : 1);
2932
+ }
2933
+ console.log('Uso: primocode voz nome <Nome> | primocode voz painel | primocode voz instalar-app [--dock] (o resto é /voice dentro da sessão)');
2926
2934
  process.exit(0);
2927
2935
  }
2928
2936
  if (['doutor', 'doctor', 'preparar'].includes(process.argv[2])) {
@@ -2953,7 +2961,11 @@ async function main() {
2953
2961
  state.workDirOrigem = 'padrao';
2954
2962
  // O projeto em que se estava na última sessão (até uma semana atrás): o
2955
2963
  // "continua o jogo" de amanhã cai nele, e não num projeto novo.
2956
- const lembrado = cfg.projetoLembrado();
2964
+ // O app do Primo (voz) manda a pasta de cada pedido: PRIMOCODE_PASTA (+ ORIGEM) e nunca herda a última do terminal.
2965
+ const lembrado = process.env.PRIMOCODE_APP === '1'
2966
+ ? (process.env.PRIMOCODE_PASTA && fs.existsSync(process.env.PRIMOCODE_PASTA)
2967
+ ? { dir: process.env.PRIMOCODE_PASTA, origem: process.env.PRIMOCODE_PASTA_ORIGEM === '/dir' ? '/dir' : 'projeto' } : null)
2968
+ : cfg.projetoLembrado();
2957
2969
  if (lembrado && pastaSegura(lembrado.dir)) {
2958
2970
  state.workDir = lembrado.dir;
2959
2971
  state.currentProject = path.basename(lembrado.dir);
@@ -2962,7 +2974,7 @@ async function main() {
2962
2974
 
2963
2975
  // Modo guardado entre sessões. O padrão é auto: sem ele, um "oi" gasta o
2964
2976
  // mesmo que uma refatoração.
2965
- state.mode = ['primocode', 'chat', 'auto'].includes(state.config.mode) ? state.config.mode : 'auto';
2977
+ state.mode = ['primocode', 'chat', 'auto'].includes(process.env.PRIMOCODE_MODO || state.config.mode) ? (process.env.PRIMOCODE_MODO || state.config.mode) : 'auto';
2966
2978
 
2967
2979
  // `-c` / `--continue` pode vir em qualquer posição e sai da lista: o
2968
2980
  // resto (one-shot, -f) segue sendo lido do jeito de sempre.
@@ -3007,6 +3019,9 @@ async function main() {
3007
3019
  if (typeof fl.resume === 'string') await retomarConversa(fl.resume);
3008
3020
  else if (continuar) await continuarUltima();
3009
3021
  state.semTerminal = true;
3022
+ if (process.env.PRIMOCODE_LOG_FERRAMENTAS === '1') { // o app do Primo registra cada ferramenta no log dele
3023
+ require('../lib/voz-ponte').escutar((nome, a) => process.stderr.write(`ferramenta: ${nome} ${JSON.stringify(a || {}).slice(0, 220)}\n`));
3024
+ }
3010
3025
  let erro = null;
3011
3026
  const texto = await H.capturar(async () => {
3012
3027
  try { await sendPrompt(pedido); } catch (e) { erro = e.message || String(e); }
package/lib/config.js CHANGED
@@ -16,7 +16,7 @@ const os = require('os');
16
16
  const { nomeCurto } = require('./nome-curto.js');
17
17
 
18
18
  const HOME = os.homedir();
19
- const ROOT_DIR = path.join(HOME, 'primocode');
19
+ const ROOT_DIR = process.env.PRIMOCODE_RAIZ || path.join(HOME, 'primocode'); // o app do Primo usa ~/Primo
20
20
  const CONFIG_DIR = path.join(HOME, '.primocode');
21
21
  const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
22
22
  const HISTORY_FILE = path.join(CONFIG_DIR, 'history.jsonl');
package/lib/conta.js CHANGED
@@ -213,6 +213,7 @@ async function entrar({ email, senha } = {}, op = {}) {
213
213
  }
214
214
 
215
215
  function abrirNoNavegador(url) {
216
+ if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return false; // app do Primo (voz): navegador só com clique explícito
216
217
  const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
217
218
  : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
218
219
  : ['xdg-open', [url]];
@@ -156,6 +156,7 @@ function registrar({ id, assunto, caminho, formato, duracao, assinatura, entrega
156
156
  }
157
157
  const listar = (arq) => ler(arq).filter((v) => v.caminho && fs.existsSync(v.caminho)).sort((a, b) => String(b.data).localeCompare(String(a.data)));
158
158
  function abrirNoSistema(alvo) {
159
+ if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return false; // app do Primo (voz): navegador só com clique explícito
159
160
  const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'cmd' : 'xdg-open';
160
161
  const args = process.platform === 'win32' ? ['/c', 'start', '""', alvo] : [alvo];
161
162
  return spawnSync(cmd, args, { stdio: 'ignore' }).status === 0;
package/lib/mcp.js CHANGED
@@ -204,6 +204,7 @@ function conectarHttp(def, { tetoMs = 60000, nome, buscar = globalThis.fetch } =
204
204
  const b64url = (b) => Buffer.from(b).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
205
205
 
206
206
  function abrirUrl(url) {
207
+ if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return false; // app do Primo (voz): navegador só com clique explícito
207
208
  const [cmd, args] = process.platform === 'darwin' ? ['open', [url]]
208
209
  : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', url]]
209
210
  : ['xdg-open', [url]];
package/lib/tools.js CHANGED
@@ -304,6 +304,7 @@ async function runShell(command, timeout = 60000, cwd, env) {
304
304
  // UM argumento, não texto que um shell reinterpreta — sem isso, um valor tipo
305
305
  // `https://x/"; touch /tmp/pwned; echo "` injetava comando (achado de segurança).
306
306
  function openInBrowser(target) {
307
+ if (process.env.PRIMOCODE_SEM_NAVEGADOR === '1') return Promise.resolve({ ok: false, error: 'o navegador está bloqueado no modo voz: só abre quando a pessoa clica' }); // app do Primo (voz): navegador só com clique explícito
307
308
  const p = os.platform();
308
309
  const [cmd, args, opener] = p === 'darwin' ? ['open', [target], 'open']
309
310
  : p === 'win32' ? ['cmd.exe', ['/c', 'start', '', target], 'start']
package/lib/voz.js CHANGED
@@ -322,6 +322,7 @@ function abrir({ python: py, servidor, token, voz, aoParar = () => {}, aoOuvir,
322
322
  const OPCIONAIS = () => [
323
323
  ['faster_whisper', 'faster-whisper', 'a transcrição local (Whisper)'],
324
324
  ['pysilero_vad', 'pysilero-vad', 'o fim de fala preciso (Silero VAD)'],
325
+ ['edge_tts', 'edge-tts', 'a voz neural pt-BR (Edge TTS)'],
325
326
  ...(process.platform === 'darwin' ? [
326
327
  ['WebKit', 'pyobjc-framework-WebKit', 'o painel em tela cheia do Primo'],
327
328
  ['EventKit', 'pyobjc-framework-EventKit', 'os compromissos de hoje do app Calendário'],
@@ -466,11 +467,34 @@ async function preparar(aoFalar = () => {}) {
466
467
  return { ok: true, python: py, avisos };
467
468
  }
468
469
 
470
+ /** Primo.app em ~/Applications (Launchpad/Spotlight) + o primo-app.json com os caminhos ABSOLUTOS de node e primocode:
471
+ * o app aberto pelo Finder não tem o PATH do terminal. Chamado no primeiro /voice e por `primocode voz instalar-app`. */
472
+ function instalarApp({ python: py, dock = false } = {}) {
473
+ if (process.platform !== 'darwin') return { ok: false, erro: 'o app do Primo é só para macOS' };
474
+ try {
475
+ let cfg = {};
476
+ try { cfg = require('./config').loadConfig(); } catch { /* usa o padrão do app */ }
477
+ const arq = path.join(os.homedir(), '.primocode', 'primo-app.json');
478
+ fs.mkdirSync(path.dirname(arq), { recursive: true });
479
+ fs.writeFileSync(arq, JSON.stringify({
480
+ node: process.execPath, primocode: path.resolve(__dirname, '..', 'bin', 'primocode.js'),
481
+ servidor: cfg.server || '', path: process.env.PATH || '', voz: cfg.voz || 'antonio', versao: require('../package.json').version,
482
+ }, null, 2));
483
+ const env = { ...process.env, PYTHONPATH: PASTA, ...(dock ? { PRIMOCODE_APP_DOCK: '1' } : {}) };
484
+ const linhas = execFileSync(py, ['-m', 'primo_voz.appmac'], { cwd: PASTA, env, encoding: 'utf8', timeout: 60000, stdio: ['ignore', 'pipe', 'ignore'] }).trim().split('\n');
485
+ return { ok: !!linhas[0], exe: linhas[0] || null, pythonpath: linhas[1] || PASTA, app: path.join(os.homedir(), 'Applications', 'Primo.app') };
486
+ } catch (e) { return { ok: false, erro: e.message }; }
487
+ }
488
+
469
489
  /** `/voice painel`: só o HUD (sem microfone). Mac com PyObjC = tela cheia; senão, o quadro no terminal. */
470
490
  function abrirPainel({ python: py }) {
471
491
  const mac = process.platform === 'darwin' && temModulo(py, 'WebKit');
472
- const p = spawn(py, ['-m', mac ? 'primo_voz.hud' : 'primo_voz.painel_tui', ...(mac ? ['--livre'] : [])], {
473
- cwd: PASTA, env: { ...process.env, PYTHONPATH: PASTA }, stdio: mac ? 'ignore' : ['pipe', 'inherit', 'inherit'],
492
+ // No Mac o painel roda pelo Primo.app (Dock e Cmd+Tab mostram "Primo" com o ícone); sem ele, o python direto.
493
+ let exe = null, pp = PASTA;
494
+ if (mac) { const r = instalarApp({ python: py }); if (r.exe) { exe = r.exe; pp = r.pythonpath; } }
495
+ const [cmd, args] = exe ? [exe, ['-m', 'primo_voz.hud', '--livre']] : [py, ['-m', mac ? 'primo_voz.hud' : 'primo_voz.painel_tui', ...(mac ? ['--livre'] : [])]];
496
+ const p = spawn(cmd, args, {
497
+ cwd: PASTA, env: { ...process.env, PYTHONPATH: exe ? pp : PASTA }, stdio: mac ? 'ignore' : ['pipe', 'inherit', 'inherit'],
474
498
  });
475
499
  return { processo: p, tela: mac };
476
500
  }
@@ -505,4 +529,4 @@ function linhasDoutor() {
505
529
  ];
506
530
  }
507
531
 
508
- module.exports = { definirNome, VENV, abrirPainel, linhasDoutor, DICA_COMPUTADOR, paraFalar, conferir, instalar, preparar, porqueOPipFalhou, abrir, python, PASTA };
532
+ module.exports = { instalarApp, definirNome, VENV, abrirPainel, linhasDoutor, DICA_COMPUTADOR, paraFalar, conferir, instalar, preparar, porqueOPipFalhou, abrir, python, PASTA };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.15.0",
3
+ "version": "10.15.2",
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": {
@@ -31,6 +31,7 @@ from __future__ import annotations
31
31
  import json
32
32
  import os
33
33
  import queue
34
+ import re
34
35
  import shutil
35
36
  import subprocess
36
37
  import sys
@@ -42,12 +43,36 @@ from pathlib import Path
42
43
  AQUI = Path(__file__).resolve().parent
43
44
  sys.path.insert(0, str(AQUI.parent))
44
45
 
45
- from primo_voz import conversa, escuta, identidade, painel, transcrever # noqa: E402
46
+ from primo_voz import conversa, escuta, fala, fala_pt, identidade, painel, transcrever # noqa: E402
46
47
  from primo_voz import tela as dados_tela # noqa: E402
47
48
  from primo_voz.luz import Luz # noqa: E402
48
49
  from primo_voz.sons import Sons # noqa: E402
49
50
 
50
51
 
52
+ class _PonteHud:
53
+ """No modo app o painel mora no processo-pai (app.py): as mensagens dele vão pelo stdout do protocolo."""
54
+
55
+ class _Entrada:
56
+ def write(self, linha: str) -> None:
57
+ try:
58
+ falar_para_o_node({"tipo": "hud", "msg": json.loads(linha)})
59
+ except ValueError:
60
+ pass
61
+
62
+ def flush(self) -> None:
63
+ pass
64
+
65
+ def close(self) -> None:
66
+ pass
67
+
68
+ def __init__(self) -> None:
69
+ self.stdin = self._Entrada()
70
+ self.stdout = iter(())
71
+
72
+ def poll(self):
73
+ return None
74
+
75
+
51
76
  class Bolinha:
52
77
  """A orb (orbe.py) num processo à parte: este só diz o estado a ela.
53
78
 
@@ -62,6 +87,9 @@ class Bolinha:
62
87
  if os.environ.get("PRIMOCODE_SEM_ORB") == "1":
63
88
  return
64
89
  self.ao_acao = None
90
+ if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
91
+ self.modulo, self.p = "primo_voz.hud", _PonteHud()
92
+ return
65
93
  # No Mac com PyObjC o rosto é o HUD (hud.py); senão, a orbe de antes.
66
94
  modulo = "primo_voz.orbe"
67
95
  if sys.platform == "darwin" and os.environ.get("PRIMOCODE_HUD") != "0":
@@ -72,11 +100,19 @@ class Bolinha:
72
100
  pass
73
101
  self.modulo = modulo
74
102
  try:
75
- self.p = subprocess.Popen([sys.executable, "-m", modulo], cwd=str(AQUI.parent),
103
+ cmd = [sys.executable, "-m", modulo]
104
+ env_extra = {"PYTHONPATH": str(AQUI.parent)}
105
+ if modulo.endswith("hud"):
106
+ from primo_voz import appmac # Primo.app: nome e ícone no Dock; cai no python se não der
107
+ exe = appmac.preparar()
108
+ if exe:
109
+ cmd = [exe, "-m", "primo_voz.hud"]
110
+ env_extra = {"PYTHONPATH": appmac.pythonpath()}
111
+ self.p = subprocess.Popen(cmd, cwd=str(AQUI.parent),
76
112
  stdin=subprocess.PIPE,
77
113
  stdout=subprocess.PIPE if modulo.endswith("hud") else subprocess.DEVNULL,
78
114
  stderr=subprocess.DEVNULL,
79
- env={**os.environ, "PYTHONPATH": str(AQUI.parent)}, text=True)
115
+ env={**os.environ, **env_extra}, text=True)
80
116
  if modulo.endswith("hud"):
81
117
  threading.Thread(target=self._ler_acoes, daemon=True).start()
82
118
  except Exception:
@@ -189,6 +225,12 @@ class Voz:
189
225
  self.local = transcrever.disponivel()
190
226
  if self.local:
191
227
  transcrever.aquecer()
228
+ self.edge = fala.Edge()
229
+ self.voz_id = self.cfg.get("voz") if self.cfg.get("voz") in fala.VOZES else "francisca"
230
+ self._t_acabou = None
231
+ self._pend = ""
232
+ self._pend_ate = 0.0
233
+ threading.Thread(target=self._pre_aquecer_frases, daemon=True).start()
192
234
  self.gravador = escuta.Gravador()
193
235
  self.gravador.vad = escuta.criar_vad()
194
236
  self.corte = escuta.Corte()
@@ -209,6 +251,10 @@ class Voz:
209
251
  self._ultimo_ok = ""
210
252
  self.bolinha.enviar({"nome": self.nome})
211
253
 
254
+ def lat(self, texto: str) -> None:
255
+ if os.environ.get("PRIMOCODE_LAT") == "1":
256
+ avisar("lat: " + texto)
257
+
212
258
  # ── o corpo: painel, luz e som reagem ao mesmo estado ───────────────
213
259
 
214
260
  def _estado(self, estado: str, nivel: float = None) -> None:
@@ -270,7 +316,7 @@ class Voz:
270
316
 
271
317
  def _parcial(self) -> None:
272
318
  """Transcrição ao vivo: a cada ~1,6 s de fala, uma passada no que já foi dito."""
273
- if not self.local or getattr(self, "_parcial_rodando", False):
319
+ if not self.local or getattr(self, "_parcial_rodando", False) or os.environ.get("PRIMOCODE_SEM_PARCIAL") == "1":
274
320
  return
275
321
  copia = list(self.gravador._blocos)
276
322
  if not copia:
@@ -328,14 +374,64 @@ class Voz:
328
374
  if not self._vivo:
329
375
  break
330
376
  if self.gravador.encerrou_por == "ninguem-falou" or self.gravador.segundos < 0.4:
377
+ if self._pend and time.time() >= self._pend_ate: # ninguém continuou: agora sim vira pedido
378
+ pend, self._pend = self._pend, ""
379
+ self.gravador.espera = escuta.ESPERA_COMECAR
380
+ self.lat(f"frase completa após a pausa extra: {pend!r}")
381
+ self._tratar(pend)
331
382
  continue
332
383
  wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
333
384
  if not wav:
334
385
  continue
386
+ self._t_acabou = time.time()
335
387
  texto = transcrever.limpar(self._transcrever(wav))
336
- self._tratar(texto)
388
+ self.lat(f"transcrição {time.time() - self._t_acabou:.2f}s (a frase fechou após {escuta.RABO_VAD if self.gravador.vad else escuta.RABO_SILENCIO:.1f}s de silêncio) -> {texto!r}")
389
+ self._juntar_e_tratar(texto)
337
390
  mic.fechar()
338
391
 
392
+ # "espera": a pessoa segura a vez. Palavras que, sozinhas no fim, prometem continuação.
393
+ _SEGURA = re.compile(r"(?:^|[\s,.])(?:espera|espere|pera ai|perai|aguarda|aguarde|calma|deixa eu pensar)[\s.!?,]*$", re.I)
394
+ _CONTINUA = re.compile(r"\b(?:e|ou|mas|ent[aã]o|que|tipo|porque|pois|a[ií]|pra|para|de|do|da|no|na|com|como|se|quando|ent[aã]o|tamb[eé]m|a|o|um|uma|meu|minha)\W*$", re.I)
395
+
396
+ def _incompleta(self, texto: str) -> bool:
397
+ t = texto.strip()
398
+ if not t:
399
+ return False
400
+ if t[-1] in ".?!":
401
+ return False
402
+ if t[-1] in ",;:…-" or self._CONTINUA.search(t):
403
+ return True
404
+ return len(t.split()) >= 5 # sem pontuação de fim numa frase já longa: provavelmente vem mais; comando curto não espera
405
+
406
+ def _juntar_e_tratar(self, texto: str) -> None:
407
+ """Fim de fala ADAPTATIVO: 1,1 s de silêncio já fechou; se a frase soa incompleta ("e…", "então…", sem ponto)
408
+ ou a pessoa disse "espera", ele segura mais um pouco e junta com o que vier. Nunca corta no meio."""
409
+ if self._pend:
410
+ texto = (self._pend + " " + texto).strip()
411
+ self._pend = ""
412
+ if not texto:
413
+ self.gravador.espera = escuta.ESPERA_COMECAR
414
+ self._tratar(texto)
415
+ return
416
+ if self._SEGURA.search(texto):
417
+ resto = self._SEGURA.sub("", texto).strip(" ,")
418
+ self._pend, self._pend_ate = resto or " ", time.time() + 10
419
+ self.gravador.espera = 10.0
420
+ self.lat("a pessoa pediu para esperar: segurando até 10 s")
421
+ self.bolinha.enviar({"transcricao": (resto + " …") if resto else "…", "parcial": True})
422
+ self._estado("ouvindo")
423
+ return
424
+ if self._incompleta(texto):
425
+ self._pend, self._pend_ate = texto, time.time() + 1.6
426
+ self._acordado_ate = max(self._acordado_ate, time.time() + 30)
427
+ self.gravador.espera = 1.6
428
+ self.lat(f"frase incompleta, esperando mais 1,6 s: {texto!r}")
429
+ self.bolinha.enviar({"transcricao": texto + " …", "parcial": True})
430
+ self._estado("ouvindo")
431
+ return
432
+ self.gravador.espera = escuta.ESPERA_COMECAR
433
+ self._tratar(texto)
434
+
339
435
  def _tratar(self, texto: str) -> None:
340
436
  """O que foi dito: ativação, apresentação, comando local ou pedido ao terminal."""
341
437
  if not texto:
@@ -345,6 +441,8 @@ class Voz:
345
441
  acordado = (self.modo == "sempre" or agora < self._acordado_ate or agora < self._ptt_ate)
346
442
  chamou, resto = conversa.ativacao(texto, self.nome)
347
443
  if not acordado and not chamou:
444
+ self.lat(f"ignorado (sem o nome \"{self.nome}\" e fora da conversa): {texto!r}")
445
+ self.bolinha.enviar({"transcricao": f"(diga \"{self.nome}\" antes do pedido) {texto}", "parcial": False})
348
446
  self._estado("dormindo") # não era comigo: não vira pedido
349
447
  return
350
448
  texto = resto if chamou else texto
@@ -365,6 +463,12 @@ class Voz:
365
463
  else:
366
464
  self._dizer(f"Prazer, {novo['pessoa']}! Vou lembrar.")
367
465
  return
466
+ nova = fala.voz_pedida(texto, self.voz_id)
467
+ if nova:
468
+ self.voz_id = nova
469
+ self.cfg = identidade.salvar(voz=nova)
470
+ self._dizer("Fechado. Agora estou com a voz do Antonio." if nova == "antonio" else f"Fechado. Agora estou com a voz da {nova.capitalize()}.")
471
+ return
368
472
  cmd = conversa.comando(texto)
369
473
  if cmd == "esconder":
370
474
  self.bolinha.enviar({"painel": "esconder"})
@@ -450,28 +554,89 @@ class Voz:
450
554
  except queue.Full:
451
555
  continue
452
556
 
557
+ def _cache_de(self, frase: str, hora: bool = False) -> "Path | None":
558
+ """Onde fica a versão em cache de uma frase curta (None se for longa demais para valer a pena)."""
559
+ if len(frase) > 90 or self.cfg.get("tts", "edge") == "elevenlabs":
560
+ return None
561
+ import hashlib
562
+ pasta = Path.home() / ".primocode" / "voz-cache"
563
+ pasta.mkdir(parents=True, exist_ok=True)
564
+ if hora:
565
+ return pasta / ("hora-" + self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:10] + ".mp3")
566
+ return pasta / (self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:16] + ".mp3")
567
+
568
+ def _cache_horas(self) -> None:
569
+ agora = time.time()
570
+ for dm in (0, 1, 2):
571
+ f = " ".join(fala_pt.normalizar(fala.hora_falada(time.localtime(agora + 60 * dm))).split())
572
+ c = self._cache_de(f, hora=True)
573
+ if c is not None and not c.exists():
574
+ self.edge.sintetizar(f, self.voz_id, c)
575
+
576
+ def _pre_aquecer_frases(self) -> None:
577
+ """As frases que o Primo mais diz entram no cache em segundo plano: a confirmação sai na hora."""
578
+ self.edge.aquecer()
579
+ self._cache_horas()
580
+ for f in ("Feito.", "Aumentei.", "Abaixei.", "Pulei.", "Voltei.",
581
+ "Oi! Fala.", "Pois não?", "Tô aqui.", "Não consegui uma resposta agora."):
582
+ c = self._cache_de(f)
583
+ if c is not None and not c.exists():
584
+ self.edge.sintetizar(f, self.voz_id, c)
585
+ while self._vivo: # a hora do minuto atual e dos próximos: "que horas são?" sai sem esperar a rede
586
+ self._cache_horas()
587
+ for velho in (Path.home() / ".primocode" / "voz-cache").glob("hora-*.mp3"): # só os minutos que já passaram
588
+ try:
589
+ if time.time() - velho.stat().st_mtime > 600:
590
+ velho.unlink()
591
+ except OSError:
592
+ pass
593
+ time.sleep(20)
594
+
453
595
  def _sintetizar(self, frase: str) -> "Path | None":
454
- limpo = " ".join(str(frase).split())[:600]
596
+ limpo = " ".join(fala_pt.normalizar(frase).split())[:900] # números, unidades, siglas: por extenso, como se fala
455
597
  if not limpo:
456
598
  return None
599
+ t0 = time.time()
600
+ self.lat(f"vai falar: {limpo[:140]!r}")
457
601
  mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
602
+ # 0. frase curta já dita antes (ou pré-aquecida): sai do cache, sem rede
603
+ guardada = self._cache_de(limpo)
604
+ if guardada is not None and not guardada.exists():
605
+ guardada = self._cache_de(limpo, hora=True) if self._cache_de(limpo, hora=True).exists() else guardada
606
+ if guardada is not None and guardada.exists():
607
+ shutil.copyfile(guardada, mp3)
608
+ self.lat(f"voz do cache ({self.voz_id}): {time.time() - t0:.3f}s")
609
+ return mp3
610
+ # 1. Edge TTS neural pt-BR, no próprio processo (sem subprocesso por frase)
611
+ if self.cfg.get("tts", "edge") != "elevenlabs" and self.edge.sintetizar(limpo, self.voz_id, mp3) and mp3.exists():
612
+ self.lat(f"voz edge/{self.voz_id}: {time.time() - t0:.2f}s para {len(limpo)} caracteres")
613
+ if guardada is not None:
614
+ try:
615
+ shutil.copyfile(mp3, guardada)
616
+ except OSError:
617
+ pass
618
+ return mp3
619
+ # 2. ElevenLabs, só se a pessoa pediu
458
620
  narrar = AQUI.parent.parent / "studio" / "narrar.py"
459
- try:
460
- r = subprocess.run(
461
- [sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz],
462
- capture_output=True, timeout=180)
463
- except Exception as e:
464
- avisar(f"não deu para gerar a voz: {e}")
465
- return None
466
- if r.returncode != 0 or not mp3.exists():
467
- if sys.platform == "darwin" and shutil.which("say"): # reserva: a voz do sistema
468
- aiff = mp3.with_suffix(".aiff")
469
- if subprocess.run(["say", "-o", str(aiff), limpo], capture_output=True).returncode == 0:
470
- return aiff
471
- avisar(motivo_da_narracao(r))
472
- self._erro("Não consegui gerar a voz. O texto está no painel.")
473
- return None
474
- return mp3
621
+ r = None
622
+ if self.cfg.get("tts") == "elevenlabs":
623
+ try:
624
+ r = subprocess.run([sys.executable, str(narrar), "falar", "--texto", limpo, "--saida", str(mp3), "--voz", self.voz_id],
625
+ capture_output=True, timeout=60)
626
+ except Exception as e:
627
+ avisar(f"não deu para gerar a voz: {e}")
628
+ if r is not None and r.returncode == 0 and mp3.exists():
629
+ return mp3
630
+ # 3. offline: a voz do sistema
631
+ if sys.platform == "darwin" and shutil.which("say"):
632
+ aiff = mp3.with_suffix(".aiff")
633
+ if subprocess.run(["say", "-v", "Luciana", "-o", str(aiff), limpo], capture_output=True).returncode == 0 or \
634
+ subprocess.run(["say", "-o", str(aiff), limpo], capture_output=True).returncode == 0:
635
+ self.lat(f"voz offline (say): {time.time() - t0:.2f}s")
636
+ return aiff
637
+ avisar("não deu para gerar a voz (sem internet e sem voz do sistema). O texto está no painel.")
638
+ self._erro("Não consegui gerar a voz. O texto está no painel.")
639
+ return None
475
640
 
476
641
  def _tocar_em_fila(self) -> None:
477
642
  while self._vivo:
@@ -502,7 +667,7 @@ class Voz:
502
667
  self._falando = False
503
668
  self.maquina.mandar("fim_fala")
504
669
  self._estado("ouvindo" if self.modo == "sempre" or time.time() < self._acordado_ate else "dormindo")
505
- self._acordado_ate = max(self._acordado_ate, time.time() + 20)
670
+ self._acordado_ate = max(self._acordado_ate, time.time() + 90) # conversa em andamento: 90 s sem repetir o nome
506
671
  self.sons.tocar("pronto")
507
672
  self.corte.nivel_dele = 0.0
508
673
  self._esvaziar_blocos() # o que o microfone pegou era a voz dele
@@ -528,6 +693,11 @@ class Voz:
528
693
  except (FileNotFoundError, OSError):
529
694
  continue
530
695
  t0 = time.time()
696
+ if self._t_acabou:
697
+ self.lat(f"fim da fala -> primeira palavra falada: {t0 - self._t_acabou:.2f}s (+ o silêncio que fechou a frase)")
698
+ self._t_acabou = None
699
+ if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
700
+ avisar(f"falando ({cmd[0]}): {arquivo.name}")
531
701
  while p.poll() is None:
532
702
  if env:
533
703
  i = int((time.time() - t0) / 0.05)
@@ -541,6 +711,8 @@ class Voz:
541
711
  pass
542
712
  return
543
713
  time.sleep(0.05)
714
+ if os.environ.get("PRIMOCODE_HUD_PONTE") == "1":
715
+ avisar(f"falou por {time.time() - t0:.1f}s (saída {p.returncode})")
544
716
  if p.returncode == 0:
545
717
  return
546
718
  # INICIAR não é TOCAR: o tocador que abriu e morreu não conta, e o
@@ -565,7 +737,10 @@ class Voz:
565
737
  tipo = msg.get("tipo")
566
738
  if tipo == "falar":
567
739
  texto = str(msg.get("texto") or "")
568
- if msg.get("bruto"):
740
+ if msg.get("painel"): # o cérebro já mandou o texto FALADO e o ESCRITO separados
741
+ self.bolinha.enviar({"resposta": str(msg["painel"])[:1500]})
742
+ self._acordado_ate = time.time() + 90
743
+ elif msg.get("bruto"):
569
744
  # a voz resume; o texto inteiro (código, listas) vai ao painel
570
745
  fala, tela = conversa.resumo(texto)
571
746
  self.bolinha.enviar({"resposta": tela[:700]})
@@ -592,6 +767,8 @@ class Voz:
592
767
  ("dormindo" if est == "esperando" else est))
593
768
  if est == "esperando":
594
769
  self.sons.tocar("pronto", falando=self._falando)
770
+ elif tipo == "acao":
771
+ self._acao_do_painel(str(msg.get("acao") or ""))
595
772
  elif tipo == "painel":
596
773
  self.bolinha.enviar({"painel": msg.get("acao", "alternar")})
597
774
  elif tipo == "fim":