primocode 10.14.0 → 10.15.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.
- package/LICENSES/robo.txt +19 -0
- package/README.md +6 -2
- package/bin/primocode.js +46 -3
- package/lib/act.js +9 -2
- package/lib/estudio/cota.js +10 -3
- package/lib/estudio/editor.js +5 -0
- package/lib/estudio/index.js +9 -2
- package/lib/estudio/projetos.js +13 -1
- package/lib/limites.js +100 -0
- package/lib/marca-kit.js +3 -0
- package/lib/planos.js +10 -8
- package/lib/preparar.js +2 -0
- package/lib/voz-ponte.js +15 -0
- package/lib/voz.js +75 -5
- package/package.json +3 -2
- package/voz/primo_voz/__main__.py +307 -54
- package/voz/primo_voz/conversa.py +236 -0
- package/voz/primo_voz/escuta.py +32 -2
- package/voz/primo_voz/hud.html +550 -0
- package/voz/primo_voz/hud.py +224 -0
- package/voz/primo_voz/identidade.py +88 -0
- package/voz/primo_voz/luz.py +137 -0
- package/voz/primo_voz/painel.py +459 -0
- package/voz/primo_voz/painel_tui.py +26 -0
- package/voz/primo_voz/sons.py +96 -0
- package/voz/primo_voz/tela.py +337 -0
- package/voz/primo_voz/vendor/RobotExpressive.glb +0 -0
- package/voz/primo_voz/vendor/spline-runtime.min.js +8219 -0
- package/voz/primo_voz/vendor/three-robo.min.js +3824 -0
- package/voz/primo_voz/vendor/three.LICENSE.txt +21 -0
- package/voz/primo_voz/vivo.py +34 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""conversa.py — a lógica da conversa, sem microfone, sem tela e sem som.
|
|
2
|
+
|
|
3
|
+
Tudo aqui é puro (entra texto/evento, sai decisão), de propósito: é o que o
|
|
4
|
+
teste mede. O que toca hardware (escuta.py, hud.py, __main__.py) só chama isto.
|
|
5
|
+
|
|
6
|
+
Maquina ouvindo → pensando → falando → (interrompido) → ouvindo
|
|
7
|
+
Fatiador texto em streaming → frases prontas para falar
|
|
8
|
+
resumo resposta longa → (fala curta, o que vai para o painel)
|
|
9
|
+
ativacao "Primo, abre o projeto" → (True, "abre o projeto")
|
|
10
|
+
comando "esconde o painel" → "esconder"
|
|
11
|
+
confirmacao ferramenta → "rodando os testes"
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import unicodedata
|
|
17
|
+
from typing import List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _sem_acento(s: str) -> str:
|
|
21
|
+
return "".join(ch for ch in unicodedata.normalize("NFD", s) if unicodedata.category(ch) != "Mn")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalizar(s: str) -> str:
|
|
25
|
+
"""minúsculo, sem acento, sem pontuação — para comparar o que o Whisper deu."""
|
|
26
|
+
s = _sem_acento(str(s or "").lower())
|
|
27
|
+
return re.sub(r"\s+", " ", re.sub(r"[^a-z0-9 ]+", " ", s)).strip()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ── a máquina de estados ─────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
ESTADOS = ("dormindo", "ouvindo", "pensando", "falando", "executando", "interrompido")
|
|
33
|
+
|
|
34
|
+
# evento → {estado de origem → estado novo}. O que não está aqui é ignorado
|
|
35
|
+
# (um evento fora de hora não pode quebrar a conversa).
|
|
36
|
+
TRANSICOES = {
|
|
37
|
+
"ativar": {"dormindo": "ouvindo", "ouvindo": "ouvindo"},
|
|
38
|
+
"dormir": {"ouvindo": "dormindo", "interrompido": "dormindo"},
|
|
39
|
+
"ouviu": {"ouvindo": "pensando", "interrompido": "pensando", "dormindo": "pensando"},
|
|
40
|
+
"ferramenta": {"pensando": "executando", "executando": "executando", "falando": "falando"},
|
|
41
|
+
"resposta": {"pensando": "falando", "executando": "falando", "ouvindo": "falando"},
|
|
42
|
+
"fim_fala": {"falando": "ouvindo", "interrompido": "ouvindo"},
|
|
43
|
+
"sem_fala": {"pensando": "ouvindo", "executando": "ouvindo"},
|
|
44
|
+
"cortou": {"falando": "interrompido"},
|
|
45
|
+
"escutar": {"interrompido": "ouvindo"},
|
|
46
|
+
"parar": {"pensando": "ouvindo", "executando": "ouvindo", "falando": "ouvindo", "interrompido": "ouvindo"},
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Maquina:
|
|
51
|
+
"""O estado da conversa. `mandar(evento)` devolve o estado novo."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, inicial: str = "ouvindo") -> None:
|
|
54
|
+
self.estado = inicial
|
|
55
|
+
self.historico: List[str] = [inicial]
|
|
56
|
+
|
|
57
|
+
def mandar(self, evento: str) -> str:
|
|
58
|
+
novo = TRANSICOES.get(evento, {}).get(self.estado)
|
|
59
|
+
if novo and novo != self.estado:
|
|
60
|
+
self.estado = novo
|
|
61
|
+
self.historico.append(novo)
|
|
62
|
+
return self.estado
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def pode_cortar(self) -> bool:
|
|
66
|
+
"""Falar por cima só corta se ele está falando."""
|
|
67
|
+
return self.estado == "falando"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# ── frase a frase ────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
_FIM = re.compile(r"(?<=[.!?…])\s+|(?<=\n)")
|
|
73
|
+
_ABREV = re.compile(r"\b(sr|sra|dr|dra|etc|ex|obs|vs|p\.ex)\.$", re.I)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class Fatiador:
|
|
77
|
+
"""Recebe texto aos pedaços (streaming) e devolve frases completas.
|
|
78
|
+
|
|
79
|
+
Frase curta demais não sai sozinha (a voz picotada soa pior que a espera):
|
|
80
|
+
gruda na seguinte. A primeira frase sai mais cedo (mínimo menor), porque é
|
|
81
|
+
ela que decide quanto tempo a pessoa espera até ouvir algo."""
|
|
82
|
+
|
|
83
|
+
def __init__(self, minimo: int = 24, minimo_primeira: int = 12) -> None:
|
|
84
|
+
self.buf = ""
|
|
85
|
+
self.minimo = minimo
|
|
86
|
+
self.minimo_primeira = minimo_primeira
|
|
87
|
+
self.saiu = 0
|
|
88
|
+
|
|
89
|
+
def _alvo(self) -> int:
|
|
90
|
+
return self.minimo_primeira if self.saiu == 0 else self.minimo
|
|
91
|
+
|
|
92
|
+
def alimentar(self, pedaco: str) -> List[str]:
|
|
93
|
+
self.buf += pedaco or ""
|
|
94
|
+
return self._tirar(final=False)
|
|
95
|
+
|
|
96
|
+
def fechar(self) -> List[str]:
|
|
97
|
+
return self._tirar(final=True)
|
|
98
|
+
|
|
99
|
+
def _tirar(self, final: bool) -> List[str]:
|
|
100
|
+
saida: List[str] = []
|
|
101
|
+
partes = _FIM.split(self.buf)
|
|
102
|
+
resto = partes.pop() if partes else ""
|
|
103
|
+
acumulado = ""
|
|
104
|
+
for p in partes:
|
|
105
|
+
acumulado = (acumulado + " " + p).strip() if acumulado else p.strip()
|
|
106
|
+
if _ABREV.search(acumulado):
|
|
107
|
+
continue
|
|
108
|
+
if len(acumulado) >= self._alvo():
|
|
109
|
+
saida.append(acumulado)
|
|
110
|
+
self.saiu += 1
|
|
111
|
+
acumulado = ""
|
|
112
|
+
self.buf = (acumulado + " " + resto).strip() if acumulado else resto
|
|
113
|
+
if final and self.buf.strip():
|
|
114
|
+
saida.append(self.buf.strip())
|
|
115
|
+
self.saiu += 1
|
|
116
|
+
self.buf = ""
|
|
117
|
+
return saida
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def dividir_frases(texto: str) -> List[str]:
|
|
121
|
+
f = Fatiador()
|
|
122
|
+
return f.alimentar(texto) + f.fechar()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# ── o que vai para o ouvido e o que vai para o painel ────────────────────
|
|
126
|
+
|
|
127
|
+
def limpar_para_voz(texto: str) -> str:
|
|
128
|
+
t = str(texto or "")
|
|
129
|
+
t = re.sub(r"```[\s\S]*?```", " ", t)
|
|
130
|
+
t = re.sub(r"\s+(em|no|na|de|do|da|para)\s+`[^`]*[/\\.][^`]*`", "", t)
|
|
131
|
+
t = re.sub(r"`([^`]*)`", lambda m: "" if re.search(r"[/\\.]", m.group(1)) else m.group(1), t)
|
|
132
|
+
t = re.sub(r"https?://\S+", "", t)
|
|
133
|
+
t = re.sub(r"(^|\s)[~.]?/[\w.\-/]+", " ", t)
|
|
134
|
+
t = re.sub(r"^\s*(#+|[-*•]|\d+[.)])\s+", "", t, flags=re.M)
|
|
135
|
+
t = re.sub(r"[*_>|]", "", t)
|
|
136
|
+
return re.sub(r"\s+", " ", t).strip()
|
|
137
|
+
|
|
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."""
|
|
141
|
+
painel = str(texto or "").strip()
|
|
142
|
+
limpo = limpar_para_voz(painel)
|
|
143
|
+
partes = [p.strip() for p in re.findall(r"[^.!?…]+[.!?…]*", limpo) if p.strip()]
|
|
144
|
+
fala = ""
|
|
145
|
+
for p in partes[:frases]:
|
|
146
|
+
if len(fala) + len(p) > teto:
|
|
147
|
+
break
|
|
148
|
+
fala = (fala + " " + p).strip()
|
|
149
|
+
if not fala:
|
|
150
|
+
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."
|
|
154
|
+
return fala, painel
|
|
155
|
+
|
|
156
|
+
|
|
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
|
+
# ── comandos de voz que o painel e o modo resolvem sozinhos ──────────────
|
|
183
|
+
|
|
184
|
+
_COMANDOS = [
|
|
185
|
+
("esconder", r"(esconde|esconder|some com|fecha|oculta) (o )?(painel|hud)"),
|
|
186
|
+
("mostrar", r"(mostra|mostrar|abre|exibe|volta com) (o )?(painel|hud)"),
|
|
187
|
+
("parar", r"(para|pare|calma|chega|cala a boca|silencio|cancela)"),
|
|
188
|
+
("olhar", r"(olha aqui|olha isso|olha a tela|veja a tela|ve a tela|olha pra tela|ve isso)"),
|
|
189
|
+
("resumo_dia", r"(resumo do dia|como foi o dia|resumo de hoje|briefing|o que mudou hoje)"),
|
|
190
|
+
]
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def comando(texto: str) -> Optional[str]:
|
|
194
|
+
n = normalizar(texto)
|
|
195
|
+
if not n or len(n.split()) > 6: # comando é curto; frase longa é pedido
|
|
196
|
+
return None
|
|
197
|
+
for nome, rx in _COMANDOS:
|
|
198
|
+
if re.fullmatch(rx + r"( por favor| ai| agora)?", n):
|
|
199
|
+
return nome
|
|
200
|
+
return None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# ── confirmações curtas de ação ──────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
def confirmacao(ferramenta: str, args: Optional[dict] = None) -> str:
|
|
206
|
+
"""O que o Primo diz ao começar uma ferramenta. Vazio = não diz nada."""
|
|
207
|
+
f = str(ferramenta or "")
|
|
208
|
+
cmd = str((args or {}).get("command") or "")
|
|
209
|
+
if f == "run_shell":
|
|
210
|
+
if re.search(r"\b(test|jest|pytest|vitest)\b", cmd):
|
|
211
|
+
return "Rodando os testes."
|
|
212
|
+
if re.search(r"\bgit\s+(push|commit)\b", cmd):
|
|
213
|
+
return "Mandando pro git."
|
|
214
|
+
if re.search(r"\b(build|tsc)\b", cmd):
|
|
215
|
+
return "Fazendo o build."
|
|
216
|
+
return "Rodando."
|
|
217
|
+
if f in ("desktop_open_app",):
|
|
218
|
+
return "Abrindo."
|
|
219
|
+
if f in ("write_file", "edit_file", "apply_patch"):
|
|
220
|
+
return "Editando."
|
|
221
|
+
if f in ("web_search", "pesquisar_referencia"):
|
|
222
|
+
return "Pesquisando."
|
|
223
|
+
return ""
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def rotulo_ferramenta(ferramenta: str, args: Optional[dict] = None) -> str:
|
|
227
|
+
"""O texto do painel: ferramenta + alvo curto."""
|
|
228
|
+
a = args or {}
|
|
229
|
+
alvo = a.get("path") or a.get("command") or a.get("query") or a.get("url") or ""
|
|
230
|
+
alvo = re.sub(r"\s+", " ", str(alvo)).strip()
|
|
231
|
+
return (str(ferramenta) + (" · " + alvo[:48] if alvo else ""))[:70]
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def frase_final(texto: str) -> str:
|
|
235
|
+
"""Termina a fala de ação com um 'feito' quando a resposta não tem fala."""
|
|
236
|
+
return texto if texto.strip() else "Feito."
|
package/voz/primo_voz/escuta.py
CHANGED
|
@@ -49,6 +49,7 @@ PRE_ROLL = 4 # blocos guardados ANTES do início da fala
|
|
|
49
49
|
RABO_SILENCIO = 2.0 # silêncio que encerra a frase
|
|
50
50
|
RABO_SILENCIO_LONGA = 2.8 # …e quando a fala já vem longa
|
|
51
51
|
FALA_LONGA = 6.0
|
|
52
|
+
RABO_VAD = 0.9 # silêncio que encerra a frase quando o VAD confirma
|
|
52
53
|
ESPERA_COMECAR = 8.0 # tempo para a pessoa começar a falar
|
|
53
54
|
MAX_FRASE = 45.0
|
|
54
55
|
MAX_FRASE_ESTENDIDA = 100.0
|
|
@@ -93,6 +94,9 @@ class Gravador:
|
|
|
93
94
|
self._ambiente = PISO_FALA
|
|
94
95
|
self._amostras_ambiente: List[float] = []
|
|
95
96
|
self.encerrou_por = ""
|
|
97
|
+
# VAD opcional (Silero, via pysilero-vad): bloco → True se for voz.
|
|
98
|
+
# Com ele, a frase fecha em 0,9 s de silêncio em vez de 2 s.
|
|
99
|
+
self.vad = None
|
|
96
100
|
|
|
97
101
|
# O ambiente é medido ENQUANTO ele espera a pessoa começar. Medir antes de
|
|
98
102
|
# abrir o microfone daria o silêncio do processo, não o do cômodo.
|
|
@@ -114,6 +118,12 @@ class Gravador:
|
|
|
114
118
|
self._relogio += BLOCO_MS / 1000.0
|
|
115
119
|
agora = self._relogio
|
|
116
120
|
nivel = rms(bloco)
|
|
121
|
+
voz = None
|
|
122
|
+
if self.vad is not None:
|
|
123
|
+
try:
|
|
124
|
+
voz = bool(self.vad(bloco))
|
|
125
|
+
except Exception:
|
|
126
|
+
self.vad = None
|
|
117
127
|
|
|
118
128
|
if not self._falando:
|
|
119
129
|
# Guarda os últimos blocos: sem eles, a frase começa cortada na
|
|
@@ -128,7 +138,7 @@ class Gravador:
|
|
|
128
138
|
if len(self._amostras_ambiente) >= 5:
|
|
129
139
|
self._ambiente = float(np.median(self._amostras_ambiente))
|
|
130
140
|
|
|
131
|
-
if nivel > self._limiar():
|
|
141
|
+
if nivel > self._limiar() and voz is not False:
|
|
132
142
|
self._falando = True
|
|
133
143
|
self._blocos = list(self._pre)
|
|
134
144
|
self._ultimo_som = agora
|
|
@@ -138,11 +148,13 @@ class Gravador:
|
|
|
138
148
|
return False
|
|
139
149
|
|
|
140
150
|
self._blocos.append(bloco)
|
|
141
|
-
if nivel > self._limiar():
|
|
151
|
+
if voz is True or (voz is None and nivel > self._limiar()):
|
|
142
152
|
self._ultimo_som = agora
|
|
143
153
|
|
|
144
154
|
duracao = len(self._blocos) * BLOCO_MS / 1000.0
|
|
145
155
|
rabo = RABO_SILENCIO_LONGA if duracao > FALA_LONGA else RABO_SILENCIO
|
|
156
|
+
if self.vad is not None:
|
|
157
|
+
rabo = RABO_VAD
|
|
146
158
|
|
|
147
159
|
if agora - self._ultimo_som > rabo:
|
|
148
160
|
self.encerrou_por = "silencio"
|
|
@@ -234,3 +246,21 @@ class Microfone:
|
|
|
234
246
|
except Exception:
|
|
235
247
|
pass
|
|
236
248
|
self._fluxo = None
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def criar_vad():
|
|
252
|
+
"""O detector de voz Silero (MIT, pysilero-vad, só onnx/ggml) ou None se não instalou."""
|
|
253
|
+
try:
|
|
254
|
+
from pysilero_vad import SileroVoiceActivityDetector
|
|
255
|
+
det = SileroVoiceActivityDetector()
|
|
256
|
+
except Exception:
|
|
257
|
+
return None
|
|
258
|
+
passo = det.chunk_samples()
|
|
259
|
+
|
|
260
|
+
def vad(bloco: np.ndarray) -> bool:
|
|
261
|
+
pcm = bloco.astype(np.int16)
|
|
262
|
+
pico = 0.0
|
|
263
|
+
for i in range(0, len(pcm) - passo + 1, passo):
|
|
264
|
+
pico = max(pico, det(pcm[i:i + passo].tobytes()))
|
|
265
|
+
return pico > 0.5
|
|
266
|
+
return vad
|