primocode 10.15.1 → 10.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -6,7 +6,6 @@ teste mede. O que toca hardware (escuta.py, hud.py, __main__.py) só chama isto.
6
6
  Maquina ouvindo → pensando → falando → (interrompido) → ouvindo
7
7
  Fatiador texto em streaming → frases prontas para falar
8
8
  resumo resposta longa → (fala curta, o que vai para o painel)
9
- ativacao "Primo, abre o projeto" → (True, "abre o projeto")
10
9
  comando "esconde o painel" → "esconder"
11
10
  confirmacao ferramenta → "rodando os testes"
12
11
  """
@@ -136,10 +135,22 @@ def limpar_para_voz(texto: str) -> str:
136
135
  return re.sub(r"\s+", " ", t).strip()
137
136
 
138
137
 
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."""
138
+ def resumo(texto: str, frases: int = 60, teto: int = 3000) -> Tuple[str, str]:
139
+ """(fala, painel). A voz diz a resposta COMPLETA (sem código, links nem caminhos); o painel guarda o texto
140
+ inteiro. Só código e lista enorme ficam fora da fala — e aí a prosa em volta já diz o que foi feito."""
141
141
  painel = str(texto or "").strip()
142
- limpo = limpar_para_voz(painel)
142
+ sem_codigo = re.sub(r"```[\s\S]*?```", " ", painel)
143
+ # lista ENORME (mais de 8 itens seguidos) não é lida item a item
144
+ linhas, saida, run = sem_codigo.splitlines(), [], []
145
+ for l in linhas + [""]:
146
+ if re.match(r"^\s*(?:[-*•]|\d+[.)])\s+", l):
147
+ run.append(l)
148
+ continue
149
+ if run:
150
+ saida += run if len(run) <= 8 else [f"São {len(run)} itens, a lista completa está no painel."]
151
+ run = []
152
+ saida.append(l)
153
+ limpo = limpar_para_voz("\n".join(saida))
143
154
  partes = [p.strip() for p in re.findall(r"[^.!?…]+[.!?…]*", limpo) if p.strip()]
144
155
  fala = ""
145
156
  for p in partes[:frases]:
@@ -148,37 +159,11 @@ def resumo(texto: str, frases: int = 2, teto: int = 260) -> Tuple[str, str]:
148
159
  fala = (fala + " " + p).strip()
149
160
  if not fala:
150
161
  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."
162
+ if not fala and re.search(r"```", painel):
163
+ fala = "Mandei o código para o painel."
154
164
  return fala, painel
155
165
 
156
166
 
157
- # ── a palavra de ativação ────────────────────────────────────────────────
158
-
159
- _NOMES = ("primo", "primu", "prima", "primocode", "primo code")
160
- _ANTES = ("ei", "ey", "hey", "oi", "ola", "fala", "e ai", "opa", "salve")
161
-
162
-
163
- def ativacao(texto: str, nome: str = "Primo") -> Tuple[bool, str]:
164
- """Começa com "Primo" (ou "ei Primo")? Devolve (sim?, o resto do pedido).
165
-
166
- Só vale no INÍCIO da fala: "o primo da Ana ligou" não acorda ninguém."""
167
- cru = str(texto or "").strip()
168
- n = normalizar(cru)
169
- meus = _NOMES if normalizar(nome) in ("primo", "") else (normalizar(nome),)
170
- for ant in ("",) + _ANTES:
171
- for nm in meus:
172
- pre = (ant + " " + nm).strip()
173
- if n == pre or n.startswith(pre + " "):
174
- resto = re.sub(r"^\W*(?:" + r"\W+".join(re.escape(w) for w in pre.split()) + r")\W*", "",
175
- _sem_acento(cru), flags=re.I)
176
- # o corte vale no texto sem acento; o tamanho é o mesmo
177
- resto = cru[len(cru) - len(resto):] if len(resto) <= len(cru) else resto
178
- return True, resto.strip(" ,.!?;:-")
179
- return False, cru
180
-
181
-
182
167
  # ── comandos de voz que o painel e o modo resolvem sozinhos ──────────────
183
168
 
