primocode 9.7.0 → 9.8.0-beta.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.
@@ -0,0 +1,151 @@
1
+ /**
2
+ * provedor.js — a chave da própria pessoa, e com qual IA ela fala.
3
+ *
4
+ * "Fica batendo no limite. Coloca um comando /provider onde você seleciona
5
+ * se é Gemini, ChatGPT etc., cola a key e ela roda local com a key
6
+ * configurada." (23/09/2026)
7
+ *
8
+ * Com um provedor configurado (/api), o agente fala DIRETO com a API dele, daqui da
9
+ * máquina (lib/local/motor.js). O servidor e a cota compartilhada saem do
10
+ * caminho: o limite passa a ser o da conta da pessoa.
11
+ *
12
+ * ── ONDE A CHAVE MORA ────────────────────────────────────────────────────
13
+ * Em `~/.primocode/provedor.json`, permissão 600. Não no config.json: esse é
14
+ * o arquivo que alguém abre para conferir um ajuste ou cola num chamado.
15
+ *
16
+ * ── O QUE NUNCA APARECE ──────────────────────────────────────────────────
17
+ * A chave inteira — nem ao listar, nem num erro. Mostra-se o começo e o fim,
18
+ * o bastante para reconhecer qual é.
19
+ *
20
+ * Todos falam o formato de chat da OpenAI (/chat/completions, tools, stream):
21
+ * é o que Gemini, Claude, Groq, OpenRouter e DeepSeek aceitam também. Um
22
+ * cliente só, e o provedor é só o endereço, o modelo e a chave.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const fs = require('fs');
28
+ const os = require('os');
29
+ const path = require('path');
30
+
31
+ const ARQUIVO = () => path.join(os.homedir(), '.primocode', 'provedor.json');
32
+
33
+ /* A ordem é a do menu. `modelos` é a preferência: ao colar a chave, fica o
34
+ primeiro que a conta da pessoa de fato tem (a lista vem da própria API). */
35
+ const PROVEDORES = {
36
+ gemini: {
37
+ nome: 'Gemini (Google)',
38
+ base: 'https://generativelanguage.googleapis.com/v1beta/openai',
39
+ modelos: ['gemini-2.5-flash', 'gemini-2.5-pro', 'gemini-2.0-flash'],
40
+ onde: 'https://aistudio.google.com/apikey',
41
+ dica: 'tem plano grátis',
42
+ },
43
+ chatgpt: {
44
+ nome: 'ChatGPT (OpenAI)',
45
+ base: 'https://api.openai.com/v1',
46
+ modelos: ['gpt-5-mini', 'gpt-5', 'gpt-4.1-mini', 'gpt-4o-mini'],
47
+ onde: 'https://platform.openai.com/api-keys',
48
+ dica: 'pago por uso',
49
+ },
50
+ claude: {
51
+ nome: 'Claude (Anthropic)',
52
+ base: 'https://api.anthropic.com/v1',
53
+ modelos: ['claude-sonnet-5', 'claude-haiku-4-5-20251001', 'claude-opus-5-5'],
54
+ onde: 'https://console.anthropic.com/settings/keys',
55
+ dica: 'pago por uso',
56
+ },
57
+ groq: {
58
+ nome: 'Groq',
59
+ base: 'https://api.groq.com/openai/v1',
60
+ modelos: ['openai/gpt-oss-120b', 'llama-3.3-70b-versatile', 'openai/gpt-oss-20b'],
61
+ onde: 'https://console.groq.com/keys',
62
+ dica: 'tem plano grátis, muito rápido',
63
+ },
64
+ openrouter: {
65
+ nome: 'OpenRouter',
66
+ base: 'https://openrouter.ai/api/v1',
67
+ modelos: ['openai/gpt-oss-120b', 'google/gemini-2.5-flash', 'anthropic/claude-sonnet-5'],
68
+ onde: 'https://openrouter.ai/keys',
69
+ dica: 'vários modelos numa chave só',
70
+ },
71
+ deepseek: {
72
+ nome: 'DeepSeek',
73
+ base: 'https://api.deepseek.com/v1',
74
+ modelos: ['deepseek-chat'],
75
+ onde: 'https://platform.deepseek.com/api_keys',
76
+ dica: 'barato',
77
+ },
78
+ };
79
+
80
+ /** A chave, irreconhecível para quem olha por cima do ombro. */
81
+ function mascarar(chave) {
82
+ const k = String(chave || '');
83
+ if (k.length <= 10) return '••••';
84
+ return `${k.slice(0, 4)}…${k.slice(-4)}`;
85
+ }
86
+
87
+ let cache = null;
88
+ let cacheMtime = 0;
89
+
90
+ /** O provedor configurado, ou null (= segue pelo servidor, como sempre). */
91
+ function atual() {
92
+ if (process.env.PRIMOCODE_SEM_PROVEDOR === '1') return null;
93
+ let st;
94
+ try { st = fs.statSync(ARQUIVO()); } catch { cache = null; return null; }
95
+ if (cache && st.mtimeMs === cacheMtime) return cache;
96
+ try {
97
+ const j = JSON.parse(fs.readFileSync(ARQUIVO(), 'utf8'));
98
+ const p = PROVEDORES[j.provedor];
99
+ cache = p && j.chave ? { id: j.provedor, nome: p.nome, base: j.base || p.base, modelo: j.modelo || p.modelos[0], chave: j.chave } : null;
100
+ } catch { cache = null; }
101
+ cacheMtime = st.mtimeMs;
102
+ return cache;
103
+ }
104
+
105
+ function salvar({ provedor, chave, modelo }) {
106
+ if (!PROVEDORES[provedor]) throw new Error(`provedor desconhecido: ${provedor}`);
107
+ const arq = ARQUIVO();
108
+ fs.mkdirSync(path.dirname(arq), { recursive: true });
109
+ fs.writeFileSync(arq, JSON.stringify({ provedor, chave: String(chave).trim(), modelo }, null, 2), { mode: 0o600 });
110
+ try { fs.chmodSync(arq, 0o600); } catch { /* Windows */ }
111
+ cache = null;
112
+ return atual();
113
+ }
114
+
115
+ function trocarModelo(modelo) {
116
+ const a = atual();
117
+ if (!a) return null;
118
+ return salvar({ provedor: a.id, chave: a.chave, modelo });
119
+ }
120
+
121
+ function desligar() {
122
+ try { fs.unlinkSync(ARQUIVO()); } catch { /* já não havia */ }
123
+ cache = null;
124
+ }
125
+
126
+ /** A chave colada serve? Pergunta a lista de modelos à própria API. */
127
+ async function conferir(provedor, chave, { buscar = fetch } = {}) {
128
+ const p = PROVEDORES[provedor];
129
+ let r;
130
+ try {
131
+ r = await buscar(`${p.base}/models`, {
132
+ headers: { Authorization: `Bearer ${String(chave).trim()}` },
133
+ signal: AbortSignal.timeout(15000),
134
+ });
135
+ } catch (e) {
136
+ return { ok: false, error: `não consegui falar com ${p.nome}: ${e.message}` };
137
+ }
138
+ if (r.status === 401 || r.status === 403) return { ok: false, error: `${p.nome} recusou a chave (${r.status}). Confira se copiou inteira.` };
139
+ if (!r.ok) return { ok: false, error: `${p.nome} respondeu ${r.status} ao conferir a chave.` };
140
+ let lista = [];
141
+ try {
142
+ const j = await r.json();
143
+ lista = (j.data || j.models || []).map((m) => String(m.id || m.name || '').replace(/^models\//, '')).filter(Boolean);
144
+ } catch { /* sem lista: fica a preferência */ }
145
+ // Nenhum da preferência na lista (ou lista vazia): fica o primeiro dela, e
146
+ // `/api modelo <nome>` troca.
147
+ const modelo = p.modelos.find((m) => lista.includes(m)) || p.modelos[0];
148
+ return { ok: true, modelo, disponiveis: lista };
149
+ }
150
+
151
+ module.exports = { PROVEDORES, ARQUIVO, mascarar, atual, salvar, trocarModelo, desligar, conferir };
@@ -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.1",
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/api-propria.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: