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.
@@ -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
- d = _json(PASTA_PC / "projeto-ativo.json").get("dir")
56
- if d and Path(d).is_dir():
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": self.pasta.name, "login": logado()})
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
- args = [self.node, self.primocode, "-p", texto, "--output-format", "json"] + (["-c"] if self.turnos else [])
284
- log("cérebro: " + " ".join(args[:3]) + " ... cwd=" + str(self.pasta))
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=self.ambiente(), text=True,
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
- if err and err.strip():
304
- log("cérebro stderr: " + err.strip()[-400:])
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
- self.motor_envia({"tipo": "falar", "texto": resp, "bruto": True})
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
- 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:
326
462
  self.js = js
327
- threading.Timer(0.5, self.subir_motor).start()
328
- threading.Timer(2.0, lambda: self.painel({"pasta": self.pasta.name, "login": logado()})).start()
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__":