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.
@@ -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, NSApplicationActivationPolicyAccessory, NSBackingStoreBuffered, NSColor,
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 == "ptt":
91
- sys.stdout.write(json.dumps({"acao": "ptt"}) + "\n")
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_(NSApplicationActivationPolicyAccessory)
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
- evs = td.eventos_do_mes()
181
- js({"calendario": {"permissao": evs["permissao"], "dias": sorted({e["dia"] for e in evs["eventos"]})}})
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
- os._exit(0)
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 "--livre" not in sys.argv:
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:
@@ -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
- return {3: "ok", 0: "pendente", 4: "negada", 1: "negada", 2: "negada"}.get(int(st), "negada")
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_(lambda ok, err: None)
145
+ s.requestFullAccessToEventsWithCompletion_(fim)
129
146
  else:
130
- s.requestAccessToEntityType_completion_(0, lambda ok, err: None)
131
- except Exception:
132
- pass
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
- _OSA_CAL = '''set d1 to current date
136
- set day of d1 to 1
137
- set time of d1 to 0
138
- set d2 to d1 + 32 * days
139
- set day of d2 to 1
140
- set agora to current date
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 in ("pendente", "negada") and sys.platform == "darwin":
175
- if perm == "pendente":
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
- try:
331
- r = _eventos_osascript(time.time()) # dispara o pedido do macOS (Automação > Calendário)
332
- except Exception:
333
- r = {"permissao": "pendente"}
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(str(wav), language=idioma, beam_size=5,
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