184
169
  _COMANDOS = [
@@ -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(" ,")
@@ -144,6 +144,10 @@
144
144
  .leg span::before { content:""; display:inline-block; width:8px; height:8px; border-radius:50%; margin-right:6px; background:var(--marca); }
145
145
  .leg span.p::before { background:var(--ok); }
146
146
  .tecla { font-size:12px; color:var(--fraco); white-space:nowrap; }
147
+ .voltar-chat { display:inline-flex; align-items:center; gap:8px; height:38px; padding:0 16px; border:1px solid var(--borda); border-radius:999px; background:var(--fundo); color:var(--texto); font:500 13px var(--sans); cursor:pointer; white-space:nowrap; transition:border-color .2s; }
148
+ .voltar-chat:hover { border-color:var(--marca); }
149
+ .voltar-chat kbd { font:11px var(--mono); color:var(--fraco); }
150
+ .voltar-chat[hidden] { display:none; }
147
151
  </style>
148
152
  </head>
149
153
  <body>
@@ -270,6 +274,7 @@
270
274
  <div id="barras"></div>
271
275
  <div class="leg"><span>você</span><span class="p" id="leg-nome">Primo</span></div>
272
276
  <div class="tecla">⌨ luz do teclado no ritmo da voz</div>
277
+ <button class="voltar-chat" id="b-chat" hidden onclick="post({acao:'chat'})">← Voltar pro chat <kbd>esc</kbd></button>
273
278
  </section>
274
279
  </main>
275
280
 
@@ -529,7 +534,7 @@ function classeTela() {
529
534
  function setEstado(e) {
530
535
  if (!e || e === S.estado) return;
531
536
  S.estado = e; classeTela(); $('nome').dataset.e = e;
532
- $('estado-t').textContent = e === 'dormindo' ? 'diga "' + S.nome + '"' : (ROTULO[e] || e);
537
+ $('estado-t').textContent = e === 'dormindo' ? 'clique para falar' : (ROTULO[e] || e);
533
538
  R3.estado(e);
534
539
  S.olhar = (e === 'pensando' || e === 'executando') ? 1 : 0;
535
540
  $('eq').setAttribute('opacity', e === 'falando' ? 1 : 0);
@@ -557,16 +562,17 @@ function laco(t) { SP.quadro(t); if (!R3.ok && !SP.ok) animaEq(t); requestAnimat
557
562
  /* ── a ponte com o Python ── */
558
563
  window.primo = (m) => {
559
564
  if (m.pasta !== undefined) $('pasta').textContent = m.pasta;
565
+ if (m.comChat) $('b-chat').hidden = false;
560
566
  if (m.login !== undefined) $('b-entrar').hidden = !!m.login;
561
- if (m.nome) { S.nome = m.nome; $('nome-h').textContent = m.nome; $('leg-nome').textContent = m.nome; if (S.estado === 'dormindo') $('estado-t').textContent = 'diga "' + m.nome + '"'; }
567
+ if (m.nome) { S.nome = m.nome; $('nome-h').textContent = m.nome; $('leg-nome').textContent = m.nome; }
562
568
  if (m.robo === 'expressive' && !SP.ok) { if (SP.cancela) SP.cancela(); SP.falhou = true; classeTela(); }
563
569
  if (m.evento) R3.evento(m.evento);
564
570
  if (m.boot) montar();
565
571
  if (m.estado) setEstado(m.estado);
566
572
  if (m.barras) { S.quem = m.barras.quem; S.nivel = m.barras.nivel; R3.nivelFala(m.barras.quem === 'primo' ? m.barras.nivel : 0); }
567
573
  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(); }
574
+ 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(); } }
575
+ 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
576
  if (m.clima) desenharClima(m.clima);
571
577
  if (m.musica !== undefined) desenharMusica(m.musica);
572
578
  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
@@ -29,8 +30,9 @@ from primo_voz.sons import Sons
29
30
  AQUI = Path(__file__).resolve().parent
30
31
 
31
32
 
32
- def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=None) -> int:
33
- """entrada/saida/ao_iniciar/ao_sair: o modo app (app.py) liga o painel ao motor de voz sem passar por stdin/stdout."""
33
+ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=None, chat=None) -> int:
34
+ """entrada/saida/ao_iniciar/ao_sair: o modo app (app.py) liga o painel ao motor de voz sem passar por stdin/stdout.
35
+ chat: o app.Chat; com ele a janela abre no chat.html e a voz (hud.html) fica atrás, trocada pelo botão de onda."""
34
36
  try:
35
37
  from AppKit import (NSApplication, NSApplicationActivationPolicyRegular, NSImage, NSMenu, NSMenuItem, NSBackingStoreBuffered, NSColor,
36
38
  NSEvent, NSScreen, NSWindow)
