primocode 9.7.0 → 9.8.0-beta.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/bin/primocode.js CHANGED
@@ -20,6 +20,7 @@ const { escolherModo } = require('../lib/modo.js');
20
20
  const api = require('../lib/api');
21
21
  const tools = require('../lib/tools');
22
22
  const cfg = require('../lib/config');
23
+ const nomeProjeto = require('../lib/nome-projeto');
23
24
  const { runAct, interromper, ehTetoDeToken, ehTransitorio, esperaDoErro } = require('../lib/act');
24
25
  const fala = require('../lib/fala');
25
26
  const boasVindas = require('../lib/boas-vindas');
@@ -563,8 +564,25 @@ function planWorkDir(userText) {
563
564
  return { dir: cfg.ROOT_DIR, projeto: cfg.getProjectDir(proposto), pending: true, name: proposto };
564
565
  }
565
566
 
567
+ /* O nome da pasta nova vem do modelo (lib/nome-projeto.js), pedido em
568
+ paralelo com o trabalho: a pasta só nasce na primeira escrita, e até lá a
569
+ resposta já chegou. Só pergunta quando o pedido é de CRIAR algo — pedir
570
+ para abrir o Chrome não gasta chamada com nome de pasta. */
571
+ function batizarDepois(plan, userText) {
572
+ if (!plan.pending || !(RE_CRIAR_UM.test(semAcento(userText)) || PEDIDO_DE_CODIGO.test(userText))) return;
573
+ const perguntar = async (texto) => {
574
+ let resposta = '';
575
+ await api.requestChat(state.config.server, {
576
+ prompt: texto, history: [], model: state.config.model, token: state.config.token,
577
+ }, (evt) => { if (evt.delta) resposta += evt.delta; });
578
+ return resposta;
579
+ };
580
+ nomeProjeto.escolher({ pedido: userText, nomes: cfg.listProjects(), perguntar })
581
+ .then((nome) => { if (nome) plan.nomeEscolhido = nome; });
582
+ }
583
+
566
584
  function createPendingProject(plan) {
567
- const dir = cfg.createProjectDir(plan.name);
585
+ const dir = cfg.createProjectDir(plan.nomeEscolhido || plan.name);
568
586
  entrarNoProjeto(dir, 'projeto');
569
587
  const rotulo = plan.similar ? 'Continuando em' : 'Projeto novo:';
570
588
  console.log(c.ok(` ${rotulo} ${state.currentProject}`) + c.muted(` (${dir})`));
@@ -619,6 +637,7 @@ function prepararPasta(userText) {
619
637
  talvezTrocarDePasta(userText);
620
638
 
621
639
  const plan = planWorkDir(userText);
640
+ batizarDepois(plan, userText);
622
641
 
623
642
  // Projeto que já existe: entra nele AGORA. Se deixasse para depois, o
624
643
  // buildMemoryContext deste turno leria a pasta errada e a memória do projeto
@@ -914,7 +933,7 @@ async function sendPrompt(userText) {
914
933
  : '';
915
934
  const pedido = passos[i].pedido + contexto + semFatos;
916
935
 
917
- const rodarPasso = () => runAct(pedido, {
936
+ const rodarPasso = (texto = pedido) => runAct(texto, {
918
937
  server: state.config.server,
919
938
  model: state.config.model,
920
939
  effort: state.config.effort,
@@ -937,15 +956,38 @@ async function sendPrompt(userText) {
937
956
  });
938
957
 
939
958
  r = await rodarPasso();
940
- // Passo que desistiu (modelo mudo, voltas esgotadas) ganha UMA
941
- // segunda chance — costuma ser tropeço transitório do modelo
942
- // grátis. Falhou de novo: o plano segue com o aviso de "sem
943
- // fatos", nunca com o texto da falha fantasiado de resultado.
944
- if (!r.parado && r.concluiu === false && passos.length > 1) {
945
- console.log(c.dim(' ⎿ o passo não concluiu — tentando de novo'));
946
- r = await rodarPasso();
959
+ /* Passo que parou no meio NÃO devolve a tarefa para a pessoa
960
+ dividir. Se ele já tinha feito alguma coisa (arquivo, comando),
961
+ o próprio CLI pede a continuação — com a lista do que já existe
962
+ no disco, para não refazer. Até CONTINUACOES vezes, e só
963
+ enquanto cada volta produz algo: continuação que não mexe em
964
+ nada é laço, e laço para.
965
+ Num plano de vários passos, o passo que desistiu sem fazer nada
966
+ ganha UMA segunda chance — costuma ser tropeço transitório do
967
+ modelo grátis. Falhou de novo: o
968
+ plano segue com o aviso de "sem fatos", nunca com o texto da
969
+ falha fantasiado de resultado. */
970
+ let continuacoes = 0;
971
+ let tentouDeNovo = false;
972
+ while (!r.parado && r.concluiu === false) {
973
+ registro.juntar(livro, r.registro);
974
+ // Sem registro é erro do provedor (cota, rede): retomar só bate
975
+ // no mesmo muro.
976
+ if (r.registro && !registro.vazio(r.registro) && continuacoes < CONTINUACOES) {
977
+ continuacoes++;
978
+ console.log(c.dim(' ⎿ continuando de onde parou'));
979
+ r = await rodarPasso(pedidoDeContinuacao(passos[i].pedido, livro));
980
+ continue;
981
+ }
982
+ if (!tentouDeNovo && passos.length > 1 && registro.vazio(r.registro) && continuacoes === 0) {
983
+ tentouDeNovo = true;
984
+ console.log(c.dim(' ⎿ o passo não concluiu — tentando de novo'));
985
+ r = await rodarPasso();
986
+ continue;
987
+ }
988
+ break;
947
989
  }
948
- registro.juntar(livro, r.registro);
990
+ if (r.concluiu !== false || r.parado) registro.juntar(livro, r.registro);
949
991
  jaFeito = (r.concluiu !== false)
950
992
  ? String((r.assistantMessage && r.assistantMessage.content) || '').slice(0, 1200)
951
993
  : '';
@@ -1002,6 +1044,18 @@ async function sendPrompt(userText) {
1002
1044
  const RE_DESCOBRIR = /\b(pesquis|busca|procur|descubr|investig|analis|leia|estud|olh[ae] (o site|a p[áa]gina)|ver o que)\w*/i;
1003
1045
  const RE_CRIAR_DEPOIS = /\b(cri[ae]|faz|faça|mont[ae]|ger[ae]|escrev[ae]|produz|desenvolv)\w*/i;
1004
1046
 
1047
+ /* A outra divisão que compensa: o pedido LONGO que emenda entregas diferentes
1048
+ ("cria o site, depois põe login e publica"). Estreita de propósito — o
1049
+ planejador dividindo pedido de uma fase só era o "faz coisas nada a ver" —
1050
+ e o servidor ainda devolve passo único na dúvida. */
1051
+ const RE_ACAO = /\b(cri[ae]r?|fa[zç]a?|fazer|mont[ae]r?|ger[ae]r?|escrev[ae]r?|adicion[ae]r?|coloc[ae]r?|p[oõ]e|implement[ae]r?|public[ae]r?|sob[ea]|instal[ae]r?|configur[ae]r?|test[ae]r?|corrig[ei]r?|arrum[ae]r?|mud[ae]r?|integr[ae]r?|conect[ae]r?)\b/gi;
1052
+ function pedidoDeVariasPartes(pedido) {
1053
+ const t = String(pedido || '');
1054
+ if (t.length < 200) return false;
1055
+ const acoes = new Set((t.match(RE_ACAO) || []).map((x) => x.toLowerCase().slice(0, 4)));
1056
+ return acoes.size >= 3 && /\b(depois|em seguida|e tamb[ée]m|al[ée]m disso|por fim|e ent[ãa]o)\b|\n\s*(\d+[.)]|[-*•])\s/i.test(t);
1057
+ }
1058
+
1005
1059
  /* "me manda o vídeo" resolvido na hora, sem gastar modelo.
1006
1060
  *
1007
1061
  * Antes isso abria a última criação do Studio no navegador com `#gravar`, e a
@@ -1038,12 +1092,33 @@ async function baixarCriacaoDireto(texto) {
1038
1092
  }
1039
1093
  }
1040
1094
 
1095
+ /* Quantas vezes um passo que parou no meio é retomado sozinho. */
1096
+ const CONTINUACOES = 2;
1097
+
1098
+ /**
1099
+ * O pedido de retomada: o pedido original + o que já existe, para o agente
1100
+ * seguir em vez de recomeçar. A conversa do passo anterior não vai junto (é
1101
+ * ela que estourou) — vai o que aconteceu no disco, que é o que importa.
1102
+ */
1103
+ function pedidoDeContinuacao(original, livro) {
1104
+ const linhas = [];
1105
+ if (livro.criados.length) linhas.push(`Arquivos criados: ${livro.criados.slice(0, 20).join(', ')}`);
1106
+ if (livro.alterados.length) linhas.push(`Arquivos alterados: ${livro.alterados.slice(0, 20).join(', ')}`);
1107
+ const cmds = livro.comandos.slice(-8).map((x) => `${x.comando} (${x.ok ? 'ok' : 'falhou'})`);
1108
+ if (cmds.length) linhas.push(`Comandos rodados: ${cmds.join('; ')}`);
1109
+ const falhas = livro.falhas.slice(-3).map((f) => `${f.acao}: ${f.motivo}`);
1110
+ if (falhas.length) linhas.push(`Falhas: ${falhas.join('; ')}`);
1111
+ return `CONTINUAÇÃO — a tarefa abaixo já foi começada e parou no meio. NÃO recomece do zero: `
1112
+ + `leia o que já existe, veja o que falta, divida o que falta em passos pequenos (write_todos) e termine.\n\n`
1113
+ + `O QUE JÁ FOI FEITO:\n${linhas.join('\n') || '(nada registrado)'}\n\nA TAREFA:\n${original}`;
1114
+ }
1115
+
1041
1116
  async function dividirTarefa(pedido) {
1042
1117
  const inteiro = [{ titulo: 'tarefa', pedido }];
1043
1118
  if (!pedido || pedido.trim().length < 12) return inteiro;
1044
1119
  // Sem as duas fases no texto, nem chama o servidor: mais rápido, e sem
1045
1120
  // dar ao modelo a chance de dividir o que não devia.
1046
- if (!(RE_DESCOBRIR.test(pedido) && RE_CRIAR_DEPOIS.test(pedido))) return inteiro;
1121
+ if (!(RE_DESCOBRIR.test(pedido) && RE_CRIAR_DEPOIS.test(pedido)) && !pedidoDeVariasPartes(pedido)) return inteiro;
1047
1122
  const sp = new Spinner('Primo Code pensando').start();
1048
1123
  try {
1049
1124
  const r = await api.requestPlano(state.config.server, {
@@ -1691,6 +1766,27 @@ async function handleSlash(text) {
1691
1766
  // Mesma memória do terminal: espera o resumo da rodada
1692
1767
  // anterior e leva só ele, com o projeto atual.
1693
1768
  await esperarResumo();
1769
+ /* O mesmo roteador do terminal: cumprimento e pergunta vão
1770
+ para a conversa, curta; só pedido de trabalho vai para o
1771
+ agente. Antes TUDO ia para o agente — até um "oi". */
1772
+ const escolha = escolherModo(texto, { ultimoModo: state.modoVoz });
1773
+ state.modoVoz = escolha.modo;
1774
+ if (escolha.modo === 'chat') {
1775
+ let resposta = '';
1776
+ try {
1777
+ await api.requestChat(state.config.server, {
1778
+ prompt: `${vozModo.COMO_CONVERSAR}\n\nA PESSOA DISSE: ${texto}`,
1779
+ history: state.history.slice(-4),
1780
+ model: state.config.model,
1781
+ token: state.config.token,
1782
+ }, (evt) => { if (evt.delta) resposta += evt.delta; });
1783
+ } catch { /* sem resposta: a orb volta a ouvir */ }
1784
+ resposta = resposta.trim();
1785
+ state.history.push({ role: 'user', content: texto });
1786
+ state.history.push({ role: 'assistant', content: resposta });
1787
+ resumirDepois();
1788
+ return resposta;
1789
+ }
1694
1790
  const plan = prepararPasta(texto);
1695
1791
  const resultado = await runAct(texto, {
1696
1792
  instrucao: vozModo.COMO_FALAR,
package/lib/act.js CHANGED
@@ -138,7 +138,10 @@ function logResult(kind, result, profundidade) {
138
138
  // vermelho que faz parecer que a tarefa morreu.
139
139
  if (result.ok === false) {
140
140
  const texto = String(result.error || `exit ${result.code ?? 'erro'}`).replace(/\s+/g, ' ').trim();
141
- console.log(p + c.warn(texto.length > 160 ? texto.slice(0, 159) + '…' : texto));
141
+ // Erro de permissão não corta: a instrução que resolve ("Acessibilidade…
142
+ // peça de novo") fica justamente depois do 160º caractere.
143
+ const teto = result.permissao === false ? 400 : 160;
144
+ console.log(p + c.warn(texto.length > teto ? texto.slice(0, teto - 1) + '…' : texto));
142
145
  return;
143
146
  }
144
147
 
@@ -411,22 +414,29 @@ function pareceParadaNoMeio(texto, { todosPendentes = 0, rodouTool = false } = {
411
414
  return false;
412
415
  }
413
416
 
417
+ /* O recado da troca de rota: não repete o que já tentou, quebra o que falta
418
+ em pedaços e faz o primeiro. Quem divide a tarefa é o agente, não a pessoa. */
419
+ const TROCA_DE_ROTA = 'Você ficou três voltas sem conseguir rodar nada, repetindo chamadas que já tinha feito. '
420
+ + 'Não repita nenhuma delas. Olhe o que já está pronto na conversa, divida o que falta em passos pequenos '
421
+ + '(write_todos) e faça AGORA o primeiro passo, por um caminho diferente: outra ferramenta, outro arquivo, '
422
+ + 'outro comando. Se não houver caminho, chame finish e diga em uma frase o que impediu.';
423
+
414
424
  function motivoDaParada({ voltas, voltasEmFalso, falhasPendentes, travou }) {
415
425
  if (falhasPendentes.length) {
416
426
  return `Parei sem concluir. A última falha foi: ${falhasPendentes[falhasPendentes.length - 1].texto}`;
417
427
  }
418
428
  if (travou) {
419
- return 'Travei repetindo as mesmas chamadas sem sair do lugar, e parei antes de gastar sua cota à toa. '
420
- + 'Peça em partes: primeiro a pesquisa, depois o que criar com ela.';
429
+ return 'Fiquei preso: tentei dois caminhos e os dois voltaram ao mesmo lugar, então parei antes de gastar sua cota à toa. '
430
+ + 'O que já fiz está salvo. Diga "continua" que eu retomo daqui.';
421
431
  }
422
432
  if (voltas >= MAX_ITERATIONS) {
423
433
  const emFalso = voltasEmFalso
424
434
  ? ` ${voltasEmFalso} ${voltasEmFalso === 1 ? 'chamada foi repetida' : 'chamadas foram repetidas'} e não rodaram de novo.`
425
435
  : '';
426
- return `Gastei as ${MAX_ITERATIONS} voltas sem chegar ao fim.${emFalso}`
427
- + ' Peça em partes: primeiro a pesquisa, depois o que criar com ela.';
436
+ return `Usei as ${MAX_ITERATIONS} voltas deste pedido sem chegar ao fim.${emFalso}`
437
+ + ' O que já fiz está salvo. Diga "continua" que eu retomo daqui.';
428
438
  }
429
- return 'Parei sem concluir e sem explicação do modelo. Tente pedir de novo, em partes menores.';
439
+ return 'Parei sem concluir e sem explicação do modelo. Diga "continua" que eu tento de novo daqui.';
430
440
  }
431
441
 
432
442
  /* ── O MAPA DO PROJETO ───────────────────────────────────────────────────
@@ -591,6 +601,7 @@ async function runAct(userPrompt, opts) {
591
601
  // assim que "crie um vídeo para a tostudy" morreu sem entregar nada.
592
602
  let pesquisasSecas = 0;
593
603
  let rodadasParadas = 0;
604
+ let trocouDeRota = false;
594
605
  /* Para o `pareceParadaNoMeio`: se alguma tool já rodou nesta tarefa, e o
595
606
  último plano que o modelo escreveu (write_todos) — desta TAREFA, não da
596
607
  sessão: um plano velho, de um pedido interrompido, não pode empurrar o
@@ -996,6 +1007,7 @@ async function runAct(userPrompt, opts) {
996
1007
  : await runSubagent(tc.arguments || {}, {
997
1008
  server, model, token, projectDir: pastaAtual, projectName, profundidade, motor,
998
1009
  });
1010
+ repeticao.executou(jaChamado, tc.name, tc.arguments);
999
1011
  messages.push({ role: 'tool', tool_call_id: tc.id, content: r });
1000
1012
  continue;
1001
1013
  }
@@ -1027,6 +1039,7 @@ async function runAct(userPrompt, opts) {
1027
1039
  server, model, token, projectDir: pastaAtual, projectName, profundidade, motor, livro, autorizar, controle: portaoControle,
1028
1040
  });
1029
1041
  logResult(tc.name, r, profundidade);
1042
+ repeticao.executou(jaChamado, tc.name, tc.arguments);
1030
1043
  messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(r) });
1031
1044
  if (r && r.ok && catalogo.escreveArquivo(tc.name)) temPasta = true;
1032
1045
  continue;
@@ -1079,6 +1092,7 @@ async function runAct(userPrompt, opts) {
1079
1092
  fala.acao(tc.name, tc.arguments, profundidade);
1080
1093
  const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
1081
1094
  logResult(tc.name, result, profundidade);
1095
+ repeticao.executou(jaChamado, tc.name, tc.arguments);
1082
1096
  if (tc.name === 'write_todos' && result && Array.isArray(result.todos)) todosDaTarefa = result.todos;
1083
1097
  // Além de narrar, ANOTA: no fim do pedido sai o balanço do que
1084
1098
  // nasceu, mudou, rodou e falhou. Narrar sozinho não basta — numa
@@ -1133,6 +1147,16 @@ async function runAct(userPrompt, opts) {
1133
1147
  // terminar no mesmo lugar — melhor parar e devolver a vez ao usuário.
1134
1148
  if (!algoRodou && toolCalls.length) {
1135
1149
  rodadasParadas++;
1150
+ /* Antes de desistir, UMA troca de rota, sem aparecer na tela. Parar
1151
+ aqui devolvia ao usuário o trabalho de dividir a tarefa — que é
1152
+ justamente o que ele não sabe fazer e o sistema sabe: o que já
1153
+ foi feito está na conversa, e o que falta é outro caminho. */
1154
+ if (rodadasParadas >= MAX_RODADAS_PARADAS && !trocouDeRota) {
1155
+ trocouDeRota = true;
1156
+ rodadasParadas = 0;
1157
+ messages.push({ role: 'user', content: TROCA_DE_ROTA });
1158
+ continue;
1159
+ }
1136
1160
  // Quem fala é o motivoDaParada, uma vez só — aqui havia uma
1137
1161
  // segunda frase de "travei", e as duas saíam em sequência.
1138
1162
  if (rodadasParadas >= MAX_RODADAS_PARADAS) break;
package/lib/browser.js CHANGED
@@ -463,6 +463,9 @@ async function ensure(url) {
463
463
  // ---- Ações ------------------------------------------------------
464
464
  async function open(url) {
465
465
  await ensure(url);
466
+ // A janela vem para a frente: quem pediu "abre o youtube" quer VER abrir,
467
+ // não descobrir depois que havia um Chrome atrás do terminal.
468
+ if (verSeta()) { try { await currentSession.send('Page.bringToFront'); } catch { /* segue */ } }
466
469
  // Navega até URL solicitada
467
470
  await currentSession.send('Page.navigate', { url });
468
471
  // Espera "load" simples via polling do document.readyState
@@ -486,8 +489,73 @@ async function evaluate(js) {
486
489
  return r.result ? r.result.value : undefined;
487
490
  }
488
491
 
489
- async function click(x, y) {
492
+ /* ── A SETA DO PRIMO DENTRO DA PÁGINA ────────────────────────────────────
493
+ * "Quando a IA vai mexer no computador, eu não consigo ver ela mexendo."
494
+ *
495
+ * O clique no navegador vai pelo CDP (Input.dispatchMouseEvent): acontece de
496
+ * verdade, mas nada anda na tela — a página só muda, do nada. Medido no
497
+ * pedido "abra o youtube e abra o vídeo mais recente". Então, antes de cada
498
+ * clique e de cada digitação, uma seta desenhada NA página desliza até o
499
+ * alvo com o que ela vai fazer ("clicando em Pesquisar"), e o clique deixa
500
+ * uma onda. É DOM, não o mouse do sistema: funciona em qualquer SO, sem
501
+ * permissão, e não rouba o mouse de quem está usando o computador.
502
+ *
503
+ * PRIMOCODE_SEM_CURSOR=1 (e o navegador sem tela) desliga: nos testes e no
504
+ * servidor ninguém está olhando, e a animação só custaria tempo. */
505
+ const DESLIZE_MS = 450;
506
+ function verSeta() {
507
+ return process.env.PRIMOCODE_SEM_CURSOR !== '1'
508
+ && !(process.env.PRIMOCODE_BROWSER_HEADLESS === '1' || process.env.PRIMOCODE_BROWSER_HEADLESS === 'true');
509
+ }
510
+
511
+ function scriptDaSeta(x, y, rotulo, onda) {
512
+ return `(function(){
513
+ var id = '__primo_seta';
514
+ var s = document.getElementById(id);
515
+ if (!s) {
516
+ s = document.createElement('div');
517
+ s.id = id;
518
+ s.setAttribute('aria-hidden', 'true');
519
+ s.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;'
520
+ + 'transition:transform ${DESLIZE_MS}ms cubic-bezier(.2,.7,.2,1);will-change:transform;'
521
+ + 'transform:translate(' + (innerWidth / 2) + 'px,' + (innerHeight / 2) + 'px)';
522
+ s.innerHTML = '<svg width="26" height="26" viewBox="0 0 24 24" style="display:block;filter:drop-shadow(0 2px 3px rgba(2,6,23,.45))">'
523
+ + '<path d="M3 2 L3 19 L8 14.5 L11.5 22 L14.5 20.6 L11 13.2 L18 13.2 Z" fill="#22d3ee" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg>'
524
+ + '<div data-rotulo style="position:absolute;left:24px;top:18px;white-space:nowrap;font:600 12px/1 system-ui,sans-serif;'
525
+ + 'color:#0f172a;background:#22d3ee;padding:5px 8px;border-radius:6px;box-shadow:0 2px 6px rgba(2,6,23,.3)"></div>';
526
+ document.documentElement.appendChild(s);
527
+ s.getBoundingClientRect();
528
+ }
529
+ var r = s.querySelector('[data-rotulo]');
530
+ r.textContent = ${JSON.stringify(String(rotulo || '').slice(0, 48))};
531
+ r.style.display = r.textContent ? 'block' : 'none';
532
+ s.style.transform = 'translate(${Math.round(x)}px,${Math.round(y)}px)';
533
+ if (${onda ? 'true' : 'false'}) setTimeout(function(){
534
+ var o = document.createElement('div');
535
+ o.style.cssText = 'position:fixed;left:${Math.round(x) - 16}px;top:${Math.round(y) - 16}px;width:32px;height:32px;'
536
+ + 'border-radius:50%;border:3px solid #22d3ee;z-index:2147483646;pointer-events:none;'
537
+ + 'transition:transform .5s ease-out,opacity .5s ease-out;transform:scale(.3);opacity:1';
538
+ document.documentElement.appendChild(o);
539
+ o.getBoundingClientRect();
540
+ o.style.transform = 'scale(1.6)'; o.style.opacity = '0';
541
+ setTimeout(function(){ o.remove(); }, 600);
542
+ }, ${DESLIZE_MS});
543
+ return true;
544
+ })()`;
545
+ }
546
+
547
+ /** Leva a seta até (x, y) e espera ela chegar. Nunca falha a ação por causa dela. */
548
+ async function mostrarSeta(x, y, rotulo = '', { onda = true } = {}) {
549
+ if (!verSeta() || !currentSession) return;
550
+ try {
551
+ await currentSession.send('Runtime.evaluate', { expression: scriptDaSeta(x, y, rotulo, onda) });
552
+ await sleep(DESLIZE_MS + 60);
553
+ } catch { /* a seta é para quem olha; a ação segue sem ela */ }
554
+ }
555
+
556
+ async function click(x, y, rotulo = 'clicando') {
490
557
  await ensure();
558
+ await mostrarSeta(x, y, rotulo);
491
559
  await currentSession.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
492
560
  await currentSession.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
493
561
  await currentSession.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
@@ -507,7 +575,8 @@ async function clickText(text) {
507
575
  if (r.width && r.height) {
508
576
  el.scrollIntoView({block:'center'});
509
577
  const r2 = el.getBoundingClientRect();
510
- return { x: r2.left + r2.width/2, y: r2.top + r2.height/2, tag: el.tagName, text: s.slice(0,80) };
578
+ const nome = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ');
579
+ return { x: r2.left + r2.width/2, y: r2.top + r2.height/2, tag: el.tagName, text: s.slice(0,80), nome: nome.slice(0,30) };
511
580
  }
512
581
  }
513
582
  }
@@ -516,7 +585,7 @@ async function clickText(text) {
516
585
  const found = await evaluate(js);
517
586
  if (!found) throw new Error(`nenhum elemento clicável encontrado com texto: ${text}`);
518
587
  await sleep(200);
519
- await click(found.x, found.y);
588
+ await click(found.x, found.y, `clicando em ${found.nome || found.text}`.slice(0, 44));
520
589
  return { ok: true, ...found };
521
590
  }
522
591
 
@@ -595,13 +664,14 @@ async function clickSelector(selector) {
595
664
  if (!el) return null;
596
665
  el.scrollIntoView({block:'center'});
597
666
  const r = el.getBoundingClientRect();
598
- return { x: r.left + r.width/2, y: r.top + r.height/2, tag: el.tagName };
667
+ const nome = (el.getAttribute('aria-label') || el.innerText || el.value || el.placeholder || '').trim().replace(/\s+/g, ' ').slice(0, 30);
668
+ return { x: r.left + r.width/2, y: r.top + r.height/2, tag: el.tagName, nome };
599
669
  })()`;
600
670
  let found = await evaluate(js);
601
671
  if (!found && await esperarSeletor(selector)) found = await evaluate(js);
602
672
  if (!found) throw await erroDeSeletor(selector);
603
673
  await sleep(200);
604
- await click(found.x, found.y);
674
+ await click(found.x, found.y, found.nome ? `clicando em ${found.nome}`.slice(0, 44) : 'clicando');
605
675
  return { ok: true, selector, ...found };
606
676
  }
607
677
 
@@ -619,13 +689,16 @@ async function typeInSelector(selector, text, clear = true) {
619
689
  const focar = () => evaluate(`(function(){
620
690
  const el = document.querySelector(${JSON.stringify(selector)});
621
691
  if (!el) return false;
692
+ el.scrollIntoView({block:'center'});
622
693
  el.focus();
623
694
  ${clear ? "if ('value' in el) el.value = '';" : ''}
624
- return true;
695
+ const r = el.getBoundingClientRect();
696
+ return { x: r.left + Math.min(24, r.width / 2), y: r.top + r.height / 2 };
625
697
  })()`);
626
698
  let focused = await focar();
627
699
  if (!focused && await esperarSeletor(selector)) focused = await focar();
628
700
  if (!focused) throw await erroDeSeletor(selector);
701
+ await mostrarSeta(focused.x, focused.y, `digitando: ${text}`.slice(0, 44), { onda: false });
629
702
  await type(text);
630
703
  // dispara evento input para frameworks reativos
631
704
  await evaluate(`(function(){
@@ -1043,6 +1116,7 @@ function fecharRevisor() {
1043
1116
  }
1044
1117
 
1045
1118
  module.exports = {
1119
+ scriptDaSeta, verSeta,
1046
1120
  open, click, clickText, clickSelector, type, typeInSelector,
1047
1121
  key, screenshot, evaluate, waitFor, wait, scroll, close,
1048
1122
  inspecionarEscondido, rodarEscondido, abrirEscondido, fecharRevisor,
@@ -0,0 +1,97 @@
1
+ /**
2
+ * nome-projeto.js — quem dá nome à pasta é quem entendeu o pedido.
3
+ *
4
+ * "Quero que ele dê nome às pastas que criar. Ele escolhe o nome, ele
5
+ * entendeu o contexto, ele analisa os outros projetos." (23/09/2026)
6
+ *
7
+ * O nome saía de `deriveProjectName`: cortar as palavras de abertura da frase
8
+ * e ficar com até quatro. Funciona para "crie um jogo de carro", e erra tudo
9
+ * que não começa assim — "aquela ideia do cardápio que falei, bora fazer" vira
10
+ * `aquela-ideia-do-cardapio`. Quem sabe o que está sendo feito é o modelo.
11
+ *
12
+ * ── COMO ─────────────────────────────────────────────────────────────────
13
+ * Uma pergunta curta ao modelo, com o pedido e os nomes das pastas que já
14
+ * existem, para ele seguir o mesmo jeito (idioma, tamanho, prefixo). Ela
15
+ * roda em PARALELO com o trabalho: a pasta só nasce na primeira escrita, e
16
+ * até lá a resposta já chegou. Se não chegou, ou veio lixo, vale o nome de
17
+ * antes — nome pior é melhor que pasta nenhuma.
18
+ *
19
+ * Puro, menos `escolher`, que recebe quem pergunta de fora (teste sem rede).
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const { slugify } = require('./config.js');
25
+
26
+ // Nome que não diz nada: o modelo devolveu isso, é como não ter respondido.
27
+ const GENERICOS = new Set(['projeto', 'project', 'app', 'site', 'novo', 'teste', 'test', 'meu-projeto', 'my-project']);
28
+ const TEMPO_MAXIMO_MS = 8000;
29
+
30
+ /** O jeito das pastas que a pessoa já tem, para o modelo seguir. */
31
+ function convencao(nomes) {
32
+ const lista = (nomes || []).filter((n) => n && !n.startsWith('.'));
33
+ if (!lista.length) return null;
34
+ const hifen = lista.filter((n) => n.includes('-')).length;
35
+ const sublinhado = lista.filter((n) => n.includes('_')).length;
36
+ const separador = sublinhado > hifen ? '_' : '-';
37
+ const palavras = lista.map((n) => n.split(/[-_]/).length);
38
+ const media = Math.round(palavras.reduce((a, b) => a + b, 0) / palavras.length);
39
+ return { separador, palavras: Math.min(4, Math.max(1, media)), exemplos: lista.slice(0, 12) };
40
+ }
41
+
42
+ /** A pergunta. Curta: vai numa chamada à parte, e cada token conta. */
43
+ function pergunta(pedido, nomes) {
44
+ const c = convencao(nomes);
45
+ const jeito = c
46
+ ? `Pastas que a pessoa já tem: ${c.exemplos.join(', ')}. Siga o mesmo jeito (idioma, tamanho, separador "${c.separador}"), `
47
+ + 'e NÃO repita um nome que já existe.'
48
+ : 'Use kebab-case em português, de 1 a 3 palavras.';
49
+ return 'Dê um nome curto para a pasta do projeto deste pedido. O nome diz O QUE é '
50
+ + '(ex.: site-padaria, jogo-de-carro, loja-roupas), nunca o que a pessoa pediu para fazer. '
51
+ + `${jeito} Responda SÓ com o nome, sem aspas e sem explicação.\n\nPEDIDO: ${String(pedido || '').slice(0, 600)}`;
52
+ }
53
+
54
+ /** Limpa a resposta do modelo. Devolve null quando não dá para confiar nela. */
55
+ function limpar(resposta, nomes) {
56
+ const primeira = String(resposta || '').trim().split('\n')[0]
57
+ .replace(/^[`"'*\s]+|[`"'*.\s]+$/g, '')
58
+ .replace(/^(nome|pasta)\s*:\s*/i, '');
59
+ if (!primeira || primeira.length > 60 || primeira.split(/\s+/).length > 5) return null;
60
+ const c = convencao(nomes);
61
+ let nome = slugify(primeira).split('-').filter(Boolean).slice(0, 4).join('-');
62
+ if (!nome || GENERICOS.has(nome)) return null;
63
+ if (c && c.separador === '_') nome = nome.replace(/-/g, '_');
64
+ return semColisao(nome, nomes);
65
+ }
66
+
67
+ /** Nome que já existe ganha -2, -3… — escrever na pasta de outro projeto é pior que um sufixo. */
68
+ function semColisao(nome, nomes) {
69
+ const tomados = new Set((nomes || []).map((n) => String(n).toLowerCase()));
70
+ if (!tomados.has(nome)) return nome;
71
+ for (let i = 2; i < 100; i++) {
72
+ const outro = `${nome}-${i}`;
73
+ if (!tomados.has(outro)) return outro;
74
+ }
75
+ return `${nome}-${Date.now()}`;
76
+ }
77
+
78
+ /**
79
+ * Pergunta o nome ao modelo. Nunca rejeita: sem resposta boa a tempo,
80
+ * devolve null e quem chamou fica com o nome de antes.
81
+ *
82
+ * `perguntar(texto)` → Promise<string> é quem fala com o servidor.
83
+ */
84
+ async function escolher({ pedido, nomes, perguntar, tempo = TEMPO_MAXIMO_MS }) {
85
+ let relogio;
86
+ const estourou = new Promise((r) => { relogio = setTimeout(() => r(null), tempo); });
87
+ try {
88
+ const resposta = await Promise.race([perguntar(pergunta(pedido, nomes)), estourou]);
89
+ return resposta == null ? null : limpar(resposta, nomes);
90
+ } catch {
91
+ return null;
92
+ } finally {
93
+ clearTimeout(relogio);
94
+ }
95
+ }
96
+
97
+ module.exports = { convencao, pergunta, limpar, semColisao, escolher, GENERICOS };
package/lib/repeticao.js CHANGED
@@ -83,8 +83,61 @@ function consultaDe(nome, args) {
83
83
  return String(a.consulta || a.query || '');
84
84
  }
85
85
 
86
+ /* ── A ÉPOCA: repetir depois de mudar algo não é repetir ────────────────
87
+ * Medido: "edita o arquivo → roda npm test → edita → roda npm test". O
88
+ * segundo `npm test` tinha os mesmos argumentos do primeiro e era barrado —
89
+ * o agente nunca via se o conserto funcionou, ficava três voltas sem rodar
90
+ * nada e morria com "peça em partes". O mesmo com reler um arquivo depois de
91
+ * editá-lo, ou tirar outra foto da tela depois de clicar.
92
+ *
93
+ * Então olhar o mundo (ler, listar, rodar teste, fotografar a tela) vale por
94
+ * ÉPOCA: cada mudança de verdade (escrever, editar, clicar, rodar um comando
95
+ * que não é só de leitura) abre uma época nova, e a mesma leitura volta a
96
+ * valer. O laço de verdade — a mesma chamada sem nada mudar no meio — segue
97
+ * barrado. Pesquisa na web e documento de referência não dependem do disco:
98
+ * ficam fora da época. */
99
+ const OBSERVAM = new Set(['read_file', 'list_dir', 'glob', 'grep', 'browser_read', 'browser_screenshot',
100
+ 'desktop_screenshot', 'desktop_size', 'desktop_windows', 'desktop_elements']);
101
+ const NAO_MUDAM = new Set(['write_todos', 'remember_general', 'pesquisar_referencia', ...REFERENCIAS]);
102
+ const SHELL_SO_LEITURA = /^\s*(ls|cat|head|tail|pwd|echo|grep|rg|find|wc|tree|stat|file|which|type|du|df|env|printenv|date|git\s+(status|log|diff|show|branch|remote))\b[^;&|>]*$/;
103
+
104
+ function comandoDe(args) {
105
+ return String((args && (args.command || args.comando)) || '');
106
+ }
107
+
108
+ /** Só olha: ler de novo depois de uma mudança é trabalho, não laço. */
109
+ function observa(nome, args) {
110
+ if (OBSERVAM.has(nome)) return true;
111
+ return nome === 'run_shell' && SHELL_SO_LEITURA.test(comandoDe(args));
112
+ }
113
+
114
+ /** Muda o mundo: abre uma época nova. */
115
+ function muda(nome, args) {
116
+ return !observa(nome, args) && !NAO_MUDAM.has(nome);
117
+ }
118
+
86
119
  function novo() {
87
- return { vistos: new Map(), buscas: [] };
120
+ return { vistos: new Map(), buscas: [], epoca: 0, epocaDaMudanca: 0, ultimaMudanca: null, olhouDesde: false };
121
+ }
122
+
123
+ /**
124
+ * Avisa que a chamada RODOU. Quem chama faz isto depois de executar (e não
125
+ * de barrar): é o que abre a época nova.
126
+ *
127
+ * A mesma mudança repetida sem olhar nada no meio (escrever o mesmo conteúdo
128
+ * no mesmo arquivo, clicar no mesmo ponto às cegas) não abre época e é
129
+ * barrada já na segunda vez — senão o laço de escrita passaria livre.
130
+ */
131
+ function executou(registro, nome, args) {
132
+ if (observa(nome, args)) { registro.olhouDesde = true; return; }
133
+ if (!muda(nome, args)) return;
134
+ const chave = assinatura(nome, args);
135
+ if (chave !== registro.ultimaMudanca || registro.olhouDesde) {
136
+ registro.epocaDaMudanca = registro.epoca;
137
+ registro.epoca++;
138
+ }
139
+ registro.ultimaMudanca = chave;
140
+ registro.olhouDesde = false;
88
141
  }
89
142
 
90
143
  /**
@@ -94,8 +147,14 @@ function novo() {
94
147
  * NÃO deve executar a tool: manda o `aviso` de volta como resultado.
95
148
  */
96
149
  function ver(registro, nome, args) {
97
- const chave = assinatura(nome, args);
98
150
  const busca = consultaDe(nome, args);
151
+ const foraDaEpoca = busca !== null || REFERENCIAS.has(nome);
152
+ const base = assinatura(nome, args);
153
+ // A mesma mudança logo em seguida, sem olhar nada no meio, conta na época
154
+ // em que a primeira rodou — senão ela mesma abriria a porta da segunda.
155
+ const epoca = (base === registro.ultimaMudanca && !registro.olhouDesde)
156
+ ? registro.epocaDaMudanca : (registro.epoca || 0);
157
+ const chave = base + (foraDaEpoca ? '' : `@${epoca}`);
99
158
 
100
159
  let vezes = (registro.vistos.get(chave) || 0) + 1;
101
160
  let parecidaCom = null;
@@ -123,14 +182,15 @@ function ver(registro, nome, args) {
123
182
  function aviso(nome, vezes, parecidaCom) {
124
183
  const oQue = parecidaCom
125
184
  ? `Você já pesquisou isto: "${parecidaCom}". A busca de agora é a mesma coisa com outras palavras, e traria as mesmas fontes.`
126
- : `Você já chamou ${nome} com estes mesmos argumentos.`;
185
+ : `Você já chamou ${nome} com estes mesmos argumentos, e nada mudou desde então.`;
127
186
 
128
187
  if (vezes === 2) {
129
188
  return `${oQue} O resultado está na conversa acima — role para cima e use o que já tem. `
130
189
  + 'Não chame esta tool de novo com os mesmos argumentos.';
131
190
  }
132
191
  return `${oQue} Esta é a ${vezes}ª vez, e a tool NÃO foi executada. PARE de repetir: `
133
- + 'com o que você já tem, produza AGORA o resultado que o usuário pediu (crie o arquivo, o documento, o vídeo). '
192
+ + 'com o que você já tem, produza AGORA o resultado que o usuário pediu (crie o arquivo, o documento, o vídeo), '
193
+ + 'ou tente OUTRO caminho (outra ferramenta, outro arquivo, outro comando). '
134
194
  + 'Se algo realmente impede, chame finish e diga ao usuário o que faltou.';
135
195
  }
136
196
 
@@ -187,5 +247,5 @@ function referenciasNaConversa(messages) {
187
247
  module.exports = {
188
248
  REFERENCIAS, LIMITE_SEMELHANCA,
189
249
  normalizar, palavras, semelhanca, assinatura, consultaDe,
190
- novo, ver, chamadasPorId, referenciasNaConversa,
250
+ novo, ver, executou, observa, muda, chamadasPorId, referenciasNaConversa,
191
251
  };
package/lib/tools.js CHANGED
@@ -22,10 +22,24 @@ const cursor = require('./cursor');
22
22
  * ação acontece de verdade, e a pessoa VÊ a seta dele indo até lá, clicando,
23
23
  * digitando. Sem o cursor (outro sistema, ou o Python não subiu), vale o
24
24
  * caminho antigo do desktop.js. */
25
+ /* Sem o cursor, a ação vai pelo caminho antigo, que NÃO aparece na tela. Isso
26
+ era calado: a pessoa via o terminal dizer "ok" e nada se mexer. Agora sai
27
+ uma linha, uma vez por sessão, dizendo por quê — e o modelo recebe o
28
+ mesmo aviso junto do resultado, para contar em vez de fingir. */
29
+ let avisouSemCursor = false;
30
+ function semCursorVisivel(resultado, motivo) {
31
+ const aviso = `o cursor do Primo não abriu (${motivo || 'motivo desconhecido'}): a ação roda, mas não aparece na tela`;
32
+ if (!avisouSemCursor) {
33
+ avisouSemCursor = true;
34
+ try { console.log(require('./ui.js').c.warn(` ⎿ ${aviso}`)); } catch { /* sem tela */ }
35
+ }
36
+ return resultado && typeof resultado === 'object' ? { ...resultado, avisoCursor: aviso } : resultado;
37
+ }
38
+
25
39
  async function peloCursor(cmd, args, reserva) {
26
40
  if (!cursor.disponivel()) return reserva();
27
41
  const r = await cursor.mandar(cmd, args);
28
- if (r.semCursor) return reserva();
42
+ if (r.semCursor) return semCursorVisivel(await reserva(), r.error);
29
43
  if (!r.ok) return { ok: false, error: r.error || 'o cursor não conseguiu', ...(r.permissao === false ? { permissao: false } : {}) };
30
44
  return { ok: true };
31
45
  }
package/lib/voz.js CHANGED
@@ -62,6 +62,12 @@ ${DICA_COMPUTADOR}
62
62
  Faça o trabalho inteiro normalmente, com as ferramentas de sempre. O que muda
63
63
  é só o jeito de contar no fim.`;
64
64
 
65
+ /* A forma da resposta de CONVERSA por voz ("oi", "tudo bem?", "o que você
66
+ faz?"). Sem ferramenta e sem memória de tarefa: um "oi" ia para o agente
67
+ inteiro, com a lista das tarefas antigas e a ordem de mexer no cursor, e
68
+ ele respondia falando delas — "eu mandei oi e ele respondeu nada a ver". */
69
+ const COMO_CONVERSAR = `[MODO VOZ] A sua resposta vai ser LIDA EM VOZ ALTA. Responda ao que a pessoa acabou de dizer, e só a isso, em uma ou duas frases curtas, em português falado, sem markdown. Não puxe assunto de tarefa antiga. Se for só um cumprimento, cumprimente de volta e pergunte o que ela quer fazer.`;
70
+
65
71
  /** Onde está o Python 3. Mesmo problema do Estúdio, mesma cura. */
66
72
  function python() {
67
73
  const nomes = process.platform === 'win32'
@@ -575,4 +581,4 @@ async function preparar(aoFalar = () => {}) {
575
581
  return { ok: true, python: py, esperando, avisos };
576
582
  }
577
583
 
578
- module.exports = { DICA_COMPUTADOR, conferir, instalar, preparar, porqueOPipFalhou, abrir, conferirEspera, baixarEspera, python, COMO_FALAR, PASTA };
584
+ module.exports = { DICA_COMPUTADOR, COMO_CONVERSAR, conferir, instalar, preparar, porqueOPipFalhou, abrir, conferirEspera, baixarEspera, python, COMO_FALAR, PASTA };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.7.0",
3
+ "version": "9.8.0-beta.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). 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": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/estudio.test.js && node test/voz.test.js && node test/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/autonomia.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -243,8 +243,10 @@ class Voz:
243
243
  continue
244
244
 
245
245
  self.orb.mudar(estado="pensando")
246
- texto = self._transcrever(wav)
247
- if not texto or len(texto.strip()) < 2:
246
+ # O servidor também transcreve com Whisper: a mesma limpeza vale.
247
+ texto = transcrever.limpar(self._transcrever(wav))
248
+ if not texto:
249
+ self.orb.mudar(estado="esperando")
248
250
  continue
249
251
 
250
252
  falar_para_o_node({"tipo": "pedido", "texto": texto})
@@ -74,13 +74,49 @@ def aquecer() -> None:
74
74
  threading.Thread(target=_ir, daemon=True).start()
75
75
 
76
76
 
77
+ # ── O QUE O WHISPER INVENTA ─────────────────────────────────────────────
78
+ # Treinado em legenda de vídeo, ele "ouve" no ruído o fim de um vídeo do
79
+ # YouTube. Essas frases chegavam ao agente como pedido, e ele respondia a
80
+ # elas: "eu mandei oi e ele respondeu nada a ver" (23/09/2026).
81
+ ALUCINACOES = (
82
+ "obrigado por assistir", "obrigada por assistir", "obrigado pela atenção",
83
+ "legendas pela comunidade", "amara.org", "inscreva-se", "se inscreva",
84
+ "deixe seu like", "ative o sininho", "até o próximo vídeo", "até a próxima",
85
+ "legendado por", "legenda por", "tradução e legendas", "transcrição por",
86
+ "www.", ".com.br",
87
+ )
88
+ # Acima disto o próprio Whisper acha que não havia fala no trecho.
89
+ SEM_FALA = 0.6
90
+ # Abaixo disto a confiança média é baixa demais para virar pedido.
91
+ CONFIANCA_MINIMA = -1.0
92
+
93
+
94
+ def limpar(texto: str) -> str:
95
+ """Tira a frase inventada. Devolve "" quando não sobra pedido."""
96
+ t = " ".join(str(texto or "").split())
97
+ baixo = t.lower()
98
+ if any(frase in baixo for frase in ALUCINACOES):
99
+ return ""
100
+ # Só pontuação ou uma letra solta não é pedido.
101
+ if len("".join(ch for ch in t if ch.isalnum())) < 2:
102
+ return ""
103
+ return t
104
+
105
+
106
+ def aproveitavel(segmento) -> bool:
107
+ """O segmento tem fala de verdade, pelo que o próprio Whisper mediu?"""
108
+ sem_fala = getattr(segmento, "no_speech_prob", 0.0) or 0.0
109
+ confianca = getattr(segmento, "avg_logprob", 0.0) or 0.0
110
+ return not (sem_fala > SEM_FALA and confianca < CONFIANCA_MINIMA)
111
+
112
+
77
113
  def transcrever(wav: Path, idioma: str = "pt") -> str:
78
114
  modelo = carregar()
79
115
  # vad_filter corta o silêncio das pontas: sem ele, o Whisper às vezes
80
116
  # "ouve" uma frase inteira inventada no silêncio ("Obrigado por assistir").
81
117
  segmentos, _ = modelo.transcribe(str(wav), language=idioma, beam_size=5,
82
118
  vad_filter=True, condition_on_previous_text=False)
83
- return " ".join(s.text.strip() for s in segmentos).strip()
119
+ return limpar(" ".join(s.text.strip() for s in segmentos if aproveitavel(s)))
84
120
 
85
121
 
86
122
  def main() -> int: