primocode 8.8.0 → 8.9.1

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/bin/primocode.js CHANGED
@@ -540,11 +540,11 @@ async function sendPrompt(userText) {
540
540
 
541
541
  // O pedido passa por uma cabeça antes de virar trabalho: conserta o
542
542
  // que está torto, diz qual é o entregável e o que conta como pronto.
543
- const prompt2 = await refinarPedido(prompt);
544
-
545
- // A tarefa grande vira passos, e cada passo é uma chamada própria — com
546
- // conversa nova, e por isso pequena. Oculto: o usuário mandou UMA coisa.
547
- const passos = await dividirTarefa(prompt2);
543
+ // UMA chamada faz as duas coisas: arruma o pedido torto e, se fizer
544
+ // sentido, divide em passos. Eram duas idas ao modelo por pedido, e
545
+ // num plano limitado por REQUISIÇÕES (o grátis da Groq são 30 por
546
+ // minuto) cada chamada extra custa tanto quanto token.
547
+ const passos = await dividirTarefa(prompt);
548
548
 
549
549
  let r = null;
550
550
  let jaFeito = '';
@@ -585,66 +585,43 @@ async function sendPrompt(userText) {
585
585
  }
586
586
 
587
587
  /**
588
- * Divide a tarefa grande em passos — e faz isso ESCONDIDO.
588
+ * Arruma o pedido e divide em passos — as duas coisas, ESCONDIDAS.
589
+ *
590
+ * ARRUMAR: gente pede curto e torto ("cria um vidro anunciando o founders
591
+ * ai"). Sem isso o agente adivinha, e adivinha errado. É oculto porque
592
+ * encanamento não é conversa: o usuário quer ver a coisa acontecendo, não um
593
+ * relatório do que a máquina entendeu — e não há o que aprovar, já que o
594
+ * refino não decide nada, só desambigua o que ele já disse.
589
595
  *
590
- * O motivo é aritmético. Com 6.000 tokens por minuto, "pesquise sobre a X e
591
- * crie um vídeo" não cabe numa conversa só: o custo fixo (prompt + tools) é
592
- * ~3.700, a spec do formato ~1.400, os fatos da pesquisa o resto — e sobram
593
- * 512 para o agente escrever o documento. Em DOIS pedidos cada um cabe
594
- * folgado, porque cada um leva só as ferramentas e o prompt do seu passo.
596
+ * DIVIDIR: o motivo é aritmético. Com 6.000 tokens por minuto, "pesquise
597
+ * sobre a X e crie um vídeo" não cabe numa conversa só — custo fixo ~3.700,
598
+ * spec do formato ~1.400, fatos da pesquisa o resto, e sobram 512 para o
599
+ * agente escrever. Em dois pedidos cada um cabe folgado, porque cada um leva
600
+ * só as ferramentas e o prompt do seu passo.
595
601
  *
596
- * O dono queria isto automático: "o usuário manda a tarefa gigante, e a IA
597
- * ocultamente vai quebrar a tarefa". E com razão — quem pede não sabe onde
598
- * está a fronteira barata, e pode partir num pedaço que ainda não cabe.
602
+ * As duas numa chamada só: elas leem o mesmo texto, e num plano limitado por
603
+ * requisições por minuto a segunda ida ao modelo custa tanto quanto token.
599
604
  *
600
- * Falhou? Um passo só, que é o comportamento de sempre.
605
+ * Falhou? O pedido do usuário, inteiro, como ele escreveu.
601
606
  */
602
607
  async function dividirTarefa(pedido) {
603
608
  const inteiro = [{ titulo: 'tarefa', pedido }];
609
+ if (!pedido || pedido.trim().length < 12) return inteiro;
610
+ const sp = new Spinner('Primo Code pensando').start();
604
611
  try {
605
612
  const r = await api.requestPlano(state.config.server, {
606
613
  prompt: pedido, model: state.config.model, token: state.config.token,
607
614
  });
615
+ sp.stop();
608
616
  const passos = (r && Array.isArray(r.passos) && r.passos.length) ? r.passos : inteiro;
609
617
  if (passos.length > 1) console.log(c.dim(` ⎿ ${passos[0].titulo}`));
610
618
  return passos;
611
619
  } catch {
620
+ sp.stop(); // preparar o pedido é melhoria, não requisito
612
621
  return inteiro;
613
622
  }
614
623
  }
615
624
 
616
- /**
617
- * Estrutura o pedido antes de mandar o agente executar.
618
- *
619
- * Gente pede curto e torto — "cria um vidro anunciando o founders ai". Sem
620
- * isto o agente adivinha, e adivinha errado: sai o genérico, ou ele pesquisa
621
- * em círculo porque não sabe o que é "pronto". Uma passada barata antes de
622
- * começar vira ordem de serviço: o que entregar, com que fatos, quando parar.
623
- *
624
- * É OCULTO de propósito. A primeira versão mostrava "entendi assim:" na tela,
625
- * e o dono cortou: encanamento não é conversa. O usuário pediu uma coisa e
626
- * quer ver a coisa acontecendo, não um relatório do que a máquina entendeu.
627
- * O refino também não decide nada — só desambigua o que ele já disse —, então
628
- * não há o que ele precise aprovar.
629
- *
630
- * Nunca bloqueia: se o refino falhar ou vier estranho, vale o pedido original.
631
- */
632
- async function refinarPedido(cru) {
633
- if (!cru || cru.trim().length < 12) return cru;
634
- const sp = new Spinner('Primo Code pensando').start();
635
- try {
636
- const r = await api.requestRefino(state.config.server, {
637
- prompt: cru, model: state.config.model, token: state.config.token,
638
- });
639
- sp.stop();
640
- if (!r || !r.refinado || !r.prompt) return cru;
641
- return r.prompt;
642
- } catch {
643
- sp.stop(); // refinar é melhoria, não requisito
644
- return cru;
645
- }
646
- }
647
-
648
625
  /**
649
626
  * Atualiza o resumo da conversa, se já compensar.
650
627
  *
package/lib/referencia.js CHANGED
@@ -224,13 +224,17 @@ const GENERICAS = new Set(['sobre', 'empresa', 'company', 'overview', 'about', '
224
224
  * "paris" e não tem nada a ver). Consulta de uma palavra só se contenta com
225
225
  * ela mesma.
226
226
  */
227
- function relevante(cand, palavras) {
228
- if (!palavras.length) return true;
227
+ function quantoBate(cand, palavras) {
229
228
  const alvo = `${cand.titulo || ''} ${cand.url || ''}`
230
229
  .normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
231
230
  let batidas = 0;
232
231
  for (const p of palavras) if (alvo.includes(p)) batidas++;
233
- return batidas >= Math.min(2, palavras.length);
232
+ return batidas;
233
+ }
234
+
235
+ function relevante(cand, palavras) {
236
+ if (!palavras.length) return true;
237
+ return quantoBate(cand, palavras) >= Math.min(2, palavras.length);
234
238
  }
235
239
 
236
240
  /**
@@ -482,6 +486,9 @@ async function pesquisar({ consulta, paginas = 3 }) {
482
486
  }
483
487
 
484
488
  const lidas = [];
489
+ // Os chutes de domínio ficam de lado até o fim: só então dá para saber
490
+ // qual deles fala mais do assunto.
491
+ const chutesLidos = [];
485
492
  for (const cand of candidatos) {
486
493
  if (lidas.length >= paginas) break;
487
494
  const html = await pegar(cand.url);
@@ -493,7 +500,16 @@ async function pesquisar({ consulta, paginas = 3 }) {
493
500
  // parisgroup.com (um grupo de varejo no exterior) e parisgroup.ai (a
494
501
  // empresa que o usuário quis). O nome não distingue os dois — o texto
495
502
  // da página, sim. Sem esta prova o agente escreveria sobre a errada.
496
- if (cand.nota === 'site provável da empresa' && !relevante({ titulo: texto.slice(0, 3000), url: cand.url }, palavras)) continue;
503
+ const ehChute = cand.nota === 'site provável da empresa';
504
+ if (ehChute) {
505
+ const nota = quantoBate({ titulo: texto.slice(0, 4000), url: cand.url }, palavras);
506
+ if (nota < Math.min(2, palavras.length)) continue;
507
+ // Passar não basta: entre dois domínios do MESMO nome, fica o que
508
+ // fala mais do assunto. parisgroup.ai e parisgroup.co os dois
509
+ // "batiam", e o agente recebia as duas empresas como se fossem uma.
510
+ chutesLidos.push({ nota, item: { titulo: cand.titulo, url: cand.url, nota: cand.nota, trecho: texto.slice(0, MAX_TEXTO) } });
511
+ continue;
512
+ }
497
513
  lidas.push({
498
514
  titulo: cand.titulo,
499
515
  url: cand.url,
@@ -502,6 +518,14 @@ async function pesquisar({ consulta, paginas = 3 }) {
502
518
  });
503
519
  }
504
520
 
521
+ // O melhor chute entra na frente: quando a empresa tem site, ele é a
522
+ // fonte mais confiável que existe sobre ela.
523
+ if (chutesLidos.length) {
524
+ chutesLidos.sort((a, b) => b.nota - a.nota);
525
+ lidas.unshift(chutesLidos[0].item);
526
+ if (lidas.length > paginas) lidas.length = paginas;
527
+ }
528
+
505
529
  if (!lidas.length) {
506
530
  return {
507
531
  ok: false,
@@ -524,4 +548,4 @@ async function pesquisar({ consulta, paginas = 3 }) {
524
548
  }
525
549
 
526
550
  module.exports = { pesquisar, buscar, stackoverflow, textoDe, buscarWikipedia, buscarRespostaDDG, pegar,
527
- relevante, palavrasDaConsulta, sitesProvaveis, pareceCodigo };
551
+ relevante, quantoBate, palavrasDaConsulta, sitesProvaveis, pareceCodigo };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.8.0",
3
+ "version": "8.9.1",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
package/studio/server.py CHANGED
@@ -15,6 +15,7 @@ import mimetypes
15
15
  import os
16
16
  import re
17
17
  import sys
18
+ import time
18
19
  import unicodedata
19
20
  import uuid
20
21
  from datetime import datetime, timezone
@@ -461,6 +462,8 @@ class Handler(SimpleHTTPRequestHandler):
461
462
 
462
463
  if p == "/api/voz":
463
464
  return self.voz(urlparse(self.path).query)
465
+ if p == "/api/voz/status":
466
+ return self.voz_status()
464
467
 
465
468
  if p == "/api/midia":
466
469
  return self.js(listar_midia())
@@ -506,20 +509,128 @@ class Handler(SimpleHTTPRequestHandler):
506
509
  if len(texto) > 4000:
507
510
  texto = texto[:4000]
508
511
 
509
- try:
510
- audio = b"".join(self._trecho_de_voz(t, idioma) for t in _picar(texto, 190))
511
- except Exception as e: # rede, bloqueio, mudança do serviço
512
- return self.js({"erro": "voz indisponível: %s" % e}, 502)
512
+ voz = (q.get("voz", [""])[0] or "").strip()
513
+ instrucao = (q.get("instrucao", [""])[0] or "").strip()
514
+ motor = (q.get("motor", [""])[0] or "").strip()
515
+
516
+ tipo = "audio/mpeg"
517
+ audio = b""
518
+ # 1) Voicebox, se o usuário tiver instalado: voz clonável, ilimitada e
519
+ # sem depender de nada na internet.
520
+ if motor != "google" and self._voicebox_vivo():
521
+ try:
522
+ audio, tipo = self._voz_voicebox(texto, voz, instrucao)
523
+ except Exception as e:
524
+ sys.stderr.write("[voz] Voicebox falhou (%s); indo de Google\n" % e)
525
+ audio = b""
526
+ # 2) Google: uma voz só, mas responde sempre.
527
+ if not audio:
528
+ try:
529
+ audio = b"".join(self._trecho_de_voz(t, idioma) for t in _picar(texto, 190))
530
+ except Exception as e: # rede, bloqueio, mudança do serviço
531
+ return self.js({"erro": "voz indisponível: %s" % e}, 502)
513
532
  if not audio:
514
533
  return self.js({"erro": "voz não devolveu áudio"}, 502)
515
534
 
516
535
  self.send_response(200)
517
- self.send_header("Content-Type", "audio/mpeg")
536
+ self.send_header("Content-Type", tipo)
518
537
  self.send_header("Content-Length", str(len(audio)))
519
538
  self.send_header("Cache-Control", "no-store")
520
539
  self.end_headers()
521
540
  self.wfile.write(audio)
522
541
 
542
+ # ── Voicebox ───────────────────────────────────────────────────────
543
+ # https://voicebox.sh — app local, código aberto, de graça e sem limite,
544
+ # com clonagem de voz. É a melhor voz que existe para este uso e não
545
+ # depende de serviço nenhum na internet: roda na máquina do usuário.
546
+ #
547
+ # A ponte é FEITA AQUI, e não no navegador, por causa do CORS: o Voicebox
548
+ # só libera as portas dele (5173 e 17493), e o Studio serve em 7777 — do
549
+ # navegador o pedido morreria antes de sair.
550
+ #
551
+ # Não está instalado? A rota cai para o Google, e ninguém fica sem voz.
552
+ VOICEBOX = "http://127.0.0.1:17493"
553
+
554
+ def voz_status(self):
555
+ """O que está disponível para narrar, para a interface não oferecer o
556
+ que não existe."""
557
+ vivo = self._voicebox_vivo()
558
+ vozes = []
559
+ if vivo:
560
+ try:
561
+ with urllib.request.urlopen(self.VOICEBOX + "/profiles", timeout=6) as r:
562
+ vozes = [{"id": v.get("id"), "nome": v.get("name"), "idioma": v.get("language")}
563
+ for v in json.loads(r.read().decode("utf-8"))]
564
+ except Exception:
565
+ vozes = []
566
+ return self.js({
567
+ "voicebox": {"ativo": vivo, "vozes": vozes, "site": "https://voicebox.sh"},
568
+ "google": {"ativo": True},
569
+ })
570
+
571
+ def _voicebox_vivo(self):
572
+ try:
573
+ with urllib.request.urlopen(self.VOICEBOX + "/health", timeout=2):
574
+ return True
575
+ except Exception:
576
+ return False
577
+
578
+ def _voicebox_perfil(self, pedido):
579
+ """A voz a usar: a que o usuário pediu pelo nome, ou a primeira que
580
+ houver. Sem perfil não há geração — o Voicebox exige um."""
581
+ req = urllib.request.Request(self.VOICEBOX + "/profiles")
582
+ with urllib.request.urlopen(req, timeout=8) as r:
583
+ perfis = json.loads(r.read().decode("utf-8"))
584
+ if not perfis:
585
+ raise RuntimeError("Voicebox está no ar mas sem nenhuma voz criada")
586
+ if pedido:
587
+ alvo = pedido.strip().lower()
588
+ for p in perfis:
589
+ if alvo in (p.get("name") or "").lower() or alvo == p.get("id"):
590
+ return p
591
+ # Sem escolha explícita, prefere uma voz em português.
592
+ for p in perfis:
593
+ if (p.get("language") or "").startswith("pt"):
594
+ return p
595
+ return perfis[0]
596
+
597
+ def _voz_voicebox(self, texto, voz, instrucao):
598
+ perfil = self._voicebox_perfil(voz)
599
+ corpo = json.dumps({
600
+ "profile_id": perfil["id"],
601
+ "text": texto[:50000],
602
+ "language": "pt",
603
+ # A instrução de entrega é o que dá voz de cinema em vez de voz de
604
+ # leitura. O Voicebox aceita em linguagem natural, até 500 chars.
605
+ "instruct": (instrucao or "Narração institucional: voz grave, ritmo pausado, tom seguro.")[:500],
606
+ "max_chunk_chars": 800,
607
+ "crossfade_ms": 50,
608
+ "normalize": True,
609
+ }).encode("utf-8")
610
+ req = urllib.request.Request(self.VOICEBOX + "/generate", data=corpo,
611
+ headers={"Content-Type": "application/json"})
612
+ with urllib.request.urlopen(req, timeout=30) as r:
613
+ g = json.loads(r.read().decode("utf-8"))
614
+ gid = g.get("id")
615
+ if not gid:
616
+ raise RuntimeError("Voicebox não devolveu id de geração")
617
+
618
+ # /generate volta na hora com status "generating" e sintetiza em
619
+ # segundo plano — então é preciso esperar de verdade.
620
+ prazo = time.time() + 180
621
+ estado = g.get("status")
622
+ while estado in (None, "generating", "pending", "processing"):
623
+ if time.time() > prazo:
624
+ raise RuntimeError("Voicebox demorou demais")
625
+ time.sleep(1.0)
626
+ with urllib.request.urlopen(self.VOICEBOX + "/generate/%s/status" % gid, timeout=8) as r:
627
+ estado = (json.loads(r.read().decode("utf-8")) or {}).get("status")
628
+ if estado != "completed":
629
+ raise RuntimeError("Voicebox falhou (%s)" % estado)
630
+
631
+ with urllib.request.urlopen(self.VOICEBOX + "/audio/%s" % gid, timeout=30) as r:
632
+ return r.read(), (r.headers.get("Content-Type") or "audio/wav")
633
+
523
634
  def _trecho_de_voz(self, texto, idioma):
524
635
  url = "https://translate.google.com/translate_tts?" + urlencode({
525
636
  "ie": "UTF-8", "q": texto, "tl": idioma, "client": "tw-ob", "total": 1, "idx": 0,