@@ -55,6 +57,40 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
55
57
  def js(msg: dict) -> None:
56
58
  fila.put("window.primo(" + json.dumps(msg, ensure_ascii=False) + ")")
57
59
 
60
+ fila_chat: "queue.Queue[str]" = queue.Queue()
61
+ fila_ui: "queue.Queue" = queue.Queue() # o que mexe na janela, rodado na thread principal
62
+
63
+ def js_chat(msg: dict) -> None:
64
+ fila_chat.put("window.chat&&window.chat(" + json.dumps(msg, ensure_ascii=False) + ")")
65
+
66
+ telas: dict = {}
67
+
68
+ def mostrar(qual: str) -> None:
69
+ """Troca entre o chat e a voz na mesma janela, sem recarregar nenhuma das duas."""
70
+ if not telas:
71
+ return
72
+ telas["chat"].setHidden_(qual != "chat")
73
+ telas["hud"].setHidden_(qual != "hud")
74
+ telas["atual"] = qual
75
+ telas["jan"].makeFirstResponder_(telas[qual])
76
+ if qual == "hud":
77
+ js({"comChat": True})
78
+ js({"boot": True})
79
+ if chat is not None:
80
+ threading.Thread(target=chat.app.ligar_voz, daemon=True).start()
81
+ elif chat is not None: # voltou pro chat: o microfone fecha
82
+ threading.Thread(target=chat.app.desligar_voz, daemon=True).start()
83
+
84
+ def painel_arquivos(pastas: bool):
85
+ from AppKit import NSOpenPanel
86
+ pn_ = NSOpenPanel.openPanel()
87
+ pn_.setCanChooseFiles_(not pastas)
88
+ pn_.setCanChooseDirectories_(pastas)
89
+ pn_.setAllowsMultipleSelection_(not pastas)
90
+ if pn_.runModal() != 1:
91
+ return []
92
+ return [str(u.path()) for u in pn_.URLs()]
93
+
58
94
  class Janela(NSWindow):
59
95
  def canBecomeKeyWindow(self):
60
96
  return True
@@ -72,6 +108,19 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
72
108
  break
73
109
  self.web.evaluateJavaScript_completionHandler_(cod, None)
74
110
  n += 1
111
+ while telas:
112
+ try:
113
+ cod = fila_chat.get_nowait()
114
+ except queue.Empty:
115
+ break
116
+ telas["chat"].evaluateJavaScript_completionHandler_(cod, None)
117
+ while True:
118
+ try:
119
+ fila_ui.get_nowait()()
120
+ except queue.Empty:
121
+ break
122
+ except Exception as e:
123
+ sys.stderr.write(f"hud: {e}\n")
75
124
  if self.jan.isMiniaturized():
76
125
  return
77
126
  if p.visivel != bool(self.jan.isVisible()):
@@ -88,6 +137,18 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
88
137
  p.visivel = False
89
138
  return True
90
139
 
140
+ def applicationShouldTerminate_(self, _a):
141
+ try: # quem pediu para sair? (vai para o log: ajuda a achar quem fecha o app)
142
+ from Foundation import NSAppleEventManager
143
+ ev = NSAppleEventManager.sharedAppleEventManager().currentAppleEvent()
144
+ pid = ev.attributeDescriptorForKeyword_(0x73706964).int32Value() if ev is not None else 0
145
+ quem = subprocess.run(["ps", "-p", str(pid), "-o", "command="], capture_output=True, text=True).stdout.strip()[:160] if pid else "interno"
146
+ sys.stderr.write(f"hud: pedido para SAIR de pid={pid} ({quem})\n")
147
+ sys.stderr.flush()
148
+ except Exception as e:
149
+ sys.stderr.write(f"hud: pedido para sair (não deu para ver quem: {e})\n")
150
+ return 1
151
+
91
152
  def applicationWillTerminate_(self, _n):
92
153
  if ao_sair:
93
154
  try:
@@ -111,6 +172,8 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
111
172
  sys.stderr.flush()
112
173
  elif a == "sair":
113
174
  p.visivel = False
175
+ elif a == "chat": # "Voltar pro chat", na tela de voz
176
+ mostrar("chat")
114
177
  elif a in ("ptt", "entrar"):
115
178
  emitir({"acao": a})
116
179
  elif a == "print": # esconde o HUD para o print pegar a tela de verdade
@@ -127,6 +190,52 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
127
190
  js({"musica": td.musica()})
128
191
  threading.Thread(target=_fazer, daemon=True).start()
129
192
 
193
+ class MsgChat(NSObject, protocols=[objc.protocolNamed("WKScriptMessageHandler")]):
194
+ """A ponte do chat.html. O que mexe na janela (trocar de tela, painel de arquivos, abrir link) é daqui; o resto vai para o app.Chat."""
195
+ def userContentController_didReceiveScriptMessage_(self, ucc, msg):
196
+ try:
197
+ m = json.loads(str(msg.body()))
198
+ except ValueError:
199
+ return
200
+ a = m.get("acao")
201
+ if a == "erro":
202
+ sys.stderr.write("chat.html: " + str(m.get("arg")) + "\n")
203
+ sys.stderr.flush()
204
+ elif a == "voz": # botão de onda: abre a voz e já começa a ouvir
205
+ mostrar("hud")
206
+ emitir({"acao": "ptt"})
207
+ elif a == "anexar":
208
+ caminhos = painel_arquivos(False)
209
+ if caminhos:
210
+ threading.Thread(target=chat.anexar_caminhos, args=(caminhos,), daemon=True).start()
211
+ elif a == "escolher_pasta":
212
+ caminhos = painel_arquivos(True)
213
+ if caminhos:
214
+ threading.Thread(target=chat.escolher_pasta, args=(caminhos[0],), daemon=True).start()
215
+ elif a == "abrir_link":
216
+ from AppKit import NSWorkspace
217
+ u = str(m.get("url") or "")
218
+ if u.startswith(("http://", "https://")):
219
+ NSWorkspace.sharedWorkspace().openURL_(NSURL.URLWithString_(u))
220
+ elif a == "haptico": # a travadinha do esforço: o trackpad dá um toque em cada nível
221
+ try:
222
+ from AppKit import NSHapticFeedbackManager
223
+ padrao = 2 if m.get("forte") else 1 # 1 = alinhamento (leve), 2 = troca de nível (mais firme)
224
+ NSHapticFeedbackManager.defaultPerformer().performFeedbackPattern_performanceTime_(padrao, 1)
225
+ except Exception as e:
226
+ sys.stderr.write(f"hud: háptico: {e}\n")
227
+ elif a in ("abrir_arquivo", "mostrar_finder"): # só o que existe; o Mac abre com o app padrão
228
+ from AppKit import NSWorkspace
229
+ c = Path(os.path.expanduser(str(m.get("caminho") or "")))
230
+ if c.is_absolute() and c.exists():
231
+ ws = NSWorkspace.sharedWorkspace()
232
+ if a == "abrir_arquivo":
233
+ ws.openURL_(NSURL.fileURLWithPath_(str(c)))
234
+ else:
235
+ ws.activateFileViewerSelectingURLs_([NSURL.fileURLWithPath_(str(c))])
236
+ else:
237
+ chat.receber(m)
238
+
130
239
  app = NSApplication.sharedApplication()
131
240
  app.setActivationPolicy_(NSApplicationActivationPolicyRegular) # app de verdade: Dock e Cmd+Tab
132
241
  try: # nome e ícone do Primo, mesmo fora do bundle
@@ -172,21 +281,49 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
172
281
  web = WKWebView.alloc().initWithFrame_configuration_(NSMakeRect(0, 0, tela.size.width, tela.size.height), cfg)
173
282
  web.setAutoresizingMask_(2 | 16)
174
283
  web.setValue_forKey_(False, "drawsBackground")
175
- jan.setContentView_(web)
176
284
  url = NSURL.fileURLWithPath_(str(AQUI / "hud.html"))
177
285
  web.loadFileURL_allowingReadAccessToURL_(url, NSURL.fileURLWithPath_(str(AQUI)))
