primocode 10.15.1 → 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 +73 -10
- package/lib/act.js +2 -0
- package/lib/app-chat.js +97 -0
- package/lib/catalogo.js +3 -1
- package/lib/config.js +1 -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 +119 -46
- package/voz/primo_voz/app.py +582 -24
- package/voz/primo_voz/appmac.py +2 -1
- package/voz/primo_voz/chat.html +1762 -0
- package/voz/primo_voz/conversa.py +17 -32
- package/voz/primo_voz/escuta.py +15 -11
- package/voz/primo_voz/fala_pt.py +151 -0
- package/voz/primo_voz/hud.html +10 -4
- package/voz/primo_voz/hud.py +152 -7
- package/voz/primo_voz/identidade.py +1 -1
- package/voz/primo_voz/transcrever.py +9 -3
package/voz/primo_voz/app.py
CHANGED
|
@@ -51,12 +51,12 @@ def _sem_acento(t: str) -> str:
|
|
|
51
51
|
return "".join(ch for ch in unicodedata.normalize("NFD", t.lower()) if unicodedata.category(ch) != "Mn")
|
|
52
52
|
|
|
53
53
|
|
|
54
|
+
RAIZ = HOME / "Primo" # a área do app: projetos novos por voz nascem em ~/Primo/<nome>
|
|
55
|
+
|
|
56
|
+
|
|
54
57
|
def pasta_atual() -> Path:
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
return Path(d)
|
|
58
|
-
pc = HOME / "primocode"
|
|
59
|
-
return pc if pc.is_dir() else HOME
|
|
58
|
+
RAIZ.mkdir(exist_ok=True)
|
|
59
|
+
return RAIZ
|
|
60
60
|
|
|
61
61
|
|
|
62
62
|
def gravar_pasta(d: Path) -> None:
|
|
@@ -72,7 +72,7 @@ def achar_projeto(nome: str) -> "Path | None":
|
|
|
72
72
|
if not palavras:
|
|
73
73
|
return None
|
|
74
74
|
cand = [Path(x) for x in (_json(PASTA_PC / "projeto-ativo.json").get("recentes") or [])]
|
|
75
|
-
for base in (HOME / "primocode", HOME):
|
|
75
|
+
for base in (RAIZ, HOME / "primocode", HOME):
|
|
76
76
|
try:
|
|
77
77
|
cand += [c for c in base.iterdir() if c.is_dir() and not c.name.startswith(".")]
|
|
78
78
|
except OSError:
|
|
@@ -84,7 +84,9 @@ def achar_projeto(nome: str) -> "Path | None":
|
|
|
84
84
|
return None
|
|
85
85
|
|
|
86
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+(.+?)[.!?]*$", re.I)
|
|
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)
|
|
88
90
|
|
|
89
91
|
|
|
90
92
|
def _hora_falada(t: "time.struct_time") -> str:
|
|
@@ -92,12 +94,51 @@ def _hora_falada(t: "time.struct_time") -> str:
|
|
|
92
94
|
return hora_falada(t)
|
|
93
95
|
|
|
94
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
|
+
|
|
95
133
|
class App:
|
|
96
134
|
def __init__(self) -> None:
|
|
97
135
|
self.cfg = _json(PASTA_PC / "primo-app.json")
|
|
98
136
|
self.node = self.cfg.get("node") or ""
|
|
99
137
|
self.primocode = self.cfg.get("primocode") or ""
|
|
100
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 = ""
|
|
101
142
|
self.motor: "subprocess.Popen | None" = None
|
|
102
143
|
self.cerebro: "subprocess.Popen | None" = None
|
|
103
144
|
self.entrada: "queue.Queue" = queue.Queue()
|
|
@@ -106,12 +147,17 @@ class App:
|
|
|
106
147
|
self.vivo = True
|
|
107
148
|
self.trava = threading.Lock()
|
|
108
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
|
|
109
152
|
|
|
110
153
|
# ── entrada do painel (iterável de linhas JSON) ──
|
|
111
154
|
def __iter__(self):
|
|
112
155
|
while True:
|
|
113
156
|
yield self.entrada.get()
|
|
114
157
|
|
|
158
|
+
def rotulo(self) -> str:
|
|
159
|
+
return "~/Primo" if self.pasta == RAIZ else self.pasta.name
|
|
160
|
+
|
|
115
161
|
def painel(self, msg: dict) -> None:
|
|
116
162
|
self.entrada.put(json.dumps(msg, ensure_ascii=False))
|
|
117
163
|
|
|
@@ -138,6 +184,13 @@ class App:
|
|
|
138
184
|
self.painel({"login": logado()})
|
|
139
185
|
threading.Thread(target=trabalho, daemon=True).start()
|
|
140
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
|
+
|
|
141
194
|
def ambiente(self) -> dict:
|
|
142
195
|
env = dict(os.environ)
|
|
143
196
|
extra = [str(Path(self.node).parent)] if self.node else []
|
|
@@ -179,17 +232,21 @@ class App:
|
|
|
179
232
|
self.painel(m.get("msg") or {})
|
|
180
233
|
elif t == "pronto":
|
|
181
234
|
log("motor de voz: PRONTO (microfone aberto)")
|
|
182
|
-
self.painel({"pasta":
|
|
235
|
+
self.painel({"pasta": "~/Primo", "login": logado()})
|
|
183
236
|
elif t == "erro":
|
|
184
237
|
log("motor de voz: erro: " + str(m.get("texto")))
|
|
185
238
|
self.painel({"resposta": str(m.get("texto"))})
|
|
186
239
|
elif t == "parar":
|
|
187
240
|
self.matar_cerebro()
|
|
188
241
|
elif t == "ouvi" and str(m.get("texto") or "").strip():
|
|
242
|
+
self.ao_ouvir() # pedido falado: a tela de voz vem pra frente
|
|
189
243
|
threading.Thread(target=self.pensar, args=(str(m["texto"]).strip(),), daemon=True).start()
|
|
190
244
|
elif t == "fim":
|
|
191
245
|
break
|
|
192
246
|
log("motor de voz: saiu")
|
|
247
|
+
if self.desligando:
|
|
248
|
+
self.desligando = False
|
|
249
|
+
return
|
|
193
250
|
if self.vivo:
|
|
194
251
|
self.vivo = False
|
|
195
252
|
os._exit(0)
|
|
@@ -250,9 +307,8 @@ class App:
|
|
|
250
307
|
if m:
|
|
251
308
|
d = achar_projeto(m.group(1))
|
|
252
309
|
if d:
|
|
253
|
-
self.pasta = d
|
|
310
|
+
self.pasta, self.pasta_modo = d, "/dir"
|
|
254
311
|
gravar_pasta(d)
|
|
255
|
-
self.turnos = 0
|
|
256
312
|
self.painel({"pasta": d.name})
|
|
257
313
|
log(f"pasta: {d}")
|
|
258
314
|
self.dizer(f"Fechado. Agora trabalho no {d.name}.")
|
|
@@ -269,8 +325,6 @@ class App:
|
|
|
269
325
|
log(f"lat: resposta LOCAL em {time.time() - t_ini:.2f}s: {local!r}")
|
|
270
326
|
self.dizer(local)
|
|
271
327
|
return
|
|
272
|
-
if len(texto.split()) >= 3: # pedido que vai ao agente: confirma na hora, enquanto ele pensa
|
|
273
|
-
self.motor_envia({"tipo": "falar", "texto": ["Já vejo.", "Deixa eu ver.", "Um instante.", "Pode deixar."][self.turnos % 4]})
|
|
274
328
|
if not (self.node and self.primocode and Path(self.primocode).exists()):
|
|
275
329
|
log("cérebro: node/primocode não registrados em primo-app.json")
|
|
276
330
|
self.dizer("Não achei o PrimoCode instalado. Rode primocode voz instalar-app no terminal.")
|
|
@@ -279,18 +333,58 @@ class App:
|
|
|
279
333
|
self.painel({"login": False})
|
|
280
334
|
self.dizer("Preciso que você entre na conta do PrimoCode. Clique em Entrar.")
|
|
281
335
|
return
|
|
336
|
+
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()))
|
|
337
|
+
self.acao_antes = acao
|
|
338
|
+
pedido = texto
|
|
339
|
+
pedido += ('\n\n(Modo voz. Termine SEMPRE com uma única linha de JSON: {"falar": "...", "painel": "..."}. '
|
|
340
|
+
'"falar" é o que será DITO em voz alta: português natural de conversa, 1 a 4 frases curtas, sem markdown, sem código, '
|
|
341
|
+
'sem caminhos de arquivo, sem siglas nem abreviações; números e unidades por extenso como se fala '
|
|
342
|
+
'("trinta e dois segundos", "dez pixels", "noventa e nove reais", "versão dez ponto quinze"); nomes técnicos pronunciáveis; '
|
|
343
|
+
'diga o que você fez de fato. "painel" é a resposta escrita completa, com markdown e detalhes.)')
|
|
344
|
+
if acao:
|
|
345
|
+
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. "
|
|
346
|
+
"Projetos novos ficam em ~/Primo/<nome curto>. Ao terminar, responda em português falado, 2 a 4 frases, dizendo o que fez DE FATO: "
|
|
347
|
+
"pastas e arquivos criados, onde ficaram e o que abriu. Não use listas nem código na resposta.)")
|
|
282
348
|
with self.trava:
|
|
283
|
-
|
|
284
|
-
|
|
349
|
+
env = self.ambiente()
|
|
350
|
+
env.update({"PRIMOCODE_APP": "1", "PRIMOCODE_RAIZ": str(RAIZ), "PRIMOCODE_LOG_FERRAMENTAS": "1",
|
|
351
|
+
"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"})
|
|
352
|
+
if acao:
|
|
353
|
+
env["PRIMOCODE_MODO"] = "primocode"
|
|
354
|
+
if self.pasta != RAIZ:
|
|
355
|
+
env["PRIMOCODE_PASTA"], env["PRIMOCODE_PASTA_ORIGEM"] = str(self.pasta), ("/dir" if self.pasta_modo == "/dir" else "projeto")
|
|
356
|
+
args = [self.node, self.primocode, "-p", pedido, "--output-format", "json"] + (["-c"] if self.turnos else [])
|
|
357
|
+
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)}")
|
|
285
358
|
t0 = time.time()
|
|
359
|
+
|
|
360
|
+
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)
|
|
361
|
+
if acao and self.cerebro and self.cerebro.poll() is None:
|
|
362
|
+
a = ack_contextual(texto, self.ultimo_ack)
|
|
363
|
+
self.ultimo_ack = a
|
|
364
|
+
log(f"ack: {a!r}")
|
|
365
|
+
self.motor_envia({"tipo": "falar", "texto": a})
|
|
366
|
+
timer = threading.Timer(1.2, ack_tardio)
|
|
367
|
+
timer.start()
|
|
286
368
|
try:
|
|
287
|
-
self.cerebro = subprocess.Popen(args, cwd=str(self.pasta), env=
|
|
288
|
-
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
|
369
|
+
self.cerebro = subprocess.Popen(args, cwd=str(self.pasta), env=env, text=True,
|
|
370
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
|
371
|
+
start_new_session=True) # grupo próprio: nada do que o agente faça com processos atinge o app
|
|
289
372
|
out, err = self.cerebro.communicate(timeout=900)
|
|
373
|
+
log(f"cérebro: saiu com código {self.cerebro.returncode}")
|
|
374
|
+
timer.cancel()
|
|
290
375
|
except Exception as e:
|
|
376
|
+
timer.cancel()
|
|
291
377
|
log(f"cérebro: falhou: {e}")
|
|
292
378
|
self.dizer("O cérebro não respondeu.")
|
|
293
379
|
return
|
|
380
|
+
for l in (err or "").splitlines():
|
|
381
|
+
if l.startswith("ferramenta:"):
|
|
382
|
+
log(l[:300])
|
|
383
|
+
pj = _json(PASTA_PC / "projeto-ativo.json") # o agente criou/trocou de projeto? o painel e o próximo pedido seguem nele
|
|
384
|
+
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):
|
|
385
|
+
self.pasta, self.pasta_modo = Path(pj["dir"]), "projeto"
|
|
386
|
+
self.painel({"pasta": self.rotulo()})
|
|
387
|
+
log(f"pasta do projeto agora: {self.pasta}")
|
|
294
388
|
self.turnos += 1
|
|
295
389
|
resp = ""
|
|
296
390
|
for l in reversed((out or "").strip().splitlines()):
|
|
@@ -300,17 +394,37 @@ class App:
|
|
|
300
394
|
break
|
|
301
395
|
except ValueError:
|
|
302
396
|
continue
|
|
303
|
-
|
|
304
|
-
|
|
397
|
+
sobra = "\n".join(l for l in (err or "").splitlines() if not l.startswith("ferramenta:")).strip()
|
|
398
|
+
if sobra:
|
|
399
|
+
log("cérebro stderr: " + sobra[-400:])
|
|
305
400
|
# tira os enfeites do terminal (⎿ linhas de status, ● marcador) — a voz fala só o texto
|
|
306
|
-
resp = "\n".join(l for l in resp.splitlines() if not l.lstrip().startswith("⎿")).replace("●", "").strip()
|
|
401
|
+
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()
|
|
307
402
|
log(f"lat: cérebro (agente) {time.time() - t0:.1f}s, {len(resp)} caracteres: {resp[:160]!r}")
|
|
308
403
|
if not resp.strip():
|
|
309
404
|
self.dizer("Não consegui uma resposta agora.")
|
|
310
405
|
return
|
|
311
|
-
|
|
406
|
+
falar, painel = self.separar(resp)
|
|
407
|
+
self.motor_envia({"tipo": "falar", "texto": falar, "painel": painel})
|
|
312
408
|
self.motor_envia({"tipo": "estado", "estado": "esperando"})
|
|
313
409
|
|
|
410
|
+
@staticmethod
|
|
411
|
+
def separar(resp: str) -> "tuple[str, str]":
|
|
412
|
+
"""O cérebro devolve {"falar","painel"} (o terminal quebra a linha no meio do JSON: junta e lê). Sem JSON, vale o texto todo."""
|
|
413
|
+
corpo = re.sub(r"\s*\n\s*", " ", resp)
|
|
414
|
+
i, j = corpo.find('{"falar"'), corpo.rfind("}")
|
|
415
|
+
if i >= 0 and j > i:
|
|
416
|
+
try:
|
|
417
|
+
d = json.loads(corpo[i:j + 1], strict=False)
|
|
418
|
+
if str(d.get("falar") or "").strip():
|
|
419
|
+
antes = resp[: resp.find('{"falar"')].strip() if '{"falar"' in resp else ""
|
|
420
|
+
return str(d["falar"]).strip(), (str(d.get("painel") or "").strip() or antes or str(d["falar"]).strip())
|
|
421
|
+
except ValueError:
|
|
422
|
+
pass
|
|
423
|
+
m = re.search(r'"falar"\s*:\s*"(.*?)"\s*(?:,\s*"painel"|\})', corpo, re.S)
|
|
424
|
+
if m:
|
|
425
|
+
return m.group(1).strip(), corpo[:i].strip() or m.group(1).strip()
|
|
426
|
+
return resp, resp
|
|
427
|
+
|
|
314
428
|
def sair(self) -> None:
|
|
315
429
|
self.vivo = False
|
|
316
430
|
self.matar_cerebro()
|
|
@@ -322,10 +436,454 @@ class App:
|
|
|
322
436
|
self.motor.kill()
|
|
323
437
|
log("app: encerrado")
|
|
324
438
|
|
|
325
|
-
|
|
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:
|
|
326
462
|
self.js = js
|
|
327
|
-
|
|
328
|
-
|
|
463
|
+
if not com_chat: # só o modo voz puro (sem chat) abre ouvindo
|
|
464
|
+
threading.Timer(0.5, self.subir_motor).start()
|
|
465
|
+
threading.Timer(2.0, lambda: self.painel({"pasta": self.rotulo(), "login": logado()})).start()
|
|
466
|
+
|
|
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]
|
|
329
887
|
|
|
330
888
|
|
|
331
889
|
def main() -> int:
|
|
@@ -343,7 +901,7 @@ def main() -> int:
|
|
|
343
901
|
app = App()
|
|
344
902
|
atexit.register(app.sair)
|
|
345
903
|
from primo_voz import hud
|
|
346
|
-
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))
|
|
347
905
|
|
|
348
906
|
|
|
349
907
|
if __name__ == "__main__":
|