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 +2 -2
- package/bin/primocode.js +9 -2
- package/lib/config.js +1 -1
- package/package.json +1 -1
- package/voz/primo_voz/__main__.py +68 -10
- package/voz/primo_voz/app.py +122 -21
- package/voz/primo_voz/appmac.py +2 -1
- package/voz/primo_voz/conversa.py +17 -6
- package/voz/primo_voz/escuta.py +15 -11
- package/voz/primo_voz/fala_pt.py +151 -0
- package/voz/primo_voz/hud.html +2 -2
- package/voz/primo_voz/hud.py +23 -2
- package/voz/primo_voz/transcrever.py +9 -3
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v10.15.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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 ("
|
|
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(
|
|
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() +
|
|
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("
|
|
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]})
|
package/voz/primo_voz/app.py
CHANGED
|
@@ -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
|
-
|
|
56
|
-
|
|
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":
|
|
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
|
-
|
|
284
|
-
|
|
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=
|
|
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
|
-
|
|
304
|
-
|
|
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
|
-
|
|
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.
|
|
429
|
+
threading.Timer(2.0, lambda: self.painel({"pasta": self.rotulo(), "login": logado()})).start()
|
|
329
430
|
|
|
330
431
|
|
|
331
432
|
def main() -> int:
|
package/voz/primo_voz/appmac.py
CHANGED
|
@@ -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 = "
|
|
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 =
|
|
140
|
-
"""(fala, painel). A voz
|
|
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
|
-
|
|
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
|
-
|
|
152
|
-
|
|
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
|
|
package/voz/primo_voz/escuta.py
CHANGED
|
@@ -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.
|
|
51
|
-
RABO_SILENCIO_LONGA = 1.
|
|
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 =
|
|
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 >
|
|
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
|
-
|
|
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
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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(" ,")
|
package/voz/primo_voz/hud.html
CHANGED
|
@@ -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);
|
package/voz/primo_voz/hud.py
CHANGED
|
@@ -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
|
-
|
|
227
|
-
|
|
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", "
|
|
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=
|
|
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
|
|