primocode 10.15.0 → 10.15.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/primocode.js +18 -3
- package/lib/config.js +1 -1
- package/lib/conta.js +1 -0
- package/lib/estudio/exportar.js +1 -0
- package/lib/mcp.js +1 -0
- package/lib/tools.js +1 -0
- package/lib/voz.js +27 -3
- package/package.json +1 -1
- package/voz/primo_voz/__main__.py +201 -24
- package/voz/primo_voz/app.py +451 -0
- package/voz/primo_voz/appmac.py +125 -0
- package/voz/primo_voz/assets/mascote.svg +1 -0
- package/voz/primo_voz/conversa.py +17 -6
- package/voz/primo_voz/escuta.py +30 -4
- package/voz/primo_voz/fala.py +87 -0
- package/voz/primo_voz/fala_pt.py +151 -0
- package/voz/primo_voz/hud.html +60 -26
- package/voz/primo_voz/hud.py +84 -12
- package/voz/primo_voz/identidade.py +1 -1
- package/voz/primo_voz/tela.py +36 -54
- package/voz/primo_voz/transcrever.py +19 -2
- package/voz/primo_voz/vendor/primo-icone.png +0 -0
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
|
|
@@ -29,9 +30,10 @@ from primo_voz.sons import Sons
|
|
|
29
30
|
AQUI = Path(__file__).resolve().parent
|
|
30
31
|
|
|
31
32
|
|
|
32
|
-
def main(demo: bool = False) -> int:
|
|
33
|
+
def main(demo: bool = False, entrada=None, saida=None, ao_iniciar=None, ao_sair=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."""
|
|
33
35
|
try:
|
|
34
|
-
from AppKit import (NSApplication,
|
|
36
|
+
from AppKit import (NSApplication, NSApplicationActivationPolicyRegular, NSImage, NSMenu, NSMenuItem, NSBackingStoreBuffered, NSColor,
|
|
35
37
|
NSEvent, NSScreen, NSWindow)
|
|
36
38
|
from Foundation import NSMakeRect, NSObject, NSTimer, NSURL
|
|
37
39
|
import objc
|
|
@@ -44,6 +46,13 @@ def main(demo: bool = False) -> int:
|
|
|
44
46
|
fila: "queue.Queue[str]" = queue.Queue()
|
|
45
47
|
sons = Sons(os.environ.get("PRIMOCODE_SEM_SONS") != "1")
|
|
46
48
|
|
|
49
|
+
def emitir(obj: dict) -> None:
|
|
50
|
+
if saida:
|
|
51
|
+
saida(obj)
|
|
52
|
+
else:
|
|
53
|
+
sys.stdout.write(json.dumps(obj) + "\n")
|
|
54
|
+
sys.stdout.flush()
|
|
55
|
+
|
|
47
56
|
def js(msg: dict) -> None:
|
|
48
57
|
fila.put("window.primo(" + json.dumps(msg, ensure_ascii=False) + ")")
|
|
49
58
|
|
|
@@ -75,6 +84,34 @@ def main(demo: bool = False) -> int:
|
|
|
75
84
|
else:
|
|
76
85
|
self.jan.orderOut_(None)
|
|
77
86
|
|
|
87
|
+
class Dono(NSObject):
|
|
88
|
+
def windowShouldClose_(self, _): # fechar o painel só esconde; o PrimoCode segue
|
|
89
|
+
p.visivel = False
|
|
90
|
+
return True
|
|
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
|
+
|
|
104
|
+
def applicationWillTerminate_(self, _n):
|
|
105
|
+
if ao_sair:
|
|
106
|
+
try:
|
|
107
|
+
ao_sair()
|
|
108
|
+
except Exception:
|
|
109
|
+
pass
|
|
110
|
+
|
|
111
|
+
def applicationShouldHandleReopen_hasVisibleWindows_(self, _a, _v): # clique no Dock traz de volta
|
|
112
|
+
p.visivel = True
|
|
113
|
+
return True
|
|
114
|
+
|
|
78
115
|
class Msg(NSObject, protocols=[objc.protocolNamed("WKScriptMessageHandler")]):
|
|
79
116
|
def userContentController_didReceiveScriptMessage_(self, ucc, msg):
|
|
80
117
|
try:
|
|
@@ -87,9 +124,8 @@ def main(demo: bool = False) -> int:
|
|
|
87
124
|
sys.stderr.flush()
|
|
88
125
|
elif a == "sair":
|
|
89
126
|
p.visivel = False
|
|
90
|
-
elif a
|
|
91
|
-
|
|
92
|
-
sys.stdout.flush()
|
|
127
|
+
elif a in ("ptt", "entrar"):
|
|
128
|
+
emitir({"acao": a})
|
|
93
129
|
elif a == "print": # esconde o HUD para o print pegar a tela de verdade
|
|
94
130
|
p.visivel = False
|
|
95
131
|
threading.Timer(0.6, lambda: pn.executar_acao(m)).start()
|
|
@@ -105,7 +141,26 @@ def main(demo: bool = False) -> int:
|
|
|
105
141
|
threading.Thread(target=_fazer, daemon=True).start()
|
|
106
142
|
|
|
107
143
|
app = NSApplication.sharedApplication()
|
|
108
|
-
app.setActivationPolicy_(
|
|
144
|
+
app.setActivationPolicy_(NSApplicationActivationPolicyRegular) # app de verdade: Dock e Cmd+Tab
|
|
145
|
+
try: # nome e ícone do Primo, mesmo fora do bundle
|
|
146
|
+
from AppKit import NSBundle
|
|
147
|
+
info = NSBundle.mainBundle().infoDictionary()
|
|
148
|
+
info["CFBundleName"] = info["CFBundleDisplayName"] = "Primo"
|
|
149
|
+
icone = AQUI / "vendor" / "primo-icone.png"
|
|
150
|
+
if icone.exists():
|
|
151
|
+
app.setApplicationIconImage_(NSImage.alloc().initWithContentsOfFile_(str(icone)))
|
|
152
|
+
except Exception as e:
|
|
153
|
+
sys.stderr.write(f"hud: ícone: {e}\n")
|
|
154
|
+
barra = NSMenu.alloc().init()
|
|
155
|
+
raiz = NSMenuItem.alloc().init()
|
|
156
|
+
barra.addItem_(raiz)
|
|
157
|
+
menu = NSMenu.alloc().init()
|
|
158
|
+
for rot, sel, tec in (("Sobre o Primo", "orderFrontStandardAboutPanel:", ""), (None, None, None),
|
|
159
|
+
("Esconder o Primo", "hide:", "h"), (None, None, None), ("Sair do Primo", "terminate:", "q")):
|
|
160
|
+
menu.addItem_(NSMenuItem.separatorItem() if rot is None else
|
|
161
|
+
NSMenuItem.alloc().initWithTitle_action_keyEquivalent_(rot, sel, tec))
|
|
162
|
+
raiz.setSubmenu_(menu)
|
|
163
|
+
app.setMainMenu_(barra)
|
|
109
164
|
mouse = NSEvent.mouseLocation()
|
|
110
165
|
alvo_tela = next((s for s in NSScreen.screens() if s.frame().origin.x <= mouse.x < s.frame().origin.x + s.frame().size.width
|
|
111
166
|
and s.frame().origin.y <= mouse.y < s.frame().origin.y + s.frame().size.height), NSScreen.mainScreen())
|
|
@@ -115,6 +170,10 @@ def main(demo: bool = False) -> int:
|
|
|
115
170
|
jan.setTitlebarAppearsTransparent_(True)
|
|
116
171
|
jan.setTitleVisibility_(1) # sem texto de título
|
|
117
172
|
jan.setTitle_("Primo")
|
|
173
|
+
jan.setReleasedWhenClosed_(False)
|
|
174
|
+
dono = Dono.alloc().init()
|
|
175
|
+
jan.setDelegate_(dono)
|
|
176
|
+
app.setDelegate_(dono)
|
|
118
177
|
jan.setMinSize_((900, 600))
|
|
119
178
|
jan.setOpaque_(True)
|
|
120
179
|
jan.setBackgroundColor_(NSColor.colorWithSRGBRed_green_blue_alpha_(0.043, 0.043, 0.039, 1.0))
|
|
@@ -174,11 +233,19 @@ def main(demo: bool = False) -> int:
|
|
|
174
233
|
def agenda(): # em outra thread: o pedido de permissão do Calendário pode demorar
|
|
175
234
|
evs: dict = {"permissao": "pendente", "eventos": []}
|
|
176
235
|
k = 0
|
|
236
|
+
falhas = 0
|
|
177
237
|
while True:
|
|
178
238
|
try:
|
|
179
|
-
if k % 12 == 0:
|
|
180
|
-
|
|
181
|
-
|
|
239
|
+
if k % 12 == 0 or evs["permissao"] != "ok": # sem acesso, olha a cada 5 s (a pessoa pode ter liberado)
|
|
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"]})}})
|
|
182
249
|
if evs["permissao"] == "ok":
|
|
183
250
|
js({"hoje": td.hoje_lista(evs["eventos"], time.time())})
|
|
184
251
|
except Exception as e:
|
|
@@ -188,7 +255,7 @@ def main(demo: bool = False) -> int:
|
|
|
188
255
|
threading.Thread(target=agenda, daemon=True).start()
|
|
189
256
|
|
|
190
257
|
def ler():
|
|
191
|
-
for l in sys.stdin:
|
|
258
|
+
for l in (entrada if entrada is not None else sys.stdin):
|
|
192
259
|
try:
|
|
193
260
|
m = json.loads(l)
|
|
194
261
|
except ValueError:
|
|
@@ -197,7 +264,8 @@ def main(demo: bool = False) -> int:
|
|
|
197
264
|
js(m)
|
|
198
265
|
if "estado" in m or "nivel" in m:
|
|
199
266
|
js({"barras": td.barras(p.estado, p.nivel)})
|
|
200
|
-
|
|
267
|
+
if entrada is None:
|
|
268
|
+
os._exit(0)
|
|
201
269
|
|
|
202
270
|
def encenar():
|
|
203
271
|
roteiro = [("dormindo", "", "", 3), ("ouvindo", "roda os testes do projeto", "", 4),
|
|
@@ -214,8 +282,12 @@ def main(demo: bool = False) -> int:
|
|
|
214
282
|
time.sleep(0.06)
|
|
215
283
|
if est == "falando":
|
|
216
284
|
js({"evento": "sucesso"})
|
|
217
|
-
if
|
|
285
|
+
if entrada is not None:
|
|
286
|
+
threading.Thread(target=ler, daemon=True).start()
|
|
287
|
+
elif "--livre" not in sys.argv:
|
|
218
288
|
threading.Thread(target=encenar if demo else ler, daemon=True).start()
|
|
289
|
+
if ao_iniciar:
|
|
290
|
+
ao_iniciar(js, p)
|
|
219
291
|
app.run()
|
|
220
292
|
return 0
|
|
221
293
|
|
|
@@ -16,7 +16,7 @@ import re
|
|
|
16
16
|
from pathlib import Path
|
|
17
17
|
from typing import Optional
|
|
18
18
|
|
|
19
|
-
PADRAO = {"nome": "Primo", "pessoa": "", "luz": True, "sons": True, "robo": "spline"}
|
|
19
|
+
PADRAO = {"nome": "Primo", "pessoa": "", "luz": True, "sons": True, "robo": "spline", "voz": "francisca", "tts": "edge"}
|
|
20
20
|
|
|
21
21
|
|
|
22
22
|
def arquivo() -> Path:
|
package/voz/primo_voz/tela.py
CHANGED
|
@@ -116,68 +116,52 @@ def calendario_permissao() -> str:
|
|
|
116
116
|
from EventKit import EKEventStore
|
|
117
117
|
except ImportError:
|
|
118
118
|
return "sem"
|
|
119
|
-
st = EKEventStore.authorizationStatusForEntityType_(0)
|
|
120
|
-
|
|
119
|
+
st = int(EKEventStore.authorizationStatusForEntityType_(0))
|
|
120
|
+
# 4 = só escrita (um pedido antigo concedeu menos do que o Primo precisa): o macOS não deixa "subir" por código,
|
|
121
|
+
# então tratamos como pendente e o pedido zera a decisão do próprio app antes de pedir o acesso completo.
|
|
122
|
+
return {3: "ok", 0: "pendente", 4: "pendente", 1: "negada", 2: "negada"}.get(st, "negada")
|
|
121
123
|
|
|
122
124
|
|
|
123
125
|
def pedir_calendario() -> None:
|
|
124
|
-
"""Dispara o pedido do macOS (uma vez). Não bloqueia."""
|
|
126
|
+
"""Dispara o pedido do macOS (uma vez). Não bloqueia. O resultado vai para o log (stderr)."""
|
|
127
|
+
if time.time() - _ek.get("pedindo", 0) < 120: # o aviso do macOS já está na tela esperando resposta
|
|
128
|
+
return
|
|
129
|
+
_ek["pedindo"] = time.time()
|
|
130
|
+
|
|
131
|
+
def fim(ok, err):
|
|
132
|
+
_ek["pedindo"] = 0
|
|
133
|
+
sys.stderr.write(f"calendario: pedido respondido ok={bool(ok)} erro={err} status={calendario_permissao()}\n")
|
|
134
|
+
sys.stderr.flush()
|
|
125
135
|
try:
|
|
136
|
+
from EventKit import EKEventStore
|
|
137
|
+
if int(EKEventStore.authorizationStatusForEntityType_(0)) == 4:
|
|
138
|
+
r = subprocess.run(["tccutil", "reset", "Calendar", "ai.primocode.primo"], capture_output=True, text=True)
|
|
139
|
+
sys.stderr.write(f"calendario: acesso só de escrita; zerando a decisão do Primo ({r.returncode})\n")
|
|
140
|
+
_ek.pop("s", None)
|
|
126
141
|
s = _store()
|
|
142
|
+
sys.stderr.write(f"calendario: pedindo acesso (status antes={calendario_permissao()}, bundle={_bundle()})\n")
|
|
143
|
+
sys.stderr.flush()
|
|
127
144
|
if hasattr(s, "requestFullAccessToEventsWithCompletion_"):
|
|
128
|
-
s.requestFullAccessToEventsWithCompletion_(
|
|
145
|
+
s.requestFullAccessToEventsWithCompletion_(fim)
|
|
129
146
|
else:
|
|
130
|
-
s.requestAccessToEntityType_completion_(0,
|
|
131
|
-
except Exception:
|
|
132
|
-
|
|
147
|
+
s.requestAccessToEntityType_completion_(0, fim)
|
|
148
|
+
except Exception as e:
|
|
149
|
+
sys.stderr.write(f"calendario: falhou ao pedir: {e}\n")
|
|
133
150
|
|
|
134
151
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
set out to ""
|
|
142
|
-
tell application "Calendar"
|
|
143
|
-
repeat with c in calendars
|
|
144
|
-
repeat with e in (every event of c whose start date >= d1 and start date < d2)
|
|
145
|
-
set out to out & (summary of e) & "|||" & ((start date of e) - agora) & "|||" & ((end date of e) - agora) & "|||" & (allday event of e) & linefeed
|
|
146
|
-
end repeat
|
|
147
|
-
end repeat
|
|
148
|
-
end tell
|
|
149
|
-
return out'''
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def _eventos_osascript(agora: float) -> dict:
|
|
153
|
-
"""Plano B quando o EventKit não consegue pedir a permissão (Python sem pacote de app): o app Calendário via osascript."""
|
|
154
|
-
r = subprocess.run(["osascript", "-e", _OSA_CAL], capture_output=True, text=True, timeout=25)
|
|
155
|
-
if r.returncode != 0:
|
|
156
|
-
return {"permissao": "negada" if "-1743" in r.stderr else "pendente", "eventos": []}
|
|
157
|
-
out = []
|
|
158
|
-
for l in r.stdout.splitlines():
|
|
159
|
-
p = l.split("|||")
|
|
160
|
-
if len(p) == 4:
|
|
161
|
-
try:
|
|
162
|
-
a, b = agora + float(p[1]), agora + float(p[2])
|
|
163
|
-
except ValueError:
|
|
164
|
-
continue
|
|
165
|
-
out.append({"titulo": p[0] or "(sem título)", "inicio": a, "fim": b, "dia_todo": p[3].strip() == "true",
|
|
166
|
-
"dia": time.localtime(a).tm_mday})
|
|
167
|
-
out.sort(key=lambda e: e["inicio"])
|
|
168
|
-
return {"permissao": "ok", "eventos": out}
|
|
152
|
+
def _bundle() -> str:
|
|
153
|
+
try:
|
|
154
|
+
from Foundation import NSBundle
|
|
155
|
+
return str(NSBundle.mainBundle().bundleIdentifier())
|
|
156
|
+
except Exception:
|
|
157
|
+
return "?"
|
|
169
158
|
|
|
170
159
|
|
|
171
160
|
def eventos_do_mes(agora: Optional[float] = None) -> dict:
|
|
172
161
|
"""{'permissao','eventos':[{titulo,inicio,fim,dia_todo,dia}]} do mês inteiro (hoje e dias com ponto)."""
|
|
173
162
|
perm = calendario_permissao()
|
|
174
|
-
if perm
|
|
175
|
-
|
|
176
|
-
pedir_calendario()
|
|
177
|
-
try:
|
|
178
|
-
return _eventos_osascript(agora or time.time())
|
|
179
|
-
except Exception:
|
|
180
|
-
return {"permissao": perm, "eventos": []}
|
|
163
|
+
if perm == "pendente":
|
|
164
|
+
pedir_calendario()
|
|
181
165
|
if perm != "ok":
|
|
182
166
|
return {"permissao": perm, "eventos": []}
|
|
183
167
|
from Foundation import NSDate
|
|
@@ -327,11 +311,9 @@ def acao(m: dict) -> bool:
|
|
|
327
311
|
"""Ações da tela que só ela entende. True = tratada."""
|
|
328
312
|
a, arg = m.get("acao"), str(m.get("arg") or "")
|
|
329
313
|
if a == "calendario":
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
if r["permissao"] != "ok":
|
|
335
|
-
subprocess.Popen(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation"])
|
|
314
|
+
if calendario_permissao() == "pendente":
|
|
315
|
+
pedir_calendario() # o pedido do macOS sai com o nome do app (Primo)
|
|
316
|
+
else:
|
|
317
|
+
subprocess.Popen(["open", "x-apple.systempreferences:com.apple.preference.security?Privacy_Calendars"])
|
|
336
318
|
return True
|
|
337
319
|
return False
|
|
@@ -69,6 +69,8 @@ def aquecer() -> None:
|
|
|
69
69
|
def _ir():
|
|
70
70
|
try:
|
|
71
71
|
carregar()
|
|
72
|
+
import numpy as np # uma passada em silêncio: a primeira frase de verdade já não paga o aquecimento
|
|
73
|
+
list(_modelo.transcribe(np.zeros(16000, dtype=np.float32), language="pt", beam_size=1)[0])
|
|
72
74
|
except Exception:
|
|
73
75
|
pass
|
|
74
76
|
threading.Thread(target=_ir, daemon=True).start()
|
|
@@ -110,12 +112,27 @@ def aproveitavel(segmento) -> bool:
|
|
|
110
112
|
return not (sem_fala > SEM_FALA and confianca < CONFIANCA_MINIMA)
|
|
111
113
|
|
|
112
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
|
+
|
|
113
120
|
def transcrever(wav: Path, idioma: str = "pt") -> str:
|
|
114
121
|
modelo = carregar()
|
|
122
|
+
entrada: object = str(wav)
|
|
123
|
+
try: # WAV 16 kHz mono: entrega o áudio já decodificado (não depende do `av`, que às vezes destoa do faster-whisper)
|
|
124
|
+
import wave
|
|
125
|
+
import numpy as np
|
|
126
|
+
with wave.open(str(wav), "rb") as w:
|
|
127
|
+
if w.getframerate() == 16000 and w.getnchannels() == 1 and w.getsampwidth() == 2:
|
|
128
|
+
entrada = np.frombuffer(w.readframes(w.getnframes()), dtype=np.int16).astype(np.float32) / 32768.0
|
|
129
|
+
except Exception:
|
|
130
|
+
entrada = str(wav)
|
|
115
131
|
# vad_filter corta o silêncio das pontas: sem ele, o Whisper às vezes
|
|
116
132
|
# "ouve" uma frase inteira inventada no silêncio ("Obrigado por assistir").
|
|
117
|
-
segmentos, _ = modelo.transcribe(
|
|
118
|
-
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)
|
|
119
136
|
return limpar(" ".join(s.text.strip() for s in segmentos if aproveitavel(s)))
|
|
120
137
|
|
|
121
138
|
|
|
Binary file
|