primocode 10.15.1 → 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.1
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.1
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
@@ -2961,7 +2961,11 @@ async function main() {
2961
2961
  state.workDirOrigem = 'padrao';
2962
2962
  // O projeto em que se estava na última sessão (até uma semana atrás): o
2963
2963
  // "continua o jogo" de amanhã cai nele, e não num projeto novo.
2964
- 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();
2965
2969
  if (lembrado && pastaSegura(lembrado.dir)) {
2966
2970
  state.workDir = lembrado.dir;
2967
2971
  state.currentProject = path.basename(lembrado.dir);
@@ -2970,7 +2974,7 @@ async function main() {
2970
2974
 
2971
2975
  // Modo guardado entre sessões. O padrão é auto: sem ele, um "oi" gasta o
2972
2976
  // mesmo que uma refatoração.
2973
- 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';
2974
2978
 
2975
2979
  // `-c` / `--continue` pode vir em qualquer posição e sai da lista: o
2976
2980
  // resto (one-shot, -f) segue sendo lido do jeito de sempre.
@@ -3015,6 +3019,9 @@ async function main() {
3015
3019
  if (typeof fl.resume === 'string') await retomarConversa(fl.resume);
3016
3020
  else if (continuar) await continuarUltima();
3017
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
+ }
3018
3025
  let erro = null;
3019
3026
  const texto = await H.capturar(async () => {
3020
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "10.15.1",
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": {
@@ -43,7 +43,7 @@ from pathlib import Path
43
43
  AQUI = Path(__file__).resolve().parent
44
44
  sys.path.insert(0, str(AQUI.parent))
45
45
 
46
- from primo_voz import conversa, escuta, fala, identidade, painel, transcrever # noqa: E402
46
+ from primo_voz import conversa, escuta, fala, fala_pt, identidade, painel, transcrever # noqa: E402
47
47
  from primo_voz import tela as dados_tela # noqa: E402
48
48
  from primo_voz.luz import Luz # noqa: E402
49
49
  from primo_voz.sons import Sons # noqa: E402
@@ -228,6 +228,8 @@ class Voz:
228
228
  self.edge = fala.Edge()
229
229
  self.voz_id = self.cfg.get("voz") if self.cfg.get("voz") in fala.VOZES else "francisca"
230
230
  self._t_acabou = None
231
+ self._pend = ""
232
+ self._pend_ate = 0.0
231
233
  threading.Thread(target=self._pre_aquecer_frases, daemon=True).start()
232
234
  self.gravador = escuta.Gravador()
233
235
  self.gravador.vad = escuta.criar_vad()
@@ -372,6 +374,11 @@ class Voz:
372
374
  if not self._vivo:
373
375
  break
374
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)
375
382
  continue
376
383
  wav = self.gravador.gravar_wav(Path(tempfile.gettempdir()) / "primo-voz-fala.wav")
377
384
  if not wav:
@@ -379,9 +386,52 @@ class Voz:
379
386
  self._t_acabou = time.time()
380
387
  texto = transcrever.limpar(self._transcrever(wav))
381
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}")
382
- self._tratar(texto)
389
+ self._juntar_e_tratar(texto)
383
390
  mic.fechar()
384
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
+
385
435
  def _tratar(self, texto: str) -> None:
386
436
  """O que foi dito: ativação, apresentação, comando local ou pedido ao terminal."""
387
437
  if not texto:
@@ -391,6 +441,8 @@ class Voz:
391
441
  acordado = (self.modo == "sempre" or agora < self._acordado_ate or agora < self._ptt_ate)
392
442
  chamou, resto = conversa.ativacao(texto, self.nome)
393
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})
394
446
  self._estado("dormindo") # não era comigo: não vira pedido
395
447
  return
396
448
  texto = resto if chamou else texto
@@ -502,22 +554,22 @@ class Voz:
502
554
  except queue.Full:
503
555
  continue
504
556
 
505
- def _cache_de(self, frase: str) -> "Path | None":
557
+ def _cache_de(self, frase: str, hora: bool = False) -> "Path | None":
506
558
  """Onde fica a versão em cache de uma frase curta (None se for longa demais para valer a pena)."""
507
559
  if len(frase) > 90 or self.cfg.get("tts", "edge") == "elevenlabs":
508
560
  return None
509
561
  import hashlib
510
562
  pasta = Path.home() / ".primocode" / "voz-cache"
511
563
  pasta.mkdir(parents=True, exist_ok=True)
512
- if re.match(r"^São \d+ (?:e \d\d|horas em ponto)\.$", frase):
564
+ if hora:
513
565
  return pasta / ("hora-" + self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:10] + ".mp3")
514
566
  return pasta / (self.voz_id + "-" + hashlib.sha1(frase.encode("utf-8")).hexdigest()[:16] + ".mp3")
515
567
 
516
568
  def _cache_horas(self) -> None:
517
569
  agora = time.time()
518
570
  for dm in (0, 1, 2):
519
- f = fala.hora_falada(time.localtime(agora + 60 * dm))
520
- c = self._cache_de(f)
571
+ f = " ".join(fala_pt.normalizar(fala.hora_falada(time.localtime(agora + 60 * dm))).split())
572
+ c = self._cache_de(f, hora=True)
521
573
  if c is not None and not c.exists():
522
574
  self.edge.sintetizar(f, self.voz_id, c)
523
575
 
@@ -525,7 +577,7 @@ class Voz:
525
577
  """As frases que o Primo mais diz entram no cache em segundo plano: a confirmação sai na hora."""
526
578
  self.edge.aquecer()
527
579
  self._cache_horas()
