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.
@@ -0,0 +1,451 @@
1
+ """app.py — o Primo como app (Finder, Launchpad, Dock): abre sem terminal e funciona inteiro.
2
+
3
+ O processo do app é o painel (hud.py). Daqui sobem os filhos, todos mortos junto com o app:
4
+ · o motor de voz (`primo_voz`: microfone, Whisper, voz) — o painel dele é este processo (PRIMOCODE_HUD_PONTE);
5
+ · o cérebro: cada frase vira `primocode -p "<frase>" --output-format json` na pasta de trabalho, e o
6
+ resultado volta falado. Node e primocode vêm de ~/.primocode/primo-app.json (o app não tem o PATH do terminal).
7
+ Log: ~/.primocode/logs/primo-app.log
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ import queue
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ import unicodedata
20
+ from pathlib import Path
21
+
22
+ HOME = Path.home()
23
+ PASTA_PC = HOME / ".primocode"
24
+ LOG = PASTA_PC / "logs" / "primo-app.log"
25
+ AQUI = Path(__file__).resolve().parent
26
+ PATH_BASE = "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"
27
+
28
+
29
+ def log(msg: str) -> None:
30
+ try:
31
+ LOG.parent.mkdir(parents=True, exist_ok=True)
32
+ with open(LOG, "a", encoding="utf-8") as f:
33
+ f.write(time.strftime("%Y-%m-%d %H:%M:%S ") + str(msg).rstrip() + "\n")
34
+ except Exception:
35
+ pass
36
+
37
+
38
+ def _json(caminho: Path) -> dict:
39
+ try:
40
+ return json.loads(caminho.read_text(encoding="utf-8"))
41
+ except Exception:
42
+ return {}
43
+
44
+
45
+ def logado() -> bool:
46
+ c = _json(PASTA_PC / "conta.json")
47
+ return bool(c.get("refreshToken") or c.get("idToken") or c.get("token"))
48
+
49
+
50
+ def _sem_acento(t: str) -> str:
51
+ return "".join(ch for ch in unicodedata.normalize("NFD", t.lower()) if unicodedata.category(ch) != "Mn")
52
+
53
+
54
+ RAIZ = HOME / "Primo" # a área do app: projetos novos por voz nascem em ~/Primo/<nome>
55
+
56
+
57
+ def pasta_atual() -> Path:
58
+ RAIZ.mkdir(exist_ok=True)
59
+ return RAIZ
60
+
61
+
62
+ def gravar_pasta(d: Path) -> None:
63
+ """Mesmo arquivo que o lib/config.js (lembrarProjeto) lê: a próxima sessão do PrimoCode cai aqui também."""
64
+ j = _json(PASTA_PC / "projeto-ativo.json")
65
+ rec = [str(d)] + [x for x in (j.get("recentes") or []) if x != str(d)][:9]
66
+ (PASTA_PC / "projeto-ativo.json").write_text(
67
+ json.dumps({"dir": str(d), "origem": "/dir", "quando": int(time.time() * 1000), "recentes": rec}, indent=2), encoding="utf-8")
68
+
69
+
70
+ def achar_projeto(nome: str) -> "Path | None":
71
+ palavras = _sem_acento(nome).replace("-", " ").split()
72
+ if not palavras:
73
+ return None
74
+ cand = [Path(x) for x in (_json(PASTA_PC / "projeto-ativo.json").get("recentes") or [])]
75
+ for base in (RAIZ, HOME / "primocode", HOME):
76
+ try:
77
+ cand += [c for c in base.iterdir() if c.is_dir() and not c.name.startswith(".")]
78
+ except OSError:
79
+ pass
80
+ for c in cand:
81
+ n = _sem_acento(c.name).replace("-", " ").replace("_", " ")
82
+ if c.is_dir() and all(p in n for p in palavras):
83
+ return c
84
+ return None
85
+
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+|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)
90
+
91
+
92
+ def _hora_falada(t: "time.struct_time") -> str:
93
+ from primo_voz.fala import hora_falada
94
+ return hora_falada(t)
95
+
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
+
133
+ class App:
134
+ def __init__(self) -> None:
135
+ self.cfg = _json(PASTA_PC / "primo-app.json")
136
+ self.node = self.cfg.get("node") or ""
137
+ self.primocode = self.cfg.get("primocode") or ""
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 = ""
142
+ self.motor: "subprocess.Popen | None" = None
143
+ self.cerebro: "subprocess.Popen | None" = None
144
+ self.entrada: "queue.Queue" = queue.Queue()
145
+ self.js = lambda m: None
146
+ self.turnos = 0
147
+ self.vivo = True
148
+ self.trava = threading.Lock()
149
+ self.cache_clima: dict = {}
150
+
151
+ # ── entrada do painel (iterável de linhas JSON) ──
152
+ def __iter__(self):
153
+ while True:
154
+ yield self.entrada.get()
155
+
156
+ def rotulo(self) -> str:
157
+ return "~/Primo" if self.pasta == RAIZ else self.pasta.name
158
+
159
+ def painel(self, msg: dict) -> None:
160
+ self.entrada.put(json.dumps(msg, ensure_ascii=False))
161
+
162
+ def saida_do_painel(self, obj: dict) -> None:
163
+ a = obj.get("acao")
164
+ if a == "ptt":
165
+ self.motor_envia({"tipo": "acao", "acao": "ptt"})
166
+ elif a == "entrar":
167
+ self.entrar()
168
+
169
+ # ── login ──
170
+ def entrar(self) -> None:
171
+ raiz = Path(self.primocode).resolve().parent.parent if self.primocode else None
172
+ if not (self.node and raiz and (raiz / "lib" / "conta.js").exists()):
173
+ log("entrar: node/primocode não registrados")
174
+ self.painel({"resposta": "Não achei o PrimoCode instalado. Rode `primocode voz instalar-app` no terminal."})
175
+ return
176
+
177
+ def trabalho():
178
+ log("login: abrindo o navegador")
179
+ r = subprocess.run([self.node, "-e", "require('./lib/conta.js').entrarPeloNavegador({aviso:()=>{}}).then(r=>process.exit(r&&r.ok?0:1))"],
180
+ cwd=str(raiz), env={**self.ambiente(), "PRIMOCODE_SEM_NAVEGADOR": "0"}, capture_output=True, timeout=600)
181
+ log(f"login: saiu com {r.returncode}")
182
+ self.painel({"login": logado()})
183
+ threading.Thread(target=trabalho, daemon=True).start()
184
+
185
+ def ambiente(self) -> dict:
186
+ env = dict(os.environ)
187
+ extra = [str(Path(self.node).parent)] if self.node else []
188
+ env["PATH"] = ":".join([x for x in extra + [self.cfg.get("path") or "", PATH_BASE, env.get("PATH", "")] if x])
189
+ env["HOME"] = str(HOME)
190
+ env["PRIMOCODE_SEM_NAVEGADOR"] = "1" # o cérebro nunca abre o navegador sozinho
191
+ return env
192
+
193
+ # ── motor de voz ──
194
+ def motor_envia(self, obj: dict) -> None:
195
+ try:
196
+ self.motor.stdin.write(json.dumps(obj, ensure_ascii=False) + "\n")
197
+ self.motor.stdin.flush()
198
+ except Exception:
199
+ pass
200
+
201
+ def subir_motor(self) -> None:
202
+ env = self.ambiente()
203
+ cfg_srv = self.cfg.get("servidor") or ""
204
+ conta = _json(PASTA_PC / "conta.json")
205
+ env.update({"PRIMOCODE_HUD_PONTE": "1", "PRIMOCODE_SERVER": cfg_srv or "https://primocode.web.app",
206
+ "PRIMOCODE_TOKEN": str(conta.get("idToken") or conta.get("token") or ""),
207
+ "PRIMOCODE_VOZ": str(self.cfg.get("voz") or "antonio"), "PRIMOCODE_CWD": str(self.pasta),
208
+ "PYTHONUNBUFFERED": "1", "PRIMOCODE_LAT": "1", "PRIMOCODE_SEM_PARCIAL": "1", **({"PRIMOCODE_TESTE_WAV": str(self.cfg["teste_wav"]), "PRIMOCODE_TESTE_ATRASO": str(self.cfg.get("teste_atraso", 12))} if self.cfg.get("teste_wav") else {}), "PYTHONPATH": os.environ.get("PYTHONPATH", str(AQUI.parent))})
209
+ log(f"motor de voz: subindo ({sys.executable} -m primo_voz) pasta={self.pasta}")
210
+ self.motor = subprocess.Popen([sys.executable, "-m", "primo_voz"], cwd=str(AQUI.parent), env=env, text=True,
211
+ stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
212
+ threading.Thread(target=self.ler_motor, daemon=True).start()
213
+ threading.Thread(target=lambda: [log("motor: " + l) for l in self.motor.stderr], daemon=True).start()
214
+
215
+ def ler_motor(self) -> None:
216
+ for linha in self.motor.stdout:
217
+ try:
218
+ m = json.loads(linha)
219
+ except ValueError:
220
+ continue
221
+ t = m.get("tipo")
222
+ if t == "hud":
223
+ self.painel(m.get("msg") or {})
224
+ elif t == "pronto":
225
+ log("motor de voz: PRONTO (microfone aberto)")
226
+ self.painel({"pasta": "~/Primo", "login": logado()})
227
+ elif t == "erro":
228
+ log("motor de voz: erro: " + str(m.get("texto")))
229
+ self.painel({"resposta": str(m.get("texto"))})
230
+ elif t == "parar":
231
+ self.matar_cerebro()
232
+ elif t == "ouvi" and str(m.get("texto") or "").strip():
233
+ threading.Thread(target=self.pensar, args=(str(m["texto"]).strip(),), daemon=True).start()
234
+ elif t == "fim":
235
+ break
236
+ log("motor de voz: saiu")
237
+ if self.vivo:
238
+ self.vivo = False
239
+ os._exit(0)
240
+
241
+ # ── cérebro ──
242
+ def matar_cerebro(self) -> None:
243
+ p = self.cerebro
244
+ if p and p.poll() is None:
245
+ p.terminate()
246
+
247
+ def dizer(self, texto: str) -> None:
248
+ self.motor_envia({"tipo": "falar", "texto": texto})
249
+ self.motor_envia({"tipo": "estado", "estado": "esperando"})
250
+
251
+ def responder_local(self, texto: str) -> "str | None":
252
+ """Pergunta simples respondida aqui, sem passar pelo agente (hora, data, clima, música, agenda, volume)."""
253
+ from primo_voz import painel as pn, tela as td
254
+ t = _sem_acento(texto).strip(" .?!")
255
+ agora = time.time()
256
+ if re.search(r"\bque horas\b|\bhoras sao\b|\bme (?:da|diz|diga) a hora\b|\bque hora (?:e|eh)\b", t):
257
+ return _hora_falada(time.localtime(agora))
258
+ if re.search(r"\bque dia (?:e|eh)\b|\bdata de hoje\b|\bhoje e que dia\b", t):
259
+ dias = ["segunda-feira", "terça-feira", "quarta-feira", "quinta-feira", "sexta-feira", "sábado", "domingo"]
260
+ meses = ["janeiro", "fevereiro", "março", "abril", "maio", "junho", "julho", "agosto", "setembro", "outubro", "novembro", "dezembro"]
261
+ lt = time.localtime(agora)
262
+ return f"Hoje é {dias[lt.tm_wday]}, {lt.tm_mday} de {meses[lt.tm_mon - 1]}."
263
+ if re.search(r"\bclima\b|\bvai chover\b|\btemperatura\b|\btempo (?:hoje|agora|la fora)\b", t):
264
+ c = td.clima_widget(self.cache_clima)
265
+ if c and c.get("temp") is not None:
266
+ return f"Em {c.get('cidade', 'sua cidade')} faz {c['temp']} graus, {str(c.get('cond', '')).lower()}. Máxima de {c['max']}, mínima de {c['min']}."
267
+ return None
268
+ if re.search(r"o que (?:esta|ta) tocando|que musica (?:e|eh) essa|qual (?:e )?a musica", t):
269
+ m = td.musica()
270
+ return f"Tocando {m['faixa']}, de {m['artista']}." if m and m.get("faixa") else "Não tem nada tocando agora."
271
+ if re.search(r"\b(?:pausa|pausar|pare|para) (?:a )?musica\b|\b(?:toca|tocar|continua|retoma) (?:a )?musica\b|\bpula (?:a )?musica\b|\bproxima musica\b|\bmusica anterior\b", t):
272
+ acao = "proxima" if re.search(r"pula|proxima", t) else "anterior" if "anterior" in t else "tocar"
273
+ pn.controle_midia(acao)
274
+ return {"proxima": "Pulei.", "anterior": "Voltei.", "tocar": "Feito."}[acao]
275
+ if re.search(r"\b(?:aumenta|sobe|aumentar|mais)\b.*\bvolume\b|\bvolume\b.*\b(?:mais alto|maior)\b", t):
276
+ pn.executar_acao({"acao": "volume", "arg": "mais"})
277
+ return "Aumentei."
278
+ if re.search(r"\b(?:abaixa|diminui|baixa|diminuir|menos)\b.*\bvolume\b|\bvolume\b.*\b(?:mais baixo|menor)\b", t):
279
+ pn.executar_acao({"acao": "volume", "arg": "menos"})
280
+ return "Abaixei."
281
+ if re.search(r"\bagenda\b|\bcompromissos?\b|o que (?:eu )?tenho hoje|\breunioes? hoje\b", t):
282
+ ev = td.eventos_do_mes()
283
+ if ev.get("permissao") != "ok":
284
+ return "Preciso do acesso ao Calendário. Clique em Liberar o Calendário no painel."
285
+ hj = td.hoje_lista(ev["eventos"], agora)
286
+ if not hj:
287
+ return "Você não tem mais nada marcado para hoje."
288
+ return "Hoje: " + "; ".join(f"{e['titulo']} às {e['hora']}" if e["hora"] != "dia todo" else f"{e['titulo']}, o dia todo" for e in hj[:4]) + "."
289
+ return None
290
+
291
+ def pensar(self, texto: str) -> None:
292
+ log(f"ouvi: {texto}")
293
+ m = PEDIDO_PASTA.match(texto.strip())
294
+ if m:
295
+ d = achar_projeto(m.group(1))
296
+ if d:
297
+ self.pasta, self.pasta_modo = d, "/dir"
298
+ gravar_pasta(d)
299
+ self.painel({"pasta": d.name})
300
+ log(f"pasta: {d}")
301
+ self.dizer(f"Fechado. Agora trabalho no {d.name}.")
302
+ else:
303
+ self.dizer(f"Não achei um projeto chamado {m.group(1)}.")
304
+ return
305
+ t_ini = time.time()
306
+ try:
307
+ local = self.responder_local(texto)
308
+ except Exception as e:
309
+ log(f"resposta local falhou: {e}")
310
+ local = None
311
+ if local:
312
+ log(f"lat: resposta LOCAL em {time.time() - t_ini:.2f}s: {local!r}")
313
+ self.dizer(local)
314
+ return
315
+ if not (self.node and self.primocode and Path(self.primocode).exists()):
316
+ log("cérebro: node/primocode não registrados em primo-app.json")
317
+ self.dizer("Não achei o PrimoCode instalado. Rode primocode voz instalar-app no terminal.")
318
+ return
319
+ if not logado():
320
+ self.painel({"login": False})
321
+ self.dizer("Preciso que você entre na conta do PrimoCode. Clique em Entrar.")
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.)")
335
+ with self.trava:
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)}")
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()
355
+ try:
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
359
+ out, err = self.cerebro.communicate(timeout=900)
360
+ log(f"cérebro: saiu com código {self.cerebro.returncode}")
361
+ timer.cancel()
362
+ except Exception as e:
363
+ timer.cancel()
364
+ log(f"cérebro: falhou: {e}")
365
+ self.dizer("O cérebro não respondeu.")
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}")
375
+ self.turnos += 1
376
+ resp = ""
377
+ for l in reversed((out or "").strip().splitlines()):
378
+ try:
379
+ j = json.loads(l)
380
+ resp = str(j.get("result") or "")
381
+ break
382
+ except ValueError:
383
+ continue
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:])
387
+ # tira os enfeites do terminal (⎿ linhas de status, ● marcador) — a voz fala só o texto
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()
389
+ log(f"lat: cérebro (agente) {time.time() - t0:.1f}s, {len(resp)} caracteres: {resp[:160]!r}")
390
+ if not resp.strip():
391
+ self.dizer("Não consegui uma resposta agora.")
392
+ return
393
+ falar, painel = self.separar(resp)
394
+ self.motor_envia({"tipo": "falar", "texto": falar, "painel": painel})
395
+ self.motor_envia({"tipo": "estado", "estado": "esperando"})
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
+
415
+ def sair(self) -> None:
416
+ self.vivo = False
417
+ self.matar_cerebro()
418
+ if self.motor and self.motor.poll() is None:
419
+ self.motor_envia({"tipo": "fim"})
420
+ try:
421
+ self.motor.wait(timeout=2)
422
+ except Exception:
423
+ self.motor.kill()
424
+ log("app: encerrado")
425
+
426
+ def iniciar(self, js, p) -> None:
427
+ self.js = js
428
+ threading.Timer(0.5, self.subir_motor).start()
429
+ threading.Timer(2.0, lambda: self.painel({"pasta": self.rotulo(), "login": logado()})).start()
430
+
431
+
432
+ def main() -> int:
433
+ import fcntl
434
+ import atexit
435
+ PASTA_PC.mkdir(exist_ok=True)
436
+ os.environ["PATH"] = PATH_BASE + ":" + os.environ.get("PATH", "")
437
+ lock = open(PASTA_PC / "primo-app.lock", "w")
438
+ try:
439
+ fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
440
+ except OSError:
441
+ return 0 # já aberto: o macOS só traz a janela de volta
442
+ sys.stderr = open(LOG, "a", buffering=1) if (LOG.parent.mkdir(parents=True, exist_ok=True) or True) else sys.stderr
443
+ log("app: abrindo")
444
+ app = App()
445
+ atexit.register(app.sair)
446
+ from primo_voz import hud
447
+ return hud.main(entrada=iter(app), saida=app.saida_do_painel, ao_iniciar=app.iniciar, ao_sair=app.sair)
448
+
449
+
450
+ if __name__ == "__main__":
451
+ sys.exit(main())
@@ -0,0 +1,125 @@
1
+ """appmac.py — o Primo como app de verdade no macOS (Dock e Cmd+Tab dizem "Primo", com o ícone).
2
+
3
+ Gera sob demanda ~/.primocode/Primo.app (bundle leve: Info.plist, ícone .icns e um script que
4
+ roda o python do venv com `-m primo_voz.hud`). Quem abre o painel executa o binário do bundle
5
+ direto (spawn), então o canal stdin/stdout continua o mesmo.
6
+
7
+ python -m primo_voz.appmac # gera (se precisar) e imprime o caminho do executável
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import plistlib
13
+ import shutil
14
+ import subprocess
15
+ import sys
16
+ import tempfile
17
+ from pathlib import Path
18
+
19
+ AQUI = Path(__file__).resolve().parent
20
+ ICONE = AQUI / "vendor" / "primo-icone.png"
21
+ VERSAO = "7" # sobe quando o bundle muda: força regerar
22
+
23
+
24
+ def pasta_app() -> Path:
25
+ return Path(os.environ.get("PRIMOCODE_APP", Path.home() / "Applications" / "Primo.app"))
26
+
27
+
28
+ def _icns(destino: Path) -> bool:
29
+ with tempfile.TemporaryDirectory() as t:
30
+ iset = Path(t) / "Primo.iconset"
31
+ iset.mkdir()
32
+ for base in (16, 32, 128, 256, 512):
33
+ for k in (1, 2):
34
+ px = base * k
35
+ nome = f"icon_{base}x{base}{'@2x' if k == 2 else ''}.png"
36
+ subprocess.run(["sips", "-z", str(px), str(px), str(ICONE), "--out", str(iset / nome)],
37
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=True)
38
+ r = subprocess.run(["iconutil", "-c", "icns", str(iset), "-o", str(destino)],
39
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
40
+ return r.returncode == 0
41
+
42
+
43
+ def _no_dock(app: Path) -> None:
44
+ """Fixa o Primo no Dock (só com --dock)."""
45
+ item = ("<dict><key>tile-data</key><dict><key>file-data</key><dict><key>_CFURLString</key><string>%s</string>"
46
+ "<key>_CFURLStringType</key><integer>0</integer></dict></dict></dict>" % str(app))
47
+ subprocess.run(["defaults", "write", "com.apple.dock", "persistent-apps", "-array-add", item], check=False)
48
+ subprocess.run(["killall", "Dock"], check=False)
49
+
50
+
51
+ def pythonpath() -> str:
52
+ """O PYTHONPATH que o binário do bundle precisa (o primo_voz e os pacotes do venv, já que ele não é o python do venv)."""
53
+ import sysconfig
54
+ dirs = [str(AQUI.parent), sysconfig.get_path("purelib"), sysconfig.get_path("platlib")]
55
+ return os.pathsep.join(dict.fromkeys(d for d in dirs if d))
56
+
57
+
58
+ def preparar() -> str | None:
59
+ """Devolve o caminho do executável do Primo.app (criando o bundle se faltar); None se não deu."""
60
+ if sys.platform != "darwin" or not ICONE.exists():
61
+ return None
62
+ try:
63
+ app = pasta_app()
64
+ exe = app / "Contents" / "MacOS" / "Primo" # o binário de verdade (o processo se chama Primo); o lançador só dá exec nele
65
+ lanc = app / "Contents" / "MacOS" / "Primo-iniciar"
66
+ marca = app / "Contents" / ".versao"
67
+ py = sys.executable
68
+ chave = f"{VERSAO}|{py}|{AQUI.parent}"
69
+ if exe.exists() and marca.exists() and marca.read_text() == chave and (app / "Contents/Resources/Primo.icns").exists():
70
+ return str(exe)
71
+ if app.exists():
72
+ shutil.rmtree(app)
73
+ antigo = Path.home() / ".primocode" / "Primo.app" # onde ficava até a 10.15.1
74
+ if antigo.exists():
75
+ shutil.rmtree(antigo, ignore_errors=True)
76
+ app.parent.mkdir(parents=True, exist_ok=True)
77
+ (app / "Contents" / "MacOS").mkdir(parents=True)
78
+ (app / "Contents" / "Resources").mkdir()
79
+ if not _icns(app / "Contents" / "Resources" / "Primo.icns"):
80
+ return None
81
+ info = {
82
+ "CFBundleName": "Primo", "CFBundleDisplayName": "Primo", "CFBundleIdentifier": "ai.primocode.primo",
83
+ "CFBundleExecutable": "Primo-iniciar", "CFBundleIconFile": "Primo", "CFBundlePackageType": "APPL",
84
+ "CFBundleShortVersionString": "1.0", "CFBundleVersion": "1", "LSMinimumSystemVersion": "11.0",
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.",
87
+ "NSMicrophoneUsageDescription": "O Primo ouve você pelo microfone para atender os seus pedidos por voz.",
88
+ "NSCalendarsFullAccessUsageDescription": "O Primo mostra os compromissos de hoje no painel.",
89
+ "NSCalendarsUsageDescription": "O Primo mostra os compromissos de hoje no painel.",
90
+ "NSAppleEventsUsageDescription": "O Primo controla a música (Spotify/Music) pelo painel.",
91
+ }
92
+ with open(app / "Contents" / "Info.plist", "wb") as f:
93
+ plistlib.dump(info, f)
94
+ # binário de verdade (cópia do Python.app do framework): o macOS passa a chamar o processo de "Primo"
95
+ real = Path(os.path.realpath(py))
96
+ mach = next((c for c in (real.parents[1] / "Resources/Python.app/Contents/MacOS/Python",
97
+ real.parent / "../Resources/Python.app/Contents/MacOS/Python") if c.exists()), None)
98
+ if mach:
99
+ shutil.copy2(mach, exe)
100
+ # duplo clique no Finder/Launchpad: sem argumentos = sobe o app completo (voz + painel + cérebro)
101
+ lanc.write_text(f'#!/bin/bash\nexport PYTHONPATH="{pythonpath()}"\nD="$(cd "$(dirname "$0")" && pwd)"\n'
102
+ f'[ $# -eq 0 ] && set -- -m primo_voz.app\nexec "$D/Primo" "$@"\n')
103
+ lanc.chmod(0o755)
104
+ subprocess.run(["codesign", "--force", "--sign", "-", str(app)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
105
+ else:
106
+ exe.write_text(f'#!/bin/bash\nexport PYTHONPATH="{pythonpath()}"\n[ $# -eq 0 ] && set -- -m primo_voz.app\nexec "{py}" "$@"\n')
107
+ exe.chmod(0o755)
108
+ shutil.copy2(exe, lanc)
109
+ marca.write_text(chave)
110
+ if os.environ.get("PRIMOCODE_APP_DOCK") == "1":
111
+ _no_dock(app)
112
+ subprocess.run(["/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
113
+ "-f", str(app)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, timeout=10)
114
+ return str(exe)
115
+ except Exception as e:
116
+ sys.stderr.write(f"appmac: {e}\n")
117
+ return None
118
+
119
+
120
+ if __name__ == "__main__":
121
+ r = preparar()
122
+ if r:
123
+ print(r)
124
+ print(pythonpath())
125
+ sys.exit(0 if r else 1)
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="-1 -4 18 18" shape-rendering="crispEdges"><rect x="2" y="0" width="1" height="1" fill="#22d3ee"/><rect x="2" y="1" width="1" height="1" fill="#22d3ee"/><rect x="3" y="0" width="1" height="1" fill="#22d3ee"/><rect x="3" y="1" width="1" height="1" fill="#22d3ee"/><rect x="4" y="0" width="1" height="1" fill="#22d3ee"/><rect x="4" y="1" width="1" height="1" fill="#22d3ee"/><rect x="5" y="0" width="1" height="1" fill="#22d3ee"/><rect x="5" y="1" width="1" height="1" fill="#22d3ee"/><rect x="6" y="0" width="1" height="1" fill="#22d3ee"/><rect x="6" y="1" width="1" height="1" fill="#22d3ee"/><rect x="7" y="0" width="1" height="1" fill="#22d3ee"/><rect x="7" y="1" width="1" height="1" fill="#22d3ee"/><rect x="8" y="0" width="1" height="1" fill="#22d3ee"/><rect x="8" y="1" width="1" height="1" fill="#22d3ee"/><rect x="9" y="0" width="1" height="1" fill="#22d3ee"/><rect x="9" y="1" width="1" height="1" fill="#22d3ee"/><rect x="10" y="0" width="1" height="1" fill="#22d3ee"/><rect x="10" y="1" width="1" height="1" fill="#22d3ee"/><rect x="11" y="0" width="1" height="1" fill="#22d3ee"/><rect x="11" y="1" width="1" height="1" fill="#22d3ee"/><rect x="12" y="0" width="1" height="1" fill="#22d3ee"/><rect x="12" y="1" width="1" height="1" fill="#22d3ee"/><rect x="13" y="0" width="1" height="1" fill="#22d3ee"/><rect x="13" y="1" width="1" height="1" fill="#22d3ee"/><rect x="2" y="2" width="1" height="1" fill="#22d3ee"/><rect x="2" y="3" width="1" height="1" fill="#22d3ee"/><rect x="3" y="2" width="1" height="1" fill="#22d3ee"/><rect x="3" y="3" width="1" height="1" fill="#22d3ee"/><rect x="5" y="2" width="1" height="1" fill="#22d3ee"/><rect x="5" y="3" width="1" height="1" fill="#22d3ee"/><rect x="6" y="2" width="1" height="1" fill="#22d3ee"/><rect x="6" y="3" width="1" height="1" fill="#22d3ee"/><rect x="7" y="2" width="1" height="1" fill="#22d3ee"/><rect x="7" y="3" width="1" height="1" fill="#22d3ee"/><rect x="8" y="2" width="1" height="1" fill="#22d3ee"/><rect x="8" y="3" width="1" height="1" fill="#22d3ee"/><rect x="9" y="2" width="1" height="1" fill="#22d3ee"/><rect x="9" y="3" width="1" height="1" fill="#22d3ee"/><rect x="10" y="2" width="1" height="1" fill="#22d3ee"/><rect x="10" y="3" width="1" height="1" fill="#22d3ee"/><rect x="12" y="2" width="1" height="1" fill="#22d3ee"/><rect x="12" y="3" width="1" height="1" fill="#22d3ee"/><rect x="13" y="2" width="1" height="1" fill="#22d3ee"/><rect x="13" y="3" width="1" height="1" fill="#22d3ee"/><rect x="0" y="4" width="1" height="1" fill="#22d3ee"/><rect x="0" y="5" width="1" height="1" fill="#22d3ee"/><rect x="1" y="4" width="1" height="1" fill="#22d3ee"/><rect x="1" y="5" width="1" height="1" fill="#22d3ee"/><rect x="2" y="4" width="1" height="1" fill="#22d3ee"/><rect x="2" y="5" width="1" height="1" fill="#22d3ee"/><rect x="3" y="4" width="1" height="1" fill="#22d3ee"/><rect x="3" y="5" width="1" height="1" fill="#22d3ee"/><rect x="4" y="4" width="1" height="1" fill="#22d3ee"/><rect x="4" y="5" width="1" height="1" fill="#22d3ee"/><rect x="5" y="4" width="1" height="1" fill="#22d3ee"/><rect x="5" y="5" width="1" height="1" fill="#22d3ee"/><rect x="6" y="4" width="1" height="1" fill="#22d3ee"/><rect x="6" y="5" width="1" height="1" fill="#22d3ee"/><rect x="7" y="4" width="1" height="1" fill="#22d3ee"/><rect x="7" y="5" width="1" height="1" fill="#22d3ee"/><rect x="8" y="4" width="1" height="1" fill="#22d3ee"/><rect x="8" y="5" width="1" height="1" fill="#22d3ee"/><rect x="9" y="4" width="1" height="1" fill="#22d3ee"/><rect x="9" y="5" width="1" height="1" fill="#22d3ee"/><rect x="10" y="4" width="1" height="1" fill="#22d3ee"/><rect x="10" y="5" width="1" height="1" fill="#22d3ee"/><rect x="11" y="4" width="1" height="1" fill="#22d3ee"/><rect x="11" y="5" width="1" height="1" fill="#22d3ee"/><rect x="12" y="4" width="1" height="1" fill="#22d3ee"/><rect x="12" y="5" width="1" height="1" fill="#22d3ee"/><rect x="13" y="4" width="1" height="1" fill="#22d3ee"/><rect x="13" y="5" width="1" height="1" fill="#22d3ee"/><rect x="14" y="4" width="1" height="1" fill="#22d3ee"/><rect x="14" y="5" width="1" height="1" fill="#22d3ee"/><rect x="15" y="4" width="1" height="1" fill="#22d3ee"/><rect x="15" y="5" width="1" height="1" fill="#22d3ee"/><rect x="2" y="6" width="1" height="1" fill="#22d3ee"/><rect x="2" y="7" width="1" height="1" fill="#22d3ee"/><rect x="3" y="6" width="1" height="1" fill="#22d3ee"/><rect x="3" y="7" width="1" height="1" fill="#22d3ee"/><rect x="4" y="6" width="1" height="1" fill="#22d3ee"/><rect x="4" y="7" width="1" height="1" fill="#22d3ee"/><rect x="5" y="6" width="1" height="1" fill="#22d3ee"/><rect x="5" y="7" width="1" height="1" fill="#22d3ee"/><rect x="6" y="6" width="1" height="1" fill="#22d3ee"/><rect x="6" y="7" width="1" height="1" fill="#22d3ee"/><rect x="7" y="6" width="1" height="1" fill="#22d3ee"/><rect x="7" y="7" width="1" height="1" fill="#22d3ee"/><rect x="8" y="6" width="1" height="1" fill="#22d3ee"/><rect x="8" y="7" width="1" height="1" fill="#22d3ee"/><rect x="9" y="6" width="1" height="1" fill="#22d3ee"/><rect x="9" y="7" width="1" height="1" fill="#22d3ee"/><rect x="10" y="6" width="1" height="1" fill="#22d3ee"/><rect x="10" y="7" width="1" height="1" fill="#22d3ee"/><rect x="11" y="6" width="1" height="1" fill="#22d3ee"/><rect x="11" y="7" width="1" height="1" fill="#22d3ee"/><rect x="12" y="6" width="1" height="1" fill="#22d3ee"/><rect x="12" y="7" width="1" height="1" fill="#22d3ee"/><rect x="13" y="6" width="1" height="1" fill="#22d3ee"/><rect x="13" y="7" width="1" height="1" fill="#22d3ee"/><rect x="3" y="8" width="1" height="1" fill="#22d3ee"/><rect x="3" y="9" width="1" height="1" fill="#22d3ee"/><rect x="5" y="8" width="1" height="1" fill="#22d3ee"/><rect x="5" y="9" width="1" height="1" fill="#22d3ee"/><rect x="10" y="8" width="1" height="1" fill="#22d3ee"/><rect x="10" y="9" width="1" height="1" fill="#22d3ee"/><rect x="12" y="8" width="1" height="1" fill="#22d3ee"/><rect x="12" y="9" width="1" height="1" fill="#22d3ee"/></svg>
@@ -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 = 2, teto: int = 260) -> Tuple[str, str]:
140
- """(fala, painel). A voz resume; o texto inteiro (código, listas) vai ao painel."""
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
- limpo = limpar_para_voz(painel)
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
- 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."
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