primocode 10.15.2 → 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.
@@ -147,6 +147,8 @@ class App:
147
147
  self.vivo = True
148
148
  self.trava = threading.Lock()
149
149
  self.cache_clima: dict = {}
150
+ self.ao_ouvir = lambda: None
151
+ self.desligando = False # a voz foi fechada de propósito: o motor sair não derruba o app
150
152
 
151
153
  # ── entrada do painel (iterável de linhas JSON) ──
152
154
  def __iter__(self):
@@ -182,6 +184,13 @@ class App:
182
184
  self.painel({"login": logado()})
183
185
  threading.Thread(target=trabalho, daemon=True).start()
184
186
 
187
+ def entrar_e_esperar(self, limite: float = 600) -> None:
188
+ """O chat espera o login terminar para mostrar a conta e as conversas."""
189
+ self.entrar()
190
+ fim = time.time() + limite
191
+ while time.time() < fim and not logado():
192
+ time.sleep(1)
193
+
185
194
  def ambiente(self) -> dict:
186
195
  env = dict(os.environ)
187
196
  extra = [str(Path(self.node).parent)] if self.node else []
@@ -230,10 +239,14 @@ class App:
230
239
  elif t == "parar":
231
240
  self.matar_cerebro()
232
241
  elif t == "ouvi" and str(m.get("texto") or "").strip():
242
+ self.ao_ouvir() # pedido falado: a tela de voz vem pra frente
233
243
  threading.Thread(target=self.pensar, args=(str(m["texto"]).strip(),), daemon=True).start()
234
244
  elif t == "fim":
235
245
  break
236
246
  log("motor de voz: saiu")
247
+ if self.desligando:
248
+ self.desligando = False
249
+ return
237
250
  if self.vivo:
238
251
  self.vivo = False
239
252
  os._exit(0)
@@ -423,12 +436,456 @@ class App:
423
436
  self.motor.kill()
424
437
  log("app: encerrado")
425
438
 
426
- def iniciar(self, js, p) -> None:
439
+ # ── a voz só liga pelo botão: com o chat na frente, o microfone fica fechado ──
440
+ def ligar_voz(self) -> None:
441
+ with self.trava:
442
+ if self.motor and self.motor.poll() is None:
443
+ return
444
+ self.desligando = False
445
+ self.subir_motor()
446
+
447
+ def desligar_voz(self) -> None:
448
+ with self.trava:
449
+ if not self.motor or self.motor.poll() is not None:
450
+ return
451
+ self.desligando = True
452
+ m, self.motor = self.motor, None
453
+ log("motor de voz: desligando (voltou pro chat)")
454
+ try:
455
+ m.stdin.write(json.dumps({"tipo": "fim"}) + "\n")
456
+ m.stdin.flush()
457
+ m.wait(timeout=2)
458
+ except Exception:
459
+ m.kill()
460
+
461
+ def iniciar(self, js, p, com_chat: bool = True) -> None:
427
462
  self.js = js
428
- threading.Timer(0.5, self.subir_motor).start()
463
+ if not com_chat: # só o modo voz puro (sem chat) abre ouvindo
464
+ threading.Timer(0.5, self.subir_motor).start()
429
465
  threading.Timer(2.0, lambda: self.painel({"pasta": self.rotulo(), "login": logado()})).start()
430
466
 
431
467
 
468
+ # ── o que aparece embaixo de uma mensagem: os arquivos citados, mostrados ali mesmo ──
469
+ TIPOS = {"video": {".mp4", ".mov", ".webm", ".m4v"}, "imagem": {".png", ".jpg", ".jpeg", ".gif", ".webp", ".svg", ".avif"},
470
+ "audio": {".mp3", ".wav", ".m4a", ".aac", ".ogg", ".flac"}, "pdf": {".pdf"}, "html": {".html", ".htm"},
471
+ "codigo": {".js", ".jsx", ".ts", ".tsx", ".py", ".css", ".json", ".md", ".txt", ".sh", ".yml", ".yaml", ".toml",
472
+ ".rs", ".go", ".java", ".kt", ".swift", ".rb", ".php", ".sql", ".csv", ".xml", ".env.example"}}
473
+ CAMINHO_ABS = re.compile(r"(?<![\w/])(?:~|/(?:Users|Volumes|tmp|private|opt|var|Applications))(?:/[\w.@+\-()À-ÿ]+)+/?")
474
+ CAMINHO_REL = re.compile(r"(?<![\w/~.])(?:[\w.@+\-À-ÿ]+/)+[\w.@+\-À-ÿ]+\.\w{1,5}\b")
475
+ CRASE = re.compile(r"`([^`\n]{2,300})`")
476
+
477
+
478
+ def tipo_de(c: Path) -> str:
479
+ if c.is_dir():
480
+ return "pasta"
481
+ ext = c.suffix.lower()
482
+ return next((t for t, exts in TIPOS.items() if ext in exts), "arquivo")
483
+
484
+
485
+ def midias_do_texto(texto: str, pasta: Path, limite: int = 8) -> "list[dict]":
486
+ """Os arquivos e pastas que a mensagem cita e que EXISTEM: caminho absoluto, ~/..., ou relativo à pasta do
487
+ projeto (entre crases ou com barra). `achado` é o texto como apareceu, para a tela virar ele num botão."""
488
+ vistos, fora = set(), []
489
+ candidatos = [m.group(1).strip() for m in CRASE.finditer(texto or "")]
490
+ candidatos += [m.group(0) for m in CAMINHO_ABS.finditer(texto or "")] + [m.group(0) for m in CAMINHO_REL.finditer(texto or "")]
491
+ for achado in candidatos:
492
+ bruto = achado.rstrip(".,;:)!?").strip()
493
+ if not bruto or "\n" in bruto or bruto.startswith(("http://", "https://")):
494
+ continue
495
+ c = Path(os.path.expanduser(bruto))
496
+ if not c.is_absolute():
497
+ if "/" not in bruto and "." not in bruto:
498
+ continue
499
+ c = pasta / c
500
+ try:
501
+ c = c.resolve()
502
+ existe = c.exists()
503
+ except (OSError, RuntimeError):
504
+ continue
505
+ if not existe or str(c) in vistos or c == HOME or c == Path("/"):
506
+ continue
507
+ vistos.add(str(c))
508
+ tipo = tipo_de(c)
509
+ item = {"achado": bruto, "caminho": str(c), "nome": c.name, "tipo": tipo, "url": c.as_uri(),
510
+ "tamanho": c.stat().st_size if c.is_file() else 0}
511
+ if tipo == "codigo" and item["tamanho"] <= 200_000:
512
+ try:
513
+ item["trecho"] = "\n".join(c.read_text("utf-8", errors="replace").splitlines()[:40])[:4000]
514
+ except OSError:
515
+ pass
516
+ fora.append(item)
517
+ if len(fora) >= limite:
518
+ break
519
+ return fora
520
+
521
+
522
+ # Os comandos de / no app. Os do terminal que viram ação da própria janela, e os que não se aplicam a ela.
523
+ COMANDOS_DO_APP = {"/clear": "nova", "/chats": "buscar", "/login": "entrar", "/logout": "sair_conta", "/account": "conta",
524
+ "/mode": "modo", "/api": "modelos", "/config": "ajustes", "/skills": "skills", "/marketplace": "skills",
525
+ "/attach": "anexar", "/detach": "desanexar", "/copiar": "copiar", "/voice": "voz", "/voice nome": "voz_nome",
526
+ "/dir": "pasta", "/help": "ajuda", "/effort": "effort", "/permissoes": "permissoes"}
527
+ # "perguntar" precisa de alguém no terminal para responder: no app (sem terminal) não se aplica.
528
+ PERMISSOES_APP = [("auto", "roda tudo, mas pergunta antes do que não tem volta"), ("livre", "faz tudo sem perguntar"),
529
+ ("plano", "só lê e planeja, não muda nada")]
530
+ COMANDOS_FORA = {"/cls", "/exit", "/uninstall", "/atalhos", "/mascot", "/offline", "/files", "/voice painel", "/voice parar", "!comando"}
531
+
532
+
533
+ def comandos_para_o_app(lista: list) -> list:
534
+ """A lista do terminal (`menu-comandos`) com o tipo de cada um: app (a janela faz), terminal (roda e mostra a
535
+ saída) ou pedido (comando da pessoa: vira um pedido ao agente)."""
536
+ fora = []
537
+ for c in lista:
538
+ nome = str(c.get("nome") or "")
539
+ if not nome or nome in COMANDOS_FORA:
540
+ continue
541
+ item = {"nome": nome, "descricao": str(c.get("descricao") or "")}
542
+ if nome in COMANDOS_DO_APP:
543
+ item.update(tipo="app", acao=COMANDOS_DO_APP[nome])
544
+ else:
545
+ item["tipo"] = "terminal" if c.get("embutido") else "pedido"
546
+ fora.append(item)
547
+ return fora
548
+
549
+
550
+ class Chat:
551
+ """A tela do chat (chat.html): manda e recebe pela ponte `chat` do hud.py. O cérebro é o mesmo da voz
552
+ (`primocode -p ... --output-format json`), com `--resume <id>` para seguir a conversa da nuvem."""
553
+
554
+ MAX_ANEXO = 25 * 1024 * 1024
555
+
556
+ def __init__(self, app: App) -> None:
557
+ self.app = app
558
+ self.js = lambda m: None
559
+ self.mostrar_voz = lambda: None
560
+ self.parado = False
561
+ app.ao_ouvir = lambda: self.mostrar_voz()
562
+
563
+ def raiz(self) -> "Path | None":
564
+ r = Path(self.app.primocode).resolve().parent.parent if self.app.primocode else None
565
+ return r if r and (r / "lib").is_dir() else None
566
+
567
+ def node(self, codigo: str, timeout: int = 60) -> dict:
568
+ """Roda um pedaço de JS na raiz do PrimoCode e lê a última linha de JSON que ele imprimir."""
569
+ r = self.raiz()
570
+ if not (self.app.node and r):
571
+ return {"ok": False, "erro": "Não achei o PrimoCode instalado. Rode primocode voz instalar-app no terminal."}
572
+ try:
573
+ p = subprocess.run([self.app.node, "-e", codigo], cwd=str(r), env=self.app.ambiente(), capture_output=True, text=True, timeout=timeout)
574
+ except Exception as e:
575
+ return {"ok": False, "erro": str(e)}
576
+ for l in reversed(p.stdout.strip().splitlines()):
577
+ try:
578
+ return json.loads(l)
579
+ except ValueError:
580
+ continue
581
+ log("chat node: " + (p.stderr or "")[-300:])
582
+ return {"ok": False, "erro": "O PrimoCode não respondeu."}
583
+
584
+ def cli(self, *args: str, timeout: int = 60) -> dict:
585
+ if not (self.app.node and self.app.primocode):
586
+ return {"ok": False, "erro": "Não achei o PrimoCode instalado."}
587
+ try:
588
+ p = subprocess.run([self.app.node, self.app.primocode, *args], cwd=str(self.app.pasta), env=self.app.ambiente(),
589
+ capture_output=True, text=True, timeout=timeout)
590
+ except Exception as e:
591
+ return {"ok": False, "erro": str(e)}
592
+ for l in reversed(p.stdout.strip().splitlines()):
593
+ try:
594
+ return json.loads(l)
595
+ except ValueError:
596
+ continue
597
+ return {"ok": False, "erro": "O PrimoCode não respondeu."}
598
+
599
+ # ── o que a tela mostra ──
600
+ def conta(self) -> dict:
601
+ c = _json(PASTA_PC / "conta.json")
602
+ ate = c.get("planoAte")
603
+ if isinstance(ate, (int, float)):
604
+ ate = time.strftime("%d/%m/%Y", time.localtime(ate / 1000 if ate > 1e11 else ate))
605
+ return {"logado": logado(), "email": c.get("email") or "", "plano": c.get("plano") or "", "planoAte": ate or ""}
606
+
607
+ def modo(self) -> str:
608
+ return "chat" if _json(PASTA_PC / "config.json").get("mode") == "chat" else "agent"
609
+
610
+ def ajustes(self) -> dict:
611
+ r = self.node("const P=require('./lib/local/provedor.js');const a=P.atual();"
612
+ "console.log(JSON.stringify({ok:true,atual:a?{id:a.id,nome:a.nome,cli:a.cli,chave:a.chave?P.mascarar(a.chave):''}:null,"
613
+ "lista:Object.entries(P.PROVEDORES).filter(([,p])=>!p.cli).map(([id,p])=>({id,nome:p.nome}))}))")
614
+ atual = r.get("atual") if r.get("ok") else None
615
+ voz = _json(PASTA_PC / "voz.json")
616
+ cfg = _json(PASTA_PC / "config.json")
617
+ ef = self.node("const E=require('./lib/effort.js');console.log(JSON.stringify({ok:true,niveis:E.NIVEIS.map(n=>({nome:n.nome,resumo:n.resumo}))}))")
618
+ perm = cfg.get("permissoes") if cfg.get("permissoes") in dict(PERMISSOES_APP) else "auto"
619
+ return {"modo": self.modo(),
620
+ "effort": {"atual": str(cfg.get("effort") or "medium"), "niveis": ef.get("niveis") or []},
621
+ "permissoes": {"atual": perm, "opcoes": [{"nome": n, "resumo": r} for n, r in PERMISSOES_APP]},
622
+ "provedor": {"id": atual["id"], "nome": atual["nome"], "chave_mascarada": atual.get("chave") or ""} if atual else {"id": "primo", "nome": "Primo", "chave_mascarada": ""},
623
+ "provedores_chave": r.get("lista") or [], "pasta": str(self.app.pasta), "voz": {"nome": voz.get("nome") or "Primo", "sons": voz.get("sons", True) is not False},
624
+ "conta": self.conta()}
625
+
626
+ def conversas(self) -> None:
627
+ r = self.cli("conversas", "--json", "--limite", "40")
628
+ self.js({"conversas": r.get("conversas") or []} if r.get("ok") else {"conversas_erro": r.get("erro") or "Não deu pra carregar as conversas."})
629
+
630
+ def skills(self) -> None:
631
+ r = self.node("const S=require('./lib/skills.js');const d=new Set((S.lerEstado().desligadas)||[]);"
632
+ "console.log(JSON.stringify({ok:true,skills:S.listar().map(s=>({nome:s.nome,descricao:s.descricao||'',ligada:!d.has(s.nome)}))}))")
633
+ self.js({"skills": r.get("skills") or []} if r.get("ok") else {"erro": r.get("erro")})
634
+
635
+ # ── a ponte ──
636
+ def receber(self, m: dict) -> None:
637
+ a = m.get("acao")
638
+ trab = None
639
+ if a == "pronto":
640
+ def trab():
641
+ self.js({"inicio": {**self.conta(), "pasta": str(self.app.pasta), "modo": self.modo()}})
642
+ r = self.cli("menu-comandos")
643
+ if r.get("ok"):
644
+ self.js({"comandos": comandos_para_o_app(r.get("comandos") or [])})
645
+ if logado():
646
+ self.conversas()
647
+ elif a == "enviar":
648
+ self.parado = False
649
+ trab = lambda: self.pensar(str(m.get("texto") or ""), m.get("conversa"), m.get("modo") or self.modo(), m.get("anexos") or [])
650
+ elif a == "comando" and str(m.get("texto") or "").startswith("/"):
651
+ self.parado = False
652
+ trab = lambda: self.pensar(str(m["texto"]), m.get("conversa"), self.modo(), [], comando=True)
653
+ elif a == "parar":
654
+ self.parado = True
655
+ self.app.matar_cerebro()
656
+ elif a == "conversas":
657
+ trab = self.conversas
658
+ elif a == "abrir" and m.get("id"):
659
+ def trab():
660
+ r = self.cli("conversa", str(m["id"]), "--json")
661
+ if not r.get("ok"):
662
+ self.js({"erro": r.get("erro") or "Não abriu a conversa."})
663
+ return
664
+ msgs = [{**x, "midias": midias_do_texto(x.get("texto", ""), self.app.pasta)} for x in (r.get("mensagens") or [])]
665
+ self.js({"conversa": {"id": r.get("id"), "titulo": r.get("titulo"), "mensagens": msgs}})
666
+ elif a == "entrar":
667
+ def trab():
668
+ self.app.entrar_e_esperar()
669
+ self.js({"login": self.conta()})
670
+ if logado():
671
+ self.conversas()
672
+ elif a == "sair_conta":
673
+ def trab():
674
+ self.node("require('./lib/conta.js').sair();console.log('{\"ok\":true}')")
675
+ self.js({"login": self.conta()})
676
+ self.js({"conversas": []})
677
+ elif a == "excluir" and m.get("id"):
678
+ def trab():
679
+ r = self.cli("conversa", str(m["id"]), "--apagar", "--json", timeout=120)
680
+ if not r.get("ok"):
681
+ self.js({"erro": "Não apaguei a conversa: " + str(r.get("erro") or "sem resposta")})
682
+ self.conversas()
683
+ elif a == "excluir_todas":
684
+ def trab():
685
+ r = self.cli("conversas", "--apagar-todas", "--json", timeout=600)
686
+ if not r.get("ok"):
687
+ self.js({"erro": "Não apaguei todas: " + str(r.get("erro") or "sem resposta")})
688
+ self.conversas()
689
+ elif a == "skills":
690
+ trab = self.skills
691
+ elif a == "skill" and m.get("nome"):
692
+ def trab():
693
+ self.node(f"require('./lib/skills.js').ligar({json.dumps(str(m['nome']))},{'true' if m.get('ligada') else 'false'});console.log('{{\"ok\":true}}')")
694
+ self.skills()
695
+ elif a == "ajustes":
696
+ trab = lambda: self.js({"ajustes": self.ajustes()})
697
+ elif a == "ajuste":
698
+ trab = lambda: self.ajuste(str(m.get("chave") or ""), m.get("valor"))
699
+ elif a == "soltar":
700
+ trab = lambda: self.soltar(str(m.get("nome") or "arquivo"), str(m.get("base64") or ""))
701
+ if trab:
702
+ threading.Thread(target=trab, daemon=True).start()
703
+
704
+ def ajuste(self, chave: str, valor) -> None:
705
+ if chave in ("modo", "effort", "permissoes"):
706
+ cfg = _json(PASTA_PC / "config.json")
707
+ if chave == "modo":
708
+ cfg["mode"] = "chat" if valor == "chat" else "primocode"
709
+ elif chave == "effort" and str(valor) in ("low", "medium", "high", "xhigh", "max", "ultracode"):
710
+ cfg["effort"] = str(valor)
711
+ elif chave == "permissoes" and str(valor) in dict(PERMISSOES_APP):
712
+ cfg["permissoes"] = str(valor)
713
+ else:
714
+ self.js({"erro": f"Valor inválido para {chave}: {valor}"})
715
+ (PASTA_PC / "config.json").write_text(json.dumps(cfg, ensure_ascii=False, indent=2), "utf-8")
716
+ elif chave == "provedor" and isinstance(valor, dict):
717
+ pid = str(valor.get("provedor") or "")
718
+ if pid == "primo":
719
+ r = self.node("require('./lib/local/provedor.js').desligar();console.log('{\"ok\":true}')")
720
+ else:
721
+ r = self.node("const P=require('./lib/local/provedor.js');const p=P.PROVEDORES[" + json.dumps(pid) + "];"
722
+ "if(!p){console.log(JSON.stringify({ok:false,erro:'Provedor desconhecido.'}));}else{"
723
+ "P.salvar({provedor:" + json.dumps(pid) + ",chave:" + json.dumps(str(valor.get("chave") or "")) + ",modelo:p.modelos[0]});"
724
+ "console.log(JSON.stringify({ok:true}))}")
725
+ if not r.get("ok"):
726
+ self.js({"erro": r.get("erro") or "Não deu pra trocar o modelo."})
727
+ elif chave == "voz_nome":
728
+ from primo_voz import identidade
729
+ nome = identidade._nome_valido(str(valor or ""))
730
+ if not nome:
731
+ self.js({"erro": "O nome precisa ter 1 ou 2 palavras, até 20 letras."})
732
+ else:
733
+ self.gravar_voz(nome=nome)
734
+ self.app.js({"nome": nome})
735
+ elif chave == "voz_sons":
736
+ self.gravar_voz(sons=bool(valor))
737
+ self.js({"ajustes": self.ajustes()})
738
+
739
+ def gravar_voz(self, **campos) -> None:
740
+ arq = PASTA_PC / "voz.json"
741
+ d = _json(arq)
742
+ d.update(campos)
743
+ arq.write_text(json.dumps(d, ensure_ascii=False, indent=2), "utf-8")
744
+
745
+ def escolher_pasta(self, caminho: str) -> None:
746
+ d = Path(caminho)
747
+ if d.is_dir():
748
+ self.app.pasta, self.app.pasta_modo = d, "/dir"
749
+ gravar_pasta(d)
750
+ self.app.painel({"pasta": self.app.rotulo()})
751
+ self.js({"pasta": str(d)})
752
+
753
+ # ── anexos: tudo vai para <pasta>/anexos e o caminho entra no pedido ──
754
+ def destino(self, nome: str) -> Path:
755
+ pasta = self.app.pasta / "anexos"
756
+ pasta.mkdir(parents=True, exist_ok=True)
757
+ base = re.sub(r"[^\w.\- ]", "_", Path(nome).name).strip() or "arquivo"
758
+ alvo, k = pasta / base, 1
759
+ while alvo.exists():
760
+ alvo = pasta / f"{Path(base).stem}-{k}{Path(base).suffix}"
761
+ k += 1
762
+ return alvo
763
+
764
+ def anexar_caminhos(self, caminhos: "list[str]") -> None:
765
+ import shutil
766
+ for c in caminhos:
767
+ o = Path(c)
768
+ if not o.is_file():
769
+ continue
770
+ if o.stat().st_size > self.MAX_ANEXO:
771
+ self.js({"erro": f"{o.name} passa de 25 MB."})
772
+ continue
773
+ alvo = self.destino(o.name)
774
+ shutil.copy2(o, alvo)
775
+ self.js({"anexo": {"nome": o.name, "caminho": str(alvo)}})
776
+
777
+ def soltar(self, nome: str, b64: str) -> None:
778
+ import base64
779
+ try:
780
+ dados = base64.b64decode(b64, validate=False)
781
+ except Exception:
782
+ self.js({"erro": f"Não li {nome}."})
783
+ return
784
+ if len(dados) > self.MAX_ANEXO:
785
+ self.js({"erro": f"{nome} passa de 25 MB."})
786
+ return
787
+ alvo = self.destino(nome)
788
+ alvo.write_bytes(dados)
789
+ self.js({"anexo": {"nome": nome, "caminho": str(alvo)}})
790
+
791
+ # ── o cérebro ──
792
+ def pensar(self, texto: str, conversa, modo: str, anexos: list, comando: bool = False) -> None:
793
+ """comando=True: `texto` é um /comando do terminal; a saída volta como {saida} (texto de terminal), não como resposta."""
794
+ texto = texto.strip()
795
+ if anexos:
796
+ texto = (texto or "Olha esses arquivos.") + "\n\nAnexos:\n" + "\n".join(f"- {a}" for a in anexos)
797
+ if not texto:
798
+ return
799
+ if not comando and not logado(): # /help, /doctor, /version… funcionam sem conta
800
+ self.js({"login": self.conta()})
801
+ self.js({"erro": "Entre na sua conta do PrimoCode pra conversar."})
802
+ return
803
+ if not (self.app.node and self.app.primocode and Path(self.app.primocode).exists()):
804
+ self.js({"erro": "Não achei o PrimoCode instalado. Rode primocode voz instalar-app no terminal."})
805
+ return
806
+ with self.app.trava:
807
+ env = self.app.ambiente()
808
+ env.update({"PRIMOCODE_APP": "1", "PRIMOCODE_RAIZ": str(RAIZ), "PRIMOCODE_LOG_FERRAMENTAS": "1",
809
+ "PRIMOCODE_MODO": "chat" if modo == "chat" else "primocode"})
810
+ if comando and re.match(r"/(open|abrir)\b", texto):
811
+ env["PRIMOCODE_SEM_NAVEGADOR"] = "0" # /open e /abrir existem para abrir o navegador
812
+ if self.app.pasta != RAIZ:
813
+ env["PRIMOCODE_PASTA"], env["PRIMOCODE_PASTA_ORIGEM"] = str(self.app.pasta), ("/dir" if self.app.pasta_modo == "/dir" else "projeto")
814
+ args = [self.app.node, self.app.primocode, "-p", texto, "--output-format", "json"] + (["--resume", str(conversa)] if conversa else [])
815
+ log(f"chat: modo={modo} cwd={self.app.pasta} conversa={conversa or 'nova'}")
816
+ t0 = time.time()
817
+ try:
818
+ p = subprocess.Popen(args, cwd=str(self.app.pasta), env=env, text=True, stdin=subprocess.DEVNULL,
819
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE, start_new_session=True)
820
+ self.app.cerebro = p
821
+ resto: "list[str]" = []
822
+
823
+ def passos(): # cada ferramenta que o agente usa vira uma linha ao vivo no chat
824
+ for l in p.stderr:
825
+ if l.startswith("ferramenta:"):
826
+ self.js({"passo": passo_legivel(l)})
827
+ else:
828
+ resto.append(l)
829
+ th = threading.Thread(target=passos, daemon=True)
830
+ th.start()
831
+ out = p.stdout.read()
832
+ p.wait(timeout=900)
833
+ th.join(timeout=2)
834
+ except Exception as e:
835
+ log(f"chat: cérebro falhou: {e}")
836
+ self.js({"erro": "O PrimoCode não respondeu."})
837
+ return
838
+ if self.parado:
839
+ self.js({"erro": "Parei."})
840
+ return
841
+ res = {}
842
+ for l in reversed((out or "").strip().splitlines()):
843
+ try:
844
+ res = json.loads(l)
845
+ break
846
+ except ValueError:
847
+ continue
848
+ if comando:
849
+ saida = str(res.get("result") or "").strip()
850
+ if res.get("is_error"):
851
+ self.js({"erro": saida or "O comando falhou."})
852
+ else:
853
+ self.js({"saida": {"comando": texto.split()[0], "texto": saida or "(sem saída)", "conversa": conversa, "midias": midias_do_texto(saida, self.app.pasta)}})
854
+ return
855
+ # o markdown cru da resposta (o -p json manda); sem ele, o texto do terminal sem os enfeites
856
+ txt = str(res.get("markdown") or "").strip() or \
857
+ "\n".join(l for l in str(res.get("result") or "").splitlines() if not l.lstrip().startswith("⎿")).replace("●", "").strip()
858
+ log(f"chat: {time.time() - t0:.1f}s, {len(txt)} caracteres, erro={bool(res.get('is_error'))}")
859
+ if res.get("is_error") or not txt:
860
+ self.js({"erro": txt or ("".join(resto)[-300:].strip() or "Não consegui uma resposta agora.")})
861
+ return
862
+ self.js({"resposta": {"texto": txt, "conversa": res.get("conversa") or conversa, "midias": midias_do_texto(txt, self.app.pasta)}})
863
+
864
+
865
+ FERRAMENTA_LEGIVEL = {"read_file": "Lendo", "write_file": "Escrevendo", "edit_file": "Editando", "multi_edit": "Editando",
866
+ "run_shell": "Rodando", "run_in_new_terminal": "Abrindo um terminal:", "list_dir": "Olhando a pasta",
867
+ "grep": "Procurando", "glob": "Procurando arquivos", "open_in_browser": "Abrindo no navegador",
868
+ "pesquisar_referencia": "Pesquisando referências", "write_todos": "Organizando as etapas",
869
+ "estudio_video": "Montando o vídeo", "estudio_motion": "Animando", "estudio_render": "Renderizando",
870
+ "estudio_editar": "Editando o vídeo", "estudio_narrar": "Gravando a narração", "estudio_site": "Fazendo o site",
871
+ "estudio_ae": "Animando no estilo After Effects", "estudio_midia": "Buscando mídia", "estudio_plano": "Planejando",
872
+ "estudio_projeto": "Abrindo o projeto"}
873
+
874
+
875
+ def passo_legivel(linha: str) -> str:
876
+ """`ferramenta: read_file {"path":"src/a.js"}` → `Lendo src/a.js`. O que não reconhece, mostra o nome."""
877
+ m = re.match(r"ferramenta:\s*(\S+)\s*(.*)", linha.strip())
878
+ if not m:
879
+ return linha.strip()[:120]
880
+ nome, resto = m.group(1), m.group(2)
881
+ try:
882
+ a = json.loads(resto) if resto else {}
883
+ except ValueError:
884
+ a = {}
885
+ alvo = next((str(a[k]) for k in ("path", "file_path", "file", "command", "cmd", "pattern", "query", "url", "dir") if isinstance(a, dict) and a.get(k)), "")
886
+ return (FERRAMENTA_LEGIVEL.get(nome, nome) + (" " + alvo if alvo else "")).strip()[:120]
887
+
888
+
432
889
  def main() -> int:
433
890
  import fcntl
434
891
  import atexit
@@ -444,7 +901,7 @@ def main() -> int:
444
901
  app = App()
445
902
  atexit.register(app.sair)
446
903
  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)
904
+ return hud.main(entrada=iter(app), saida=app.saida_do_painel, ao_iniciar=app.iniciar, ao_sair=app.sair, chat=Chat(app))
448
905
 
449
906
 
450
907
  if __name__ == "__main__":