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.
- package/README.md +2 -2
- package/bin/primocode.js +64 -8
- package/lib/act.js +2 -0
- package/lib/app-chat.js +97 -0
- package/lib/catalogo.js +3 -1
- package/lib/estudio/gosto/tela.html +1349 -0
- package/lib/estudio/gosto.js +264 -0
- package/lib/estudio/index.js +9 -1
- package/lib/estudio/maquina-diretor.js +9 -5
- package/lib/estudio/maquina.js +5 -1
- package/lib/headless.js +15 -3
- package/lib/local/cerebro.json +93 -1
- package/lib/memoria-usuario.js +164 -0
- package/lib/nuvem.js +22 -1
- package/lib/repeticao.js +1 -1
- package/lib/sandbox.js +57 -9
- package/lib/tools.js +20 -4
- package/lib/ui.js +1 -1
- package/package.json +2 -2
- package/voz/primo_voz/__main__.py +55 -40
- package/voz/primo_voz/app.py +460 -3
- package/voz/primo_voz/chat.html +1762 -0
- package/voz/primo_voz/conversa.py +0 -26
- package/voz/primo_voz/hud.html +8 -2
- package/voz/primo_voz/hud.py +129 -5
- package/voz/primo_voz/identidade.py +1 -1
package/voz/primo_voz/app.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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__":
|