286
+ if chat is None:
287
+ jan.setContentView_(web)
288
+ else: # o chat na frente; a voz atrás, já carregada (troca sem esperar)
289
+ from AppKit import NSView
290
+ fundo = NSView.alloc().initWithFrame_(NSMakeRect(0, 0, tela.size.width, tela.size.height))
291
+ fundo.setAutoresizingMask_(2 | 16)
292
+ cfg_c = WKWebViewConfiguration.alloc().init()
293
+ cfg_c.preferences().setValue_forKey_(True, "allowFileAccessFromFileURLs") # o vídeo/imagem/PDF citados tocam ali mesmo
294
+ cfg_c.userContentController().addScriptMessageHandler_name_(MsgChat.alloc().init(), "chat")
295
+ web_c = WKWebView.alloc().initWithFrame_configuration_(NSMakeRect(0, 0, tela.size.width, tela.size.height), cfg_c)
296
+ web_c.setAutoresizingMask_(2 | 16)
297
+ web_c.setValue_forKey_(False, "drawsBackground")
298
+ # leitura da pasta pessoal: os arquivos que o agente cita (~/Primo/…) aparecem no chat sem servidor
299
+ web_c.loadFileURL_allowingReadAccessToURL_(NSURL.fileURLWithPath_(str(AQUI / "chat.html")), NSURL.fileURLWithPath_(str(Path.home())))
300
+ fundo.addSubview_(web)
301
+ fundo.addSubview_(web_c)
302
+ jan.setContentView_(fundo)
303
+ telas.update({"chat": web_c, "hud": web, "jan": jan, "atual": "chat"})
304
+ web.setHidden_(True)
305
+ chat.js = js_chat
306
+ chat.mostrar_voz = lambda: fila_ui.put(lambda: mostrar("hud")) # um pedido falado com o chat na frente
178
307
  jan.makeKeyAndOrderFront_(None)
179
308
  app.activateIgnoringOtherApps_(True)
180
309
  alvo = Alvo.alloc().init()
181
310
  alvo.jan, alvo.web = jan, web
182
311
  NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_(1 / 30, alvo, b"tick:", None, True)
183
- threading.Timer(1.2, lambda: (js({"nome": identidade.ler()["nome"]}), js({"robo": identidade.ler()["robo"]}), js({"boot": True}), sons.tocar("boot"))).start()
312
+ if chat is None: # sem chat a voz é a tela: o robô acorda com o som
313
+ threading.Timer(1.2, lambda: (js({"nome": identidade.ler()["nome"]}), js({"robo": identidade.ler()["robo"]}), js({"boot": True}), sons.tocar("boot"))).start()
314
+ else: # com chat: só o nome e o robô, em silêncio; o boot fica pra quando abrir a voz
315
+ threading.Timer(1.2, lambda: (js({"nome": identidade.ler()["nome"]}), js({"robo": identidade.ler()["robo"]}))).start()
184
316
 
185
317
  def esconder():
186
318
  p.visivel = False
187
319
 
188
320
  def tecla(ev):
189
- if ev.keyCode() == 53: # ESC
321
+ if ev.keyCode() == 53: # ESC: na voz com chat, volta pro chat; no chat, fica com a página
322
+ if telas:
323
+ if telas["atual"] == "hud":
324
+ mostrar("chat")
325
+ return None
326
+ return ev
190
327
  esconder()
191
328
  return None
192
329
  return ev
@@ -220,11 +357,19 @@ def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=
220
357
  def agenda(): # em outra thread: o pedido de permissão do Calendário pode demorar
221
358
  evs: dict = {"permissao": "pendente", "eventos": []}
222
359
  k = 0
360
+ falhas = 0
223
361
  while True:
224
362
  try:
225
363
  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"]})}})
364
+ novo = td.eventos_do_mes()
365
+ if evs["permissao"] == "ok" and novo["permissao"] != "ok":
366
+ falhas += 1 # erro transitório não derruba o cartão: segura os últimos eventos
367
+ if falhas >= 4:
368
+ evs, falhas = novo, 0
369
+ else:
370
+ evs, falhas = novo, 0
371
+ # SEMPRE manda o estado junto: o painel nunca depende de uma mensagem que pode ter chegado antes da página
372
+ js({"calendario": {"permissao": evs["permissao"], "dias": sorted({e["dia"] for e in evs["eventos"]})}})
228
373
  if evs["permissao"] == "ok":
229
374
  js({"hoje": td.hoje_lista(evs["eventos"], time.time())})
230
375
  except Exception as e:
@@ -1,7 +1,7 @@
1
1
  """identidade.py — quem é o assistente e quem é a pessoa.
2
2
 
3
3
  O assistente se chama "Primo" até a pessoa dar outro nome ("seu nome agora é
4
- Luna", ou `primocode voz nome Luna`). A palavra de ativação é esse nome. Fica em
4
+ Luna", ou `primocode voz nome Luna`). O nome NÃO ativa nada: o modo voz só abre no clique. Fica em
5
5
  ~/.primocode/voz.json, junto com o nome da pessoa e as chaves de luz e som.
6
6
 
7
7
  Personalidade: curta, natural, humor leve. Cumprimenta pela hora, lembra o nome
@@ -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