528
- for f in ("Já vejo.", "Deixa eu ver.", "Um instante.", "Pode deixar.", "Feito.", "Aumentei.", "Abaixei.", "Pulei.", "Voltei.",
580
+ for f in ("Feito.", "Aumentei.", "Abaixei.", "Pulei.", "Voltei.",
529
581
  "Oi! Fala.", "Pois não?", "Tô aqui.", "Não consegui uma resposta agora."):
530
582
  c = self._cache_de(f)
531
583
  if c is not None and not c.exists():
@@ -541,13 +593,16 @@ class Voz:
541
593
  time.sleep(20)
542
594
 
543
595
  def _sintetizar(self, frase: str) -> "Path | None":
544
- limpo = " ".join(str(frase).split())[:600]
596
+ limpo = " ".join(fala_pt.normalizar(frase).split())[:900] # números, unidades, siglas: por extenso, como se fala
545
597
  if not limpo:
546
598
  return None
547
599
  t0 = time.time()
600
+ self.lat(f"vai falar: {limpo[:140]!r}")
548
601
  mp3 = Path(tempfile.gettempdir()) / f"primo-voz-{int(time.time() * 1000)}.mp3"
549
602
  # 0. frase curta já dita antes (ou pré-aquecida): sai do cache, sem rede
550
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
551
606
  if guardada is not None and guardada.exists():
552
607
  shutil.copyfile(guardada, mp3)
553
608
  self.lat(f"voz do cache ({self.voz_id}): {time.time() - t0:.3f}s")
@@ -612,7 +667,7 @@ class Voz:
612
667
  self._falando = False
613
668
  self.maquina.mandar("fim_fala")
614
669
  self._estado("ouvindo" if self.modo == "sempre" or time.time() < self._acordado_ate else "dormindo")
615
- 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
616
671
  self.sons.tocar("pronto")
617
672
  self.corte.nivel_dele = 0.0
618
673
  self._esvaziar_blocos() # o que o microfone pegou era a voz dele
@@ -682,7 +737,10 @@ class Voz:
682
737
  tipo = msg.get("tipo")
683
738
  if tipo == "falar":
684
739
  texto = str(msg.get("texto") or "")
685
- 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"):
686
744
  # a voz resume; o texto inteiro (código, listas) vai ao painel
687
745
  fala, tela = conversa.resumo(texto)
688
746
  self.bolinha.enviar({"resposta": tela[:700]})
@@ -51,12 +51,12 @@ def _sem_acento(t: str) -> str:
51
51
  return "".join(ch for ch in unicodedata.normalize("NFD", t.lower()) if unicodedata.category(ch) != "Mn")
52
52
 
53
53
 
54
+ RAIZ = HOME / "Primo" # a área do app: projetos novos por voz nascem em ~/Primo/<nome>
55
+
56
+
54
57
  def pasta_atual() -> Path:
55
- d = _json(PASTA_PC / "projeto-ativo.json").get("dir")
56
- if d and Path(d).is_dir():
57
- return Path(d)
58
- pc = HOME / "primocode"
59
- return pc if pc.is_dir() else HOME
58
+ RAIZ.mkdir(exist_ok=True)
59
+ return RAIZ
60
60
 
61
61
 
62
62
  def gravar_pasta(d: Path) -> None:
@@ -72,7 +72,7 @@ def achar_projeto(nome: str) -> "Path | None":
72
72
  if not palavras:
73
73
  return None
74
74
  cand = [Path(x) for x in (_json(PASTA_PC / "projeto-ativo.json").get("recentes") or [])]
75
- for base in (HOME / "primocode", HOME):
75
+ for base in (RAIZ, HOME / "primocode", HOME):
76
76
  try:
77
77
  cand += [c for c in base.iterdir() if c.is_dir() and not c.name.startswith(".")]
78
78
  except OSError:
@@ -84,7 +84,9 @@ def achar_projeto(nome: str) -> "Path | None":
84
84
  return None
85
85
 
86
86
 
87
- PEDIDO_PASTA = re.compile(r"^(?:abre|abrir|abra|troca|trocar|muda|mudar|vai|ir)\s+(?:para|pra|pro|no|na|o|a)?\s*(?:o\s+|a\s+)?(?:projeto|pasta)\s+(.+?)[.!?]*$", re.I)
87
+ PEDIDO_PASTA = re.compile(r"^(?:(?:abre|abrir|abra|troca|trocar|muda|mudar|vai|ir)\s+(?:para|pra|pro|no|na|o|a)?\s*(?:o\s+|a\s+)?(?:projeto|pasta)\s+|trabalha(?:r)?\s+(?:no|na|em)\s+(?:o\s+|a\s+)?(?:projeto\s+|pasta\s+)?)(.+?)[.!?]*$", re.I)
88
+ VERBO_ACAO = re.compile(r"^(?:e\s+|ent[aã]o\s+|agora\s+)?(?:cria|crie|criar|faz|fa[cç]a|fazer|monta|monte|gera|gere|escreve|escreva|edita|edite|muda|mude|corrige|corrija|conserta|conserte|instala|instale|roda|rode|executa|execute|abre|abra|apaga|apague|deleta|enxuga|enxugue|atualiza|atualize|adiciona|adicione|remove|remova|refatora|publica|publique|coloca|ponha|p[oõ]e|troca|continua|continue|pode ser|sim|isso|ok|beleza|manda|bora|vai)\b", re.I)
89
+ PEDE_NAVEGADOR = re.compile(r"\babr(?:e|a|ir)\b|\bmostr(?:a|e|ar)\b|navegador|visualiz|preview|pr[eé]-?visualiza", re.I)
88
90
 
89
91
 
90
92
  def _hora_falada(t: "time.struct_time") -> str:
@@ -92,12 +94,51 @@ def _hora_falada(t: "time.struct_time") -> str:
92
94
  return hora_falada(t)
93
95
 
94
96
 
97
+ _OBJETOS = {"site": "o site", "pagina": "a página", "landing": "a landing page", "app": "o app", "aplicativo": "o app", "video": "o vídeo",
98
+ "arquivo": "o arquivo", "pasta": "a pasta", "projeto": "o projeto", "script": "o script", "teste": "os testes", "testes": "os testes",
99
+ "bug": "o bug", "erro": "o erro", "commit": "o commit", "deploy": "o deploy", "codigo": "o código", "documento": "o documento",
100
+ "relatorio": "o relatório", "planilha": "a planilha", "logo": "a logo", "imagem": "a imagem", "motion": "o motion",
101
+ "servidor": "o servidor", "login": "o login", "cardapio": "o cardápio", "botao": "o botão", "layout": "o layout"}
102
+ _ACKS = [ # (verbos, variações; {o} = "o site", {de} = "do site", {em} = "no site")
103
+ (r"cri|mont|ger|faz|fa[cç]|escrev|constru|desenh", ["Bora, vou criar {o}.", "Beleza, já começo {o}.", "Fechado, vou montar {o}.", "Pode deixar, vou fazer {o}.", "Show, vou criar {o} agora."]),
104
+ (r"abr|mostr|exib", ["Deixa eu abrir {o}.", "Já abro {o}.", "Vou abrir {o} pra você.", "Um segundo, abrindo {o}."]),
105
+ (r"rod|execut|test|ver(?:ific)", ["Vou rodar isso agora.", "Rodando, já te falo.", "Deixa eu rodar {o}.", "Já executo e te conto."]),
106
+ (r"corrig|conserta|arrum|resolv|debug", ["Vou dar uma olhada {em} e consertar.", "Já vou atrás desse problema.", "Deixa eu ver o que aconteceu {em}."]),
107
+ (r"edit|mud|troc|atualiz|ajust|coloc|pon|adicion|remov|apag", ["Vou ajustar {o} agora.", "Beleza, já mexo {em}.", "Pode deixar, já faço essa mudança.", "Já altero isso."]),
108
+ (r"instal|public|sub[ae]|deploy|mand", ["Vou cuidar disso agora.", "Já começo por aqui.", "Deixa comigo, já faço."]),
109
+ (r"procur|busc|pesquis|analis|olh|le[ia]|explor", ["Vou dar uma olhada {em}.", "Deixa eu procurar isso.", "Já vou investigar."]),
110
+ ]
111
+ _ACK_PADRAO = ["Já cuido disso.", "Deixa comigo.", "Pode deixar, já começo.", "Beleza, vou resolver isso."]
112
+
113
+
114
+ def ack_contextual(texto: str, ultimo: str = "", sorteio=None) -> str:
115
+ """O "já vou" de uma TAREFA demorada, escrito a partir do pedido (verbo e objeto) e nunca igual ao anterior."""
116
+ import random
117
+ sorte = sorteio or random.choice
118
+ t = _sem_acento(texto)
119
+ obj = next((_OBJETOS[w] for w in re.findall(r"[a-z]+", t) if w in _OBJETOS), "")
120
+ grupo = _ACK_PADRAO
121
+ primeira = re.findall(r"[a-z]+", t)[:4]
122
+ for rx, frases in _ACKS:
123
+ if any(re.match(rx, w) for w in primeira):
124
+ grupo = frases
125
+ break
126
+ pool = [f for f in grupo if f != ultimo and ("{" not in f or obj or True)] or grupo
127
+ f = sorte(pool)
128
+ if "{" in f and not obj:
129
+ f = sorte([x for x in grupo if "{" not in x and x != ultimo] or _ACK_PADRAO)
130
+ return f.format(o=obj, de=obj.replace("o ", "do ", 1).replace("a ", "da ", 1).replace("os ", "dos ", 1) if obj else "", em=("n" + obj) if obj else "disso")
131
+
132
+
95
133
  class App:
96
134
  def __init__(self) -> None:
97
135
  self.cfg = _json(PASTA_PC / "primo-app.json")
98
136
  self.node = self.cfg.get("node") or ""
99
137
  self.primocode = self.cfg.get("primocode") or ""
100
138
  self.pasta = pasta_atual()
139
+ self.pasta_modo = "raiz" # raiz (~/Primo, projetos novos) | projeto (o do último pedido) | /dir (escolhida por voz)
140
+ self.acao_antes = False
141
+ self.ultimo_ack = ""
101
142
  self.motor: "subprocess.Popen | None" = None
102
143
  self.cerebro: "subprocess.Popen | None" = None
103
144
  self.entrada: "queue.Queue" = queue.Queue()
@@ -112,6 +153,9 @@ class App:
112
153
  while True:
113
154
  yield self.entrada.get()
114
155
 
156
+ def rotulo(self) -> str:
157
+ return "~/Primo" if self.pasta == RAIZ else self.pasta.name
158
+
115
159
  def painel(self, msg: dict) -> None:
116
160
  self.entrada.put(json.dumps(msg, ensure_ascii=False))
117
161
 
@@ -179,7 +223,7 @@ class App:
179
223
  self.painel(m.get("msg") or {})
180
224
  elif t == "pronto":
181
225
  log("motor de voz: PRONTO (microfone aberto)")
182
- self.painel({"pasta": self.pasta.name, "login": logado()})
226
+ self.painel({"pasta": "~/Primo", "login": logado()})
183
227
  elif t == "erro":
184
228
  log("motor de voz: erro: " + str(m.get("texto")))
185
229
  self.painel({"resposta": str(m.get("texto"))})
@@ -250,9 +294,8 @@ class App:
250
294
  if m:
251
295
  d = achar_projeto(m.group(1))
252
296
  if d:
253
- self.pasta = d
297
+ self.pasta, self.pasta_modo = d, "/dir"
254
298
  gravar_pasta(d)
255
- self.turnos = 0
256
299
  self.painel({"pasta": d.name})
257
300
  log(f"pasta: {d}")
258
301
  self.dizer(f"Fechado. Agora trabalho no {d.name}.")
@@ -269,8 +312,6 @@ class App:
269
312
  log(f"lat: resposta LOCAL em {time.time() - t_ini:.2f}s: {local!r}")
270
313
  self.dizer(local)
271
314
  return
272
- if len(texto.split()) >= 3: # pedido que vai ao agente: confirma na hora, enquanto ele pensa
273
- self.motor_envia({"tipo": "falar", "texto": ["Já vejo.", "Deixa eu ver.", "Um instante.", "Pode deixar."][self.turnos % 4]})
274
315
  if not (self.node and self.primocode and Path(self.primocode).exists()):
275
316
  log("cérebro: node/primocode não registrados em primo-app.json")
276
317
  self.dizer("Não achei o PrimoCode instalado. Rode primocode voz instalar-app no terminal.")
@@ -279,18 +320,58 @@ class App:
279
320
  self.painel({"login": False})
280
321
  self.dizer("Preciso que você entre na conta do PrimoCode. Clique em Entrar.")
281
322
  return
323
+ acao = bool(VERBO_ACAO.match(_sem_acento(texto).strip())) or bool(re.search(r"\b(?:site|pagina|landing|projeto|arquivo|pasta|codigo|bug|teste|commit|deploy|app|aplicativo|script)s?\b", _sem_acento(texto))) or (self.acao_antes and not re.match(r"^(?:o que|qual|quem|como|por ?que|quando|onde|quanto)\b", _sem_acento(texto).strip()))
324
+ self.acao_antes = acao
325
+ pedido = texto
326
+ pedido += ('\n\n(Modo voz. Termine SEMPRE com uma única linha de JSON: {"falar": "...", "painel": "..."}. '
327
+ '"falar" é o que será DITO em voz alta: português natural de conversa, 1 a 4 frases curtas, sem markdown, sem código, '
328
+ 'sem caminhos de arquivo, sem siglas nem abreviações; números e unidades por extenso como se fala '
329
+ '("trinta e dois segundos", "dez pixels", "noventa e nove reais", "versão dez ponto quinze"); nomes técnicos pronunciáveis; '
330
+ 'diga o que você fez de fato. "painel" é a resposta escrita completa, com markdown e detalhes.)')
331
+ if acao:
332
+ pedido += ("\n\n(Pedido por VOZ. Execute de verdade com as ferramentas, sem pedir confirmação, a menos que seja apagar, publicar ou enviar algo. "
333
+ "Projetos novos ficam em ~/Primo/<nome curto>. Ao terminar, responda em português falado, 2 a 4 frases, dizendo o que fez DE FATO: "
334
+ "pastas e arquivos criados, onde ficaram e o que abriu. Não use listas nem código na resposta.)")
282
335
  with self.trava:
283
- args = [self.node, self.primocode, "-p", texto, "--output-format", "json"] + (["-c"] if self.turnos else [])
284
- log("cérebro: " + " ".join(args[:3]) + " ... cwd=" + str(self.pasta))
336
+ env = self.ambiente()
337
+ env.update({"PRIMOCODE_APP": "1", "PRIMOCODE_RAIZ": str(RAIZ), "PRIMOCODE_LOG_FERRAMENTAS": "1",
338
+ "PRIMOCODE_SEM_NAVEGADOR": "0" if (PEDE_NAVEGADOR.search(texto) or (acao and re.search(r"site|p[aá]gina|landing|html", texto, re.I))) else "1"})
339
+ if acao:
340
+ env["PRIMOCODE_MODO"] = "primocode"
341
+ if self.pasta != RAIZ:
342
+ env["PRIMOCODE_PASTA"], env["PRIMOCODE_PASTA_ORIGEM"] = str(self.pasta), ("/dir" if self.pasta_modo == "/dir" else "projeto")
343
+ args = [self.node, self.primocode, "-p", pedido, "--output-format", "json"] + (["-c"] if self.turnos else [])
344
+ log(f"cérebro: modo={'AÇÃO' if acao else 'conversa'} cwd={self.pasta} pasta_modo={self.pasta_modo} navegador={'sim' if env['PRIMOCODE_SEM_NAVEGADOR'] == '0' else 'não'} continua={bool(self.turnos)}")
285
345
  t0 = time.time()
346
+
347
+ def ack_tardio(): # tarefa: se o agente passou de ~1,2 s, diz o que vai fazer (conversa e pergunta rápida não ganham ack)
348
+ if acao and self.cerebro and self.cerebro.poll() is None:
349
+ a = ack_contextual(texto, self.ultimo_ack)
350
+ self.ultimo_ack = a
351
+ log(f"ack: {a!r}")
352
+ self.motor_envia({"tipo": "falar", "texto": a})
353
+ timer = threading.Timer(1.2, ack_tardio)
354
+ timer.start()
286
355
  try:
287
- self.cerebro = subprocess.Popen(args, cwd=str(self.pasta), env=self.ambiente(), text=True,
288
- stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
356
+ self.cerebro = subprocess.Popen(args, cwd=str(self.pasta), env=env, text=True,
357
+ stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
358
+ start_new_session=True) # grupo próprio: nada do que o agente faça com processos atinge o app
289
359
  out, err = self.cerebro.communicate(timeout=900)
360
+ log(f"cérebro: saiu com código {self.cerebro.returncode}")
361
+ timer.cancel()
290
362
  except Exception as e:
363
+ timer.cancel()
291
364
  log(f"cérebro: falhou: {e}")
292
365
  self.dizer("O cérebro não respondeu.")
293
366
  return
367
+ for l in (err or "").splitlines():
368
+ if l.startswith("ferramenta:"):
369
+ log(l[:300])
370
+ pj = _json(PASTA_PC / "projeto-ativo.json") # o agente criou/trocou de projeto? o painel e o próximo pedido seguem nele
371
+ if pj.get("dir") and pj.get("quando", 0) / 1000 >= t0 and Path(pj["dir"]).is_dir() and Path(pj["dir"]) not in (self.pasta, RAIZ):
372
+ self.pasta, self.pasta_modo = Path(pj["dir"]), "projeto"
373
+ self.painel({"pasta": self.rotulo()})
374
+ log(f"pasta do projeto agora: {self.pasta}")
294
375
  self.turnos += 1
295
376
  resp = ""
296
377
  for l in reversed((out or "").strip().splitlines()):
@@ -300,17 +381,37 @@ class App:
300
381
  break
301
382
  except ValueError:
302
383
  continue
303
- if err and err.strip():
304
- log("cérebro stderr: " + err.strip()[-400:])
384
+ sobra = "\n".join(l for l in (err or "").splitlines() if not l.startswith("ferramenta:")).strip()
385
+ if sobra:
386
+ log("cérebro stderr: " + sobra[-400:])
305
387
  # tira os enfeites do terminal (⎿ linhas de status, ● marcador) — a voz fala só o texto
306
- resp = "\n".join(l for l in resp.splitlines() if not l.lstrip().startswith("⎿")).replace("●", "").strip()
388
+ resp = "\n".join(l for l in resp.splitlines() if not l.lstrip().startswith("⎿") and not re.match(r"^\s*(?:Projeto novo:|[A-Z][A-Za-z_]{2,12}\s{2,}\S)", l)).replace("●", "").strip()
307
389
  log(f"lat: cérebro (agente) {time.time() - t0:.1f}s, {len(resp)} caracteres: {resp[:160]!r}")
308
390
  if not resp.strip():
309
391
  self.dizer("Não consegui uma resposta agora.")
310
392
  return
311
- self.motor_envia({"tipo": "falar", "texto": resp, "bruto": True})
393
+ falar, painel = self.separar(resp)
394
+ self.motor_envia({"tipo": "falar", "texto": falar, "painel": painel})
312
395
  self.motor_envia({"tipo": "estado", "estado": "esperando"})
313
396
 
397
+ @staticmethod
398
+ def separar(resp: str) -> "tuple[str, str]":
399
+ """O cérebro devolve {"falar","painel"} (o terminal quebra a linha no meio do JSON: junta e lê). Sem JSON, vale o texto todo."""
400
+ corpo = re.sub(r"\s*\n\s*", " ", resp)
401
+ i, j = corpo.find('{"falar"'), corpo.rfind("}")
402
+ if i >= 0 and j > i:
403
+ try:
404
+ d = json.loads(corpo[i:j + 1], strict=False)
405
+ if str(d.get("falar") or "").strip():
406
+ antes = resp[: resp.find('{"falar"')].strip() if '{"falar"' in resp else ""
407
+ return str(d["falar"]).strip(), (str(d.get("painel") or "").strip() or antes or str(d["falar"]).strip())
408
+ except ValueError:
409
+ pass
410
+ m = re.search(r'"falar"\s*:\s*"(.*?)"\s*(?:,\s*"painel"|\})', corpo, re.S)
411
+ if m:
412
+ return m.group(1).strip(), corpo[:i].strip() or m.group(1).strip()
413
+ return resp, resp
414
+
314
415
  def sair(self) -> None:
315
416
  self.vivo = False
316
417
  self.matar_cerebro()
@@ -325,7 +426,7 @@ class App:
325
426
  def iniciar(self, js, p) -> None:
326
427
  self.js = js
327
428
  threading.Timer(0.5, self.subir_motor).start()
328
- threading.Timer(2.0, lambda: self.painel({"pasta": self.pasta.name, "login": logado()})).start()
429
+ threading.Timer(2.0, lambda: self.painel({"pasta": self.rotulo(), "login": logado()})).start()
329
430
 
330
431
 
331
432
  def main() -> int:
@@ -18,7 +18,7 @@ from pathlib import Path
18
18
 
19
19
  AQUI = Path(__file__).resolve().parent
20
20
  ICONE = AQUI / "vendor" / "primo-icone.png"
21
- VERSAO = "6" # sobe quando o bundle muda: força regerar
21
+ VERSAO = "7" # sobe quando o bundle muda: força regerar
22
22
 
23
23
 
24
24
  def pasta_app() -> Path:
@@ -83,6 +83,7 @@ def preparar() -> str | None:
83
83
  "CFBundleExecutable": "Primo-iniciar", "CFBundleIconFile": "Primo", "CFBundlePackageType": "APPL",
84
84
  "CFBundleShortVersionString": "1.0", "CFBundleVersion": "1", "LSMinimumSystemVersion": "11.0",
85
85
  "NSHighResolutionCapable": True, "NSPrincipalClass": "NSApplication",
86
+ "NSSpeechRecognitionUsageDescription": "O Primo entende o que você fala usando o reconhecimento de voz do Mac, no próprio computador.",
86
87
  "NSMicrophoneUsageDescription": "O Primo ouve você pelo microfone para atender os seus pedidos por voz.",
87
88
  "NSCalendarsFullAccessUsageDescription": "O Primo mostra os compromissos de hoje no painel.",
88
89
  "NSCalendarsUsageDescription": "O Primo mostra os compromissos de hoje no painel.",
@@ -136,10 +136,22 @@ def limpar_para_voz(texto: str) -> str:
136
136
  return re.sub(r"\s+", " ", t).strip()
137
137
 
138
138
 
139
- def resumo(texto: str, frases: int = 2, teto: int = 260) -> Tuple[str, str]:
140
- """(fala, painel). A voz resume; o texto inteiro (código, listas) vai ao painel."""
139
+ def resumo(texto: str, frases: int = 60, teto: int = 3000) -> Tuple[str, str]:
140
+ """(fala, painel). A voz diz a resposta COMPLETA (sem código, links nem caminhos); o painel guarda o texto
141
+ inteiro. Só código e lista enorme ficam fora da fala — e aí a prosa em volta já diz o que foi feito."""
141
142
  painel = str(texto or "").strip()
142
- limpo = limpar_para_voz(painel)
143
+ sem_codigo = re.sub(r"```[\s\S]*?```", " ", painel)
144
+ # lista ENORME (mais de 8 itens seguidos) não é lida item a item
145
+ linhas, saida, run = sem_codigo.splitlines(), [], []
146
+ for l in linhas + [""]:
147
+ if re.match(r"^\s*(?:[-*•]|\d+[.)])\s+", l):
148
+ run.append(l)
149
+ continue
150
+ if run:
151
+ saida += run if len(run) <= 8 else [f"São {len(run)} itens, a lista completa está no painel."]
152
+ run = []
153
+ saida.append(l)
154
+ limpo = limpar_para_voz("\n".join(saida))
143
155
  partes = [p.strip() for p in re.findall(r"[^.!?…]+[.!?…]*", limpo) if p.strip()]
144
156
  fala = ""
145
157
  for p in partes[:frases]:
@@ -148,9 +160,8 @@ def resumo(texto: str, frases: int = 2, teto: int = 260) -> Tuple[str, str]:
148
160
  fala = (fala + " " + p).strip()
149
161
  if not fala:
150
162
  fala = limpo[:teto].strip()
151
- tinha_mais = bool(re.search(r"```", painel)) or len(partes) > frases
152
- if tinha_mais and fala and not re.search(r"painel|tela", fala, re.I):
153
- fala += " O resto está no painel."
163
+ if not fala and re.search(r"```", painel):
164
+ fala = "Mandei o código para o painel."
154
165
  return fala, painel
155
166
 
156
167
 
@@ -47,10 +47,10 @@ TETO_FALA = 700.0 # nunca exige mais que isso (não ficar surdo)
47
47
  FATOR_CONTINUAR = 0.5 # a histerese: depois de começar, o limiar cai
48
48
  PRE_ROLL = 4 # blocos guardados ANTES do início da fala
49
49
 
50
- RABO_SILENCIO = 1.0 # silêncio que encerra a frase
51
- RABO_SILENCIO_LONGA = 1.5 # …e quando a fala já vem longa
50
+ RABO_SILENCIO = 1.4 # silêncio que encerra a frase
51
+ RABO_SILENCIO_LONGA = 1.8 # …e quando a fala já vem longa
52
52
  FALA_LONGA = 6.0
53
- RABO_VAD = 0.5 # silêncio que encerra a frase quando o VAD confirma
53
+ RABO_VAD = 1.1 # silêncio que encerra a frase quando o VAD confirma
54
54
  ESPERA_COMECAR = 8.0 # tempo para a pessoa começar a falar
55
55
  MAX_FRASE = 45.0
56
56
  MAX_FRASE_ESTENDIDA = 100.0
@@ -98,6 +98,7 @@ class Gravador:
98
98
  # VAD opcional (Silero, via pysilero-vad): bloco → True se for voz.
99
99
  # Com ele, a frase fecha em 0,9 s de silêncio em vez de 2 s.
100
100
  self.vad = None
101
+ self.espera = ESPERA_COMECAR # quanto espera a fala começar (encurta quando há frase pendente)
101
102
 
102
103
  # O ambiente é medido ENQUANTO ele espera a pessoa começar. Medir antes de
103
104
  # abrir o microfone daria o silêncio do processo, não o do cômodo.
@@ -143,7 +144,7 @@ class Gravador:
143
144
  self._falando = True
144
145
  self._blocos = list(self._pre)
145
146
  self._ultimo_som = agora
146
- elif agora > ESPERA_COMECAR:
147
+ elif agora > self.espera:
147
148
  self.encerrou_por = "ninguem-falou"
148
149
  return True
149
150
  return False
@@ -237,14 +238,17 @@ class Microfone:
237
238
  import numpy as _np
238
239
 
239
240
  def alimentar():
240
- with wave.open(caminho, "rb") as w:
241
- dados = _np.frombuffer(w.readframes(w.getnframes()), dtype=_np.int16)
242
- _t.sleep(float(os.environ.get("PRIMOCODE_TESTE_ATRASO", "12")))
241
+ # "a.wav@12;b.wav@90": cada arquivo entra N s depois do anterior (ou da abertura)
243
242
  silencio = _np.zeros(BLOCO, dtype=_np.int16)
244
- for i in range(0, len(dados) + BLOCO * 20, BLOCO):
245
- bloco = dados[i:i + BLOCO]
246
- self._ao_receber(bloco if len(bloco) == BLOCO else (_np.concatenate([bloco, silencio])[:BLOCO]))
247
- _t.sleep(BLOCO_MS / 1000)
243
+ for item in caminho.split(";"):
244
+ arq, _, espera = item.partition("@")
245
+ with wave.open(arq, "rb") as w:
246
+ dados = _np.frombuffer(w.readframes(w.getnframes()), dtype=_np.int16)
247
+ _t.sleep(float(espera or os.environ.get("PRIMOCODE_TESTE_ATRASO", "12")))
248
+ for i in range(0, len(dados) + BLOCO * 20, BLOCO):
249
+ bloco = dados[i:i + BLOCO]
250
+ self._ao_receber(bloco if len(bloco) == BLOCO else (_np.concatenate([bloco, silencio])[:BLOCO]))
251
+ _t.sleep(BLOCO_MS / 1000)
248
252
  threading.Thread(target=alimentar, daemon=True).start()
249
253
 
250
254
  def abrir(self) -> None:
@@ -0,0 +1,151 @@
1
+ """fala_pt.py — o texto vira o que se FALA, em português do Brasil, antes de ir para a voz.
2
+
3
+ Rede de segurança: o cérebro já devolve um `falar` escrito para ser ouvido (app.py), e isto limpa o resto —
4
+ markdown, links, caminhos, código, emojis — e diz por extenso números, unidades, dinheiro, horas, datas,
5
+ versões e siglas ("32 s" → "trinta e dois segundos", "R$ 99" → "noventa e nove reais", "PR" → "pê érre").
6
+ Puro: sem rede, sem Mac. Python 3.9+.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from typing import List
12
+
13
+ _UN = ["zero", "um", "dois", "três", "quatro", "cinco", "seis", "sete", "oito", "nove", "dez", "onze", "doze", "treze",
14
+ "catorze", "quinze", "dezesseis", "dezessete", "dezoito", "dezenove"]
15
+ _DZ = ["", "", "vinte", "trinta", "quarenta", "cinquenta", "sessenta", "setenta", "oitenta", "noventa"]
16
+ _CT = ["", "cento", "duzentos", "trezentos", "quatrocentos", "quinhentos", "seiscentos", "setecentos", "oitocentos", "novecentos"]
17
+
18
+
19
+ def _ate_999(n: int) -> str:
20
+ if n < 20:
21
+ return _UN[n]
22
+ if n < 100:
23
+ d, u = divmod(n, 10)
24
+ return _DZ[d] + (" e " + _UN[u] if u else "")
25
+ if n == 100:
26
+ return "cem"
27
+ c, r = divmod(n, 100)
28
+ return _CT[c] + (" e " + _ate_999(r) if r else "")
29
+
30
+
31
+ def extenso(n: int, feminino: bool = False) -> str:
32
+ """0..999.999.999.999 por extenso ("mil duzentos e trinta e quatro")."""
33
+ if n < 0:
34
+ return "menos " + extenso(-n, feminino)
35
+ if n < 1000:
36
+ t = _ate_999(n)
37
+ if feminino:
38
+ t = re.sub(r"\bum\b", "uma", t)
39
+ t = re.sub(r"\bdois\b", "duas", t)
40
+ t = re.sub(r"(\w+)entos\b", r"\1entas", t)
41
+ return t
42
+ partes: List[str] = []
43
+ for valor, sing, plur in ((10 ** 9, "bilhão", "bilhões"), (10 ** 6, "milhão", "milhões"), (1000, "mil", "mil")):
44
+ q, n = divmod(n, valor)
45
+ if not q:
46
+ continue
47
+ if valor == 1000:
48
+ partes.append("mil" if q == 1 else extenso(q, feminino) + " mil")
49
+ else:
50
+ partes.append(extenso(q) + " " + (sing if q == 1 else plur))
51
+ if n:
52
+ partes.append(("e " if n < 100 or n % 100 == 0 else "") + extenso(n, feminino))
53
+ return " ".join(partes).replace(" ", " ")
54
+
55
+
56
+ def _inteiro(txt: str) -> int:
57
+ return int(txt.replace(".", "")) if re.fullmatch(r"\d{1,3}(?:\.\d{3})+", txt) else int(txt)
58
+
59
+
60
+ def _decimal(parte_int: str, parte_dec: str) -> str:
61
+ return f"{extenso(_inteiro(parte_int))} vírgula " + " ".join(extenso(int(d)) for d in parte_dec) if parte_dec.startswith("0") \
62
+ else f"{extenso(_inteiro(parte_int))} vírgula {extenso(int(parte_dec))}"
63
+
64
+
65
+ def _num(txt: str) -> str:
66
+ if "," in txt:
67
+ i, d = txt.split(",", 1)
68
+ return _decimal(i, d)
69
+ return extenso(_inteiro(txt))
70
+
71
+
72
+ _UNIDADES = {
73
+ "ms": ("milissegundo", "milissegundos"), "s": ("segundo", "segundos"), "seg": ("segundo", "segundos"),
74
+ "min": ("minuto", "minutos"), "h": ("hora", "horas"), "px": ("pixel", "pixels"),
75
+ "kb": ("kilobyte", "kilobytes"), "mb": ("megabyte", "megabytes"), "gb": ("gigabyte", "gigabytes"), "tb": ("terabyte", "terabytes"),
76
+ "km": ("quilômetro", "quilômetros"), "kg": ("quilo", "quilos"), "g": ("grama", "gramas"), "cm": ("centímetro", "centímetros"),
77
+ "mm": ("milímetro", "milímetros"), "m": ("metro", "metros"), "fps": ("quadro por segundo", "quadros por segundo"),
78
+ "mhz": ("megahertz", "megahertz"), "ghz": ("gigahertz", "gigahertz"), "hz": ("hertz", "hertz"),
79
+ "kbps": ("kilobit por segundo", "kilobits por segundo"), "mbps": ("megabit por segundo", "megabits por segundo"),
80
+ }
81
+ _SIGLAS = {
82
+ "PR": "pê érre", "PRs": "pê érres", "npm": "ene pê eme", "CTA": "chamada", "API": "á pê í", "APIs": "á pê ís", "CSS": "cê ésse ésse",
83
+ "HTML": "agá tê eme éle", "JSON": "jêi són", "URL": "u érre éle", "CLI": "cê éle í", "CPU": "cê pê u", "RAM": "ram", "UI": "u í",
84
+ "UX": "u ékis", "SQL": "ésse quê éle", "SVG": "ésse vê gê", "PDF": "pê dê éfe", "MP3": "ême pê três", "MP4": "ême pê quatro",
85
+ "IA": "í á", "GPU": "gê pê u", "SSD": "ésse ésse dê", "TTS": "tê tê ésse", "STT": "ésse tê tê", "VAD": "vê á dê", "OK": "ôquei",
86
+ "TCC": "tê cê cê", "SDK": "ésse dê cá", "CI": "cê í", "CD": "cê dê", "ID": "í dê", "TI": "tê í", "QA": "quê á", "OKR": "ô cá érre",
87
+ "git": "guit", "GitHub": "guit rrâb", "README": "rídmi", "Spotify": "Spotifái", "WhatsApp": "uótsápi", "macOS": "mák ó ésse",
88
+ }
89
+ _LETRAS = dict(zip("ABCDEFGHIJKLMNOPQRSTUVWXYZ", "á bê cê dê é éfe gê agá í jota cá éle ême ene ó pê quê érre ésse tê u vê dáblio ékis ípsilon zê".split()))
90
+ _MESES = ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"]
91
+
92
+
93
+ def _dinheiro(m: "re.Match") -> str:
94
+ ints, cents = m.group(1), m.group(2)
95
+ n = _inteiro(ints)
96
+ t = extenso(n) + (" real" if n == 1 else " reais")
97
+ if cents and int(cents):
98
+ c = int(cents)
99
+ t += " e " + extenso(c) + (" centavo" if c == 1 else " centavos")
100
+ return t
101
+
102
+
103
+ def _unidade(m: "re.Match") -> str:
104
+ num, un = m.group(1), m.group(2)
105
+ forma = _UNIDADES.get(un.lower())
106
+ if not forma or (len(un) == 1 and un not in "smhg" and un != "m"):
107
+ return m.group(0)
108
+ singular = num in ("1", "01") or num == "1,0"
109
+ return f"{_num(num)} {forma[0] if singular else forma[1]}"
110
+
111
+
112
+ def normalizar(texto: str) -> str:
113
+ t = str(texto or "")
114
+ t = re.sub(r"```[\s\S]*?```", " ", t) # bloco de código
115
+ t = re.sub(r"`([^`\n]*)`", lambda m: "" if re.search(r"[/\\.]|\(|\)", m.group(1)) else m.group(1), t)
116
+ t = re.sub(r"\[([^\]]+)\]\([^)]*\)", r"\1", t) # [texto](link) → texto
117
+ t = re.sub(r"https?://\S+|www\.\S+", "", t)
118
+ t = re.sub(r"(?<![\w])(?:~|\.{1,2})?/[\w.\-@%+/]+", " ", t) # caminhos /a/b.js ~/Primo/x
119
+ t = re.sub(r"\b[\w\-]+(?:/[\w.\-]+)+\.\w+\b", " ", t) # a/b/c.js
120
+ t = re.sub(r"\b[\w\-]+\.(?:js|ts|tsx|jsx|py|html|css|json|md|sh|yml|yaml|png|jpg|svg|mp4|wav|mp3|pdf)\b", "um arquivo", t)
121
+ t = re.sub(r"^\s*(?:#{1,6}|[-*•]|\d+[.)])\s+", "", t, flags=re.M) # títulos e marcadores
122
+ t = re.sub(r"[*_#>|~]+", " ", t)
123
+ t = re.sub(r"[\U0001F000-\U0001FAFF☀-➿⬀-⯿️‍←-⇿⌀-⏿●▸▪▣⎿]", " ", t) # emojis e símbolos
124
+ # dinheiro
125
+ t = re.sub(r"R\$\s*(\d{1,3}(?:\.\d{3})*|\d+)(?:,(\d{1,2}))?", _dinheiro, t)
126
+ # versões: v10.15.2 / 10.15.2 / v10.15
127
+ t = re.sub(r"\bv(\d+(?:\.\d+){1,3})\b", lambda m: "versão " + " ponto ".join(extenso(int(x)) for x in m.group(1).split(".")), t)
128
+ t = re.sub(r"\b(\d+(?:\.\d+){2,3})\b", lambda m: " ponto ".join(extenso(int(x)) for x in m.group(1).split(".")), t)
129
+ # hora e data
130
+ t = re.sub(r"\b([01]?\d|2[0-3]):([0-5]\d)\b", lambda m: extenso(int(m.group(1))) + (" horas" if int(m.group(1)) != 1 else " hora") + (" e " + extenso(int(m.group(2))) if int(m.group(2)) else ""), t)
131
+ t = re.sub(r"\b(0?[1-9]|[12]\d|3[01])/(0?[1-9]|1[0-2])(?:/(\d{4}))?\b",
132
+ lambda m: extenso(int(m.group(1))) + " de " + _MESES[int(m.group(2)) - 1] + (" de " + extenso(int(m.group(3))) if m.group(3) else ""), t)
133
+ # porcentagem
134
+ t = re.sub(r"(\d+(?:,\d+)?)\s?%", lambda m: _num(m.group(1)) + " por cento", t)
135
+ # número + unidade ("32 s", "10px", "1,5 GB")
136
+ t = re.sub(r"(?<![\w.,])(\d{1,3}(?:\.\d{3})+|\d+(?:,\d+)?)\s?([A-Za-z]{1,4})\b(?!\w)", _unidade, t)
137
+ # siglas e nomes técnicos (palavra inteira)
138
+ for sig in sorted(_SIGLAS, key=len, reverse=True):
139
+ t = re.sub(r"(?<![\w])" + re.escape(sig) + r"(?![\w])", _SIGLAS[sig], t)
140
+ t = re.sub(r"(?<![\w])([A-Z]{2,4})(?![\w])", lambda m: " ".join(_LETRAS[c] for c in m.group(1)), t) # sigla desconhecida: soletra
141
+ # números que sobraram
142
+ t = re.sub(r"(?<![\w.])(\d{1,3}(?:\.\d{3})+|\d+),(\d+)(?![\w])", lambda m: _decimal(m.group(1), m.group(2)), t)
143
+ t = re.sub(r"(?<![\w.,])(\d{1,3}(?:\.\d{3})+|\d+)(?![\w.,]\d)", lambda m: extenso(_inteiro(m.group(1))), t)
144
+ t = re.sub(r"\s*[()\[\]{}]\s*", ", ", t)
145
+ t = re.sub(r"\s*[—–]\s*", ", ", t)
146
+ t = re.sub(r"\s*[&]\s*", " e ", t)
147
+ t = re.sub(r"\s*\+\s*", " mais ", t)
148
+ t = re.sub(r"\s*=>?\s*", " ", t)
149
+ t = re.sub(r"\s+([,.!?;:])", r"\1", t)
150
+ t = re.sub(r"(,\s*){2,}", ", ", t)
151
+ return re.sub(r"\s+", " ", t).strip(" ,")
@@ -565,8 +565,8 @@ window.primo = (m) => {
565
565
  if (m.estado) setEstado(m.estado);
566
566
  if (m.barras) { S.quem = m.barras.quem; S.nivel = m.barras.nivel; R3.nivelFala(m.barras.quem === 'primo' ? m.barras.nivel : 0); }
567
567
  if (m.transcricao !== undefined || m.resposta !== undefined) { const t = m.resposta || m.transcricao || ''; $('fala').textContent = t.length > 160 ? t.slice(0, 157) + '…' : t; }
568
- if (m.calendario) { S.perm = m.calendario.permissao; diasComEvento = m.calendario.dias || []; desenharCalendario(); desenharHoje(); }
569
- if (m.hoje) { S.hoje = m.hoje; desenharHoje(); }
568
+ if (m.calendario) { const k = JSON.stringify(m.calendario); if (k !== S.calKey) { S.calKey = k; S.perm = m.calendario.permissao; diasComEvento = m.calendario.dias || []; desenharCalendario(); desenharHoje(); } }
569
+ if (m.hoje) { const k = JSON.stringify(m.hoje); S.perm = 'ok'; if (k !== S.hojeKey) { S.hojeKey = k; S.hoje = m.hoje; desenharHoje(); } }
570
570
  if (m.clima) desenharClima(m.clima);
571
571
  if (m.musica !== undefined) desenharMusica(m.musica);
572
572
  if (m.maquina) desenharMaquina(m.maquina);
@@ -17,6 +17,7 @@ import json
17
17
  import math
18
18
  import os
19
19
  import queue
20
+ import subprocess
20
21
  import sys
21
22
  import threading
22
23
  import time
@@ -88,6 +89,18 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
88
89
  p.visivel = False
89
90
  return True
90
91
 
92
+ def applicationShouldTerminate_(self, _a):
93
+ try: # quem pediu para sair? (vai para o log: ajuda a achar quem fecha o app)
94
+ from Foundation import NSAppleEventManager
95
+ ev = NSAppleEventManager.sharedAppleEventManager().currentAppleEvent()
96
+ pid = ev.attributeDescriptorForKeyword_(0x73706964).int32Value() if ev is not None else 0
97
+ quem = subprocess.run(["ps", "-p", str(pid), "-o", "command="], capture_output=True, text=True).stdout.strip()[:160] if pid else "interno"
98
+ sys.stderr.write(f"hud: pedido para SAIR de pid={pid} ({quem})\n")
99
+ sys.stderr.flush()
100
+ except Exception as e:
101
+ sys.stderr.write(f"hud: pedido para sair (não deu para ver quem: {e})\n")
102
+ return 1
103
+
91
104
  def applicationWillTerminate_(self, _n):
92
105
  if ao_sair:
93
106
  try:
@@ -220,11 +233,19 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
220
233
  def agenda(): # em outra thread: o pedido de permissão do Calendário pode demorar
221
234
  evs: dict = {"permissao": "pendente", "eventos": []}
222
235
  k = 0
236
+ falhas = 0
223
237
  while True:
224
238
  try:
225
239
  if k % 12 == 0 or evs["permissao"] != "ok": # sem acesso, olha a cada 5 s (a pessoa pode ter liberado)
226
- evs = td.eventos_do_mes()
227
- js({"calendario": {"permissao": evs["permissao"], "dias": sorted({e["dia"] for e in evs["eventos"]})}})
240
+ novo = td.eventos_do_mes()
241
+ if evs["permissao"] == "ok" and novo["permissao"] != "ok":
242
+ falhas += 1 # erro transitório não derruba o cartão: segura os últimos eventos
243
+ if falhas >= 4:
244
+ evs, falhas = novo, 0
245
+ else:
246
+ evs, falhas = novo, 0
247
+ # SEMPRE manda o estado junto: o painel nunca depende de uma mensagem que pode ter chegado antes da página
248
+ js({"calendario": {"permissao": evs["permissao"], "dias": sorted({e["dia"] for e in evs["eventos"]})}})
228
249
  if evs["permissao"] == "ok":
229
250
  js({"hoje": td.hoje_lista(evs["eventos"], time.time())})
230
251
  except Exception as e:
@@ -27,7 +27,7 @@ import threading
27
27
  from pathlib import Path
28
28
  from typing import Optional
29
29
 
30
- MODELO = os.environ.get("PRIMOCODE_WHISPER_MODELO", "base").strip() or "base"
30
+ MODELO = os.environ.get("PRIMOCODE_WHISPER_MODELO", "small").strip() or "small"
31
31
  PASTA = Path.home() / ".primocode" / "modelos" / "whisper"
32
32
 
33
33
  _modelo = None
@@ -112,6 +112,11 @@ def aproveitavel(segmento) -> bool:
112
112
  return not (sem_fala > SEM_FALA and confianca < CONFIANCA_MINIMA)
113
113
 
114
114
 
115
+ # O vocabulário do Primo vai de dica ao Whisper: medido em 13 frases pt-BR, o WER do `small` caiu de 15,1% para 11,6% (e de 29,6% para 18,5% com ruído).
116
+ PROMPT_INICIAL = ("Primo, PrimoCode, commit, deploy, Spotify, Railway, projeto, testes, servidor, landing page, site, padaria, "
117
+ "agenda, volume, abre o projeto, cria um site, roda os testes.")
118
+
119
+
115
120
  def transcrever(wav: Path, idioma: str = "pt") -> str:
116
121
  modelo = carregar()
117
122
  entrada: object = str(wav)
@@ -125,8 +130,9 @@ def transcrever(wav: Path, idioma: str = "pt") -> str:
125
130
  entrada = str(wav)
126
131
  # vad_filter corta o silêncio das pontas: sem ele, o Whisper às vezes
127
132
  # "ouve" uma frase inteira inventada no silêncio ("Obrigado por assistir").
128
- segmentos, _ = modelo.transcribe(entrada, language=idioma, beam_size=1,
129
- vad_filter=True, condition_on_previous_text=False)
133
+ segmentos, _ = modelo.transcribe(entrada, language=idioma, beam_size=2,
134
+ vad_filter=True, condition_on_previous_text=False,
135
+ initial_prompt=PROMPT_INICIAL)
130
136
  return limpar(" ".join(s.text.strip() for s in segmentos if aproveitavel(s)))
131
137
 
132
138