primocode 8.4.0 → 8.5.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
@@ -538,7 +538,11 @@ async function sendPrompt(userText) {
538
538
  // conversa inteira.
539
539
  await comprimirConversa();
540
540
 
541
- const r = await runAct(prompt, {
541
+ // O pedido passa por uma cabeça antes de virar trabalho: conserta o
542
+ // que está torto, diz qual é o entregável e o que conta como pronto.
543
+ const prompt2 = await refinarPedido(prompt);
544
+
545
+ const r = await runAct(prompt2, {
542
546
  server: state.config.server,
543
547
  model: state.config.model,
544
548
  effort: state.config.effort,
@@ -564,6 +568,39 @@ async function sendPrompt(userText) {
564
568
  refreshPrompt();
565
569
  }
566
570
 
571
+ /**
572
+ * Estrutura o pedido antes de mandar o agente executar.
573
+ *
574
+ * Gente pede curto e torto — "cria um vidro anunciando o founders ai". Sem
575
+ * isto o agente adivinha, e adivinha errado: sai o genérico, ou ele pesquisa
576
+ * em círculo porque não sabe o que é "pronto". Uma passada barata antes de
577
+ * começar vira ordem de serviço: o que entregar, com que fatos, quando parar.
578
+ *
579
+ * Mostra o que mudou — o usuário precisa poder discordar do que foi entendido,
580
+ * não descobrir no fim que o agente fez outra coisa.
581
+ *
582
+ * Nunca bloqueia: se o refino falhar ou vier estranho, vale o pedido original.
583
+ */
584
+ async function refinarPedido(cru) {
585
+ if (!cru || cru.trim().length < 12) return cru;
586
+ const sp = new Spinner('entendendo o pedido').start();
587
+ try {
588
+ const r = await api.requestRefino(state.config.server, {
589
+ prompt: cru, model: state.config.model, token: state.config.token,
590
+ });
591
+ sp.stop();
592
+ if (!r || !r.refinado || !r.prompt) return cru;
593
+ console.log(c.dim(' ⎿ entendi assim:'));
594
+ for (const linha of String(r.prompt).split('\n')) {
595
+ if (linha.trim()) console.log(c.dim(' ' + linha.trim()));
596
+ }
597
+ return r.prompt;
598
+ } catch {
599
+ sp.stop(); // refinar é melhoria, não requisito
600
+ return cru;
601
+ }
602
+ }
603
+
567
604
  /**
568
605
  * Atualiza o resumo da conversa, se já compensar.
569
606
  *
@@ -585,7 +622,12 @@ async function comprimirConversa() {
585
622
  }
586
623
  }
587
624
 
625
+ // O chat também precisa parar no Esc Esc. Sem isto o texto continuava saindo
626
+ // na tela depois de o usuário ter mandado parar.
627
+ let chatEmVoo = null;
628
+
588
629
  async function runChat(promptForModel, userTextForMemory) {
630
+ chatEmVoo = new AbortController();
589
631
  const sp = new Spinner('pensando').start();
590
632
  let started = false;
591
633
  let text = '';
@@ -595,6 +637,7 @@ async function runChat(promptForModel, userTextForMemory) {
595
637
  history: memoria.paraEnviar(state.memoria, state.history),
596
638
  model: state.config.model,
597
639
  token: state.config.token,
640
+ sinal: chatEmVoo.signal,
598
641
  }, (evt) => {
599
642
  if (evt.erro) { if (!started) sp.stop(); console.log('\n' + c.err(ui.friendlyError(evt.erro))); return; }
600
643
  if (evt.delta) {
@@ -1066,6 +1109,7 @@ async function main() {
1066
1109
  if (key.sequence === '\x1b\x1b' || agora - ultimoEsc < 2000) {
1067
1110
  ultimoEsc = 0;
1068
1111
  interromper();
1112
+ if (chatEmVoo) { try { chatEmVoo.abort(); } catch {} }
1069
1113
  process.stdout.write('\n' + c.warn(' ⏹ parando o agente…') + '\n');
1070
1114
  } else {
1071
1115
  ultimoEsc = agora;
package/lib/act.js CHANGED
@@ -15,8 +15,11 @@ const tools = require('./tools');
15
15
  const api = require('./api');
16
16
  const cfg = require('./config');
17
17
  const catalogo = require('./catalogo');
18
+ const repeticao = require('./repeticao');
18
19
 
19
20
  const MAX_ITERATIONS = 15;
21
+ // Voltas seguidas sem executar nada antes de desistir.
22
+ const MAX_RODADAS_PARADAS = 3;
20
23
  const MAX_PROFUNDIDADE = 2; // principal + 1 nível de subagente
21
24
 
22
25
  // ── Interrupção (Esc Esc) ─────────────────────────────────────────────────
@@ -25,7 +28,14 @@ const MAX_PROFUNDIDADE = 2; // principal + 1 nível de subagente
25
28
  // fez, ficou feito). A flag é do módulo de propósito: parar o principal para
26
29
  // os subagentes junto.
27
30
  let PARAR = false;
28
- function interromper() { PARAR = true; }
31
+ // O pedido que está em voo agora. Sem isto o Esc Esc só valia entre uma
32
+ // rodada e outra: o modelo seguia escrevendo do outro lado e o usuário ficava
33
+ // vendo o spinner girar depois de ter mandado parar.
34
+ let emVoo = null;
35
+ function interromper() {
36
+ PARAR = true;
37
+ if (emVoo) { try { emVoo.abort(); } catch {} }
38
+ }
29
39
 
30
40
  function ident(profundidade) {
31
41
  return ' '.repeat(profundidade);
@@ -164,6 +174,13 @@ function summarizeForModel(result) {
164
174
  // que o modelo precisa para decidir o próximo passo; os velhos viram peso.
165
175
  const RESULTADOS_INTEIROS = 3;
166
176
  const RESTO_DO_RESULTADO = 300;
177
+ // A pesquisa é MATÉRIA-PRIMA, não passo intermediário: é dela que saem os
178
+ // fatos do vídeo, do post, do slide. Ela sempre fica velha (pesquisa-se no
179
+ // começo, escreve-se no fim), e a 300 caracteres não sobrava fato nenhum — o
180
+ // modelo escrevia genérico, que foi exatamente a reclamação de "está tudo
181
+ // padrão". Encolhe também, mas com espaço para os fatos sobreviverem.
182
+ const RESTO_DA_PESQUISA = 1500;
183
+ const MATERIA_PRIMA = new Set(['pesquisar_referencia']);
167
184
 
168
185
  /**
169
186
  * Encolhe os resultados de tool ANTIGOS antes de mandar a conversa.
@@ -174,23 +191,64 @@ const RESTO_DO_RESULTADO = 300;
174
191
  * 27.454 tokens contra um teto de 6.000 por minuto do plano grátis — a tarefa
175
192
  * morreu no meio, depois de todo o trabalho de pesquisa já feito.
176
193
  *
194
+ * MAS: encolher REFERÊNCIA cria um loop. O manual e as specs do estúdio são o
195
+ * formato que o modelo precisa ter em mãos na hora de escrever o documento;
196
+ * encolhidos, ele pede de novo — e é encolhido de novo. Foi assim que um
197
+ * pedido de vídeo pesquisou e leu a mesma spec sete vezes sem criar nada. A
198
+ * cópia mais nova de cada referência fica INTEIRA; as velhas, que são o mesmo
199
+ * texto repetido, viram um ponteiro para ela.
200
+ *
177
201
  * Nenhuma mensagem é REMOVIDA: a API exige que cada role:'tool' continue
178
202
  * pareada com o tool_call que a pediu. Só o texto encolhe.
179
203
  */
180
204
  function compactar(messages) {
205
+ const { inteiras, duplicadas } = repeticao.referenciasNaConversa(messages);
206
+
181
207
  const indices = [];
182
208
  for (let i = 0; i < messages.length; i++) if (messages[i].role === 'tool') indices.push(i);
183
- if (indices.length <= RESULTADOS_INTEIROS) return messages;
209
+ if (indices.length <= RESULTADOS_INTEIROS && !duplicadas.size) return messages;
184
210
 
211
+ const chamadas = repeticao.chamadasPorId(messages);
185
212
  const encolher = new Set(indices.slice(0, -RESULTADOS_INTEIROS));
186
213
  return messages.map((m, i) => {
187
- if (!encolher.has(i)) return m;
214
+ if (duplicadas.has(i)) {
215
+ return { ...m, content: '(a mesma referência aparece inteira mais adiante nesta conversa — use aquela)' };
216
+ }
217
+ if (inteiras.has(i) || !encolher.has(i)) return m;
218
+ const chamada = chamadas.get(m.tool_call_id);
219
+ const limite = chamada && MATERIA_PRIMA.has(chamada.nome) ? RESTO_DA_PESQUISA : RESTO_DO_RESULTADO;
188
220
  const texto = String(m.content || '');
189
- if (texto.length <= RESTO_DO_RESULTADO) return m;
190
- return { ...m, content: texto.slice(0, RESTO_DO_RESULTADO) + `…(resultado antigo encurtado; eram ${texto.length} caracteres)` };
221
+ if (texto.length <= limite) return m;
222
+ return { ...m, content: texto.slice(0, limite) + `…(resultado antigo encurtado; eram ${texto.length} caracteres)` };
191
223
  });
192
224
  }
193
225
 
226
+ /**
227
+ * Por que parou, quando o modelo não disse.
228
+ *
229
+ * "Parei sem concluir e sem explicação do modelo" era a resposta para tudo, e
230
+ * quase sempre mentia: na prática o agente tinha gastado as 15 voltas dando
231
+ * murro em ponta de faca. Dizer o motivo certo é a diferença entre o usuário
232
+ * saber o que fazer e ficar no escuro.
233
+ */
234
+ function motivoDaParada({ voltas, voltasEmFalso, falhasPendentes, travou }) {
235
+ if (falhasPendentes.length) {
236
+ return `Parei sem concluir. A última falha foi: ${falhasPendentes[falhasPendentes.length - 1].texto}`;
237
+ }
238
+ if (travou) {
239
+ return 'Travei repetindo as mesmas chamadas sem sair do lugar, e parei antes de gastar sua cota à toa. '
240
+ + 'Peça em partes: primeiro a pesquisa, depois o que criar com ela.';
241
+ }
242
+ if (voltas >= MAX_ITERATIONS) {
243
+ const emFalso = voltasEmFalso
244
+ ? ` ${voltasEmFalso} ${voltasEmFalso === 1 ? 'chamada foi repetida' : 'chamadas foram repetidas'} e não rodaram de novo.`
245
+ : '';
246
+ return `Gastei as ${MAX_ITERATIONS} voltas sem chegar ao fim.${emFalso}`
247
+ + ' Peça em partes: primeiro a pesquisa, depois o que criar com ela.';
248
+ }
249
+ return 'Parei sem concluir e sem explicação do modelo. Tente pedir de novo, em partes menores.';
250
+ }
251
+
194
252
  function buildMemoryContext(projectDir, projectName) {
195
253
  const parts = [];
196
254
  const geral = cfg.readGeneralMemory();
@@ -216,6 +274,10 @@ async function runAct(userPrompt, opts) {
216
274
  let temPasta = !criarPasta; // já é a pasta definitiva?
217
275
  const falhasPendentes = [];
218
276
  let tentativasDeEncerrar = 0;
277
+ // Quem já foi chamado, e quantas vezes. Ver lib/repeticao.js.
278
+ const jaChamado = repeticao.novo();
279
+ let voltasEmFalso = 0;
280
+ let rodadasParadas = 0;
219
281
 
220
282
  const messages = [];
221
283
  if (!subagent) {
@@ -232,7 +294,9 @@ async function runAct(userPrompt, opts) {
232
294
  // Pedido novo zera o pedido de parada anterior.
233
295
  if (!subagent) PARAR = false;
234
296
 
297
+ let voltas = 0;
235
298
  for (let iteration = 0; iteration < MAX_ITERATIONS && !PARAR; iteration++) {
299
+ voltas++;
236
300
  const sp = new Spinner(subagent ? 'subagente trabalhando' : 'Primo Code pensando').start();
237
301
 
238
302
  const toolCalls = [];
@@ -240,8 +304,9 @@ async function runAct(userPrompt, opts) {
240
304
  let streamError = null;
241
305
  let streaming = false;
242
306
 
307
+ emVoo = new AbortController();
243
308
  try {
244
- await motor.requestAct(server, { messages: compactar(messages), model, effort, token, projectDir, projectName, subagent }, (evt) => {
309
+ await motor.requestAct(server, { messages: compactar(messages), model, effort, token, projectDir, projectName, subagent, sinal: emVoo.signal }, (evt) => {
245
310
  if (evt.erro) { streamError = evt.erro; return; }
246
311
  if (evt.tool_call) { toolCalls.push(evt.tool_call); return; }
247
312
  if (evt.delta) {
@@ -259,6 +324,8 @@ async function runAct(userPrompt, opts) {
259
324
  throw e;
260
325
  }
261
326
  sp.stop();
327
+ emVoo = null;
328
+ if (PARAR) break;
262
329
 
263
330
  if (streamError) {
264
331
  const amigavel = friendlyError(streamError);
@@ -292,6 +359,7 @@ async function runAct(userPrompt, opts) {
292
359
  });
293
360
 
294
361
  let finished = null;
362
+ let algoRodou = false;
295
363
  for (const tc of toolCalls) {
296
364
  if (PARAR) break;
297
365
  if (tc.name === 'finish') {
@@ -307,12 +375,14 @@ async function runAct(userPrompt, opts) {
307
375
  });
308
376
  continue;
309
377
  }
378
+ algoRodou = true;
310
379
  finished = (tc.arguments && tc.arguments.summary) || 'Concluído.';
311
380
  messages.push({ role: 'tool', tool_call_id: tc.id, content: 'ok' });
312
381
  continue;
313
382
  }
314
383
 
315
384
  if (tc.name === 'spawn_agent') {
385
+ algoRodou = true;
316
386
  const r = await runSubagent(tc.arguments || {}, {
317
387
  server, model, token, projectDir: pastaAtual, projectName, profundidade, motor,
318
388
  });
@@ -320,6 +390,18 @@ async function runAct(userPrompt, opts) {
320
390
  continue;
321
391
  }
322
392
 
393
+ // Repetiu a mesma chamada? Não executa de novo. Rodar outra vez
394
+ // gastaria a cota para devolver exatamente o mesmo texto, e é
395
+ // esse texto que convence o modelo a repetir mais uma vez.
396
+ const eco = repeticao.ver(jaChamado, tc.name, tc.arguments);
397
+ if (eco.repetida) {
398
+ voltasEmFalso++;
399
+ console.log(ident(profundidade) + actionHeader(tc.name, tc.arguments));
400
+ console.log(ident(profundidade) + c.muted(' ⎿ ') + c.warn(`repetido (${eco.vezes}ª vez) — não rodei de novo`));
401
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: eco.aviso });
402
+ continue;
403
+ }
404
+
323
405
  if (criarPasta && catalogo.escreveArquivo(tc.name)) {
324
406
  const nova = criarPasta();
325
407
  if (nova) pastaAtual = nova;
@@ -327,6 +409,7 @@ async function runAct(userPrompt, opts) {
327
409
  temPasta = true;
328
410
  }
329
411
 
412
+ algoRodou = true;
330
413
  console.log(ident(profundidade) + actionHeader(tc.name, tc.arguments));
331
414
  const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
332
415
  logResult(tc.name, result, profundidade);
@@ -365,6 +448,19 @@ async function runAct(userPrompt, opts) {
365
448
  messages.push({ role: 'tool', tool_call_id: tc.id, content: summarizeForModel(result) });
366
449
  }
367
450
 
451
+ // Uma volta inteira sem executar nada é o modelo preso. Insistir custa
452
+ // um pedido ao servidor por volta, e as 15 voltas saíam caras para
453
+ // terminar no mesmo lugar — melhor parar e devolver a vez ao usuário.
454
+ if (!algoRodou && toolCalls.length) {
455
+ rodadasParadas++;
456
+ if (rodadasParadas >= MAX_RODADAS_PARADAS) {
457
+ if (!subagent) console.log('\n' + c.warn(`Travei repetindo as mesmas chamadas ${rodadasParadas} voltas seguidas — parei aqui.`));
458
+ break;
459
+ }
460
+ } else {
461
+ rodadasParadas = 0;
462
+ }
463
+
368
464
  if (finished) {
369
465
  finalText = finished;
370
466
  if (!subagent) console.log('\n' + c.white(finished));
@@ -382,9 +478,7 @@ async function runAct(userPrompt, opts) {
382
478
  // não dá nem para saber que acabou. Medido: um pedido de navegador morria assim
383
479
  // depois de a primeira tentativa de seletor falhar.
384
480
  if (!finalText) {
385
- finalText = falhasPendentes.length
386
- ? `Parei sem concluir. A última falha foi: ${falhasPendentes[falhasPendentes.length - 1].texto}`
387
- : 'Parei sem concluir e sem explicação do modelo. Tente pedir de novo, em partes menores.';
481
+ finalText = motivoDaParada({ voltas, voltasEmFalso, falhasPendentes, travou: rodadasParadas >= MAX_RODADAS_PARADAS });
388
482
  if (!subagent) console.log('\n' + c.warn(finalText));
389
483
  }
390
484
 
@@ -430,4 +524,4 @@ async function runSubagent(args, ctx) {
430
524
  return resumo;
431
525
  }
432
526
 
433
- module.exports = { runAct, interromper, compactar };
527
+ module.exports = { runAct, interromper, compactar, motivoDaParada, MAX_ITERATIONS };
package/lib/api.js CHANGED
@@ -57,8 +57,17 @@ function request(serverUrl, endpoint, payload, token) {
57
57
  });
58
58
  }
59
59
 
60
- function requestStream(serverUrl, endpoint, payload, onEvent, token) {
60
+ /**
61
+ * `sinal` (AbortSignal) corta o pedido NA HORA.
62
+ *
63
+ * Sem ele o Esc Esc só era visto entre uma rodada e outra: o modelo continuava
64
+ * escrevendo do outro lado e o usuário ficava olhando o spinner girar depois
65
+ * de ter mandado parar. Abortar não é erro — resolve normal, e quem chamou
66
+ * segue para a checagem de parada.
67
+ */
68
+ function requestStream(serverUrl, endpoint, payload, onEvent, token, sinal) {
61
69
  return new Promise((resolve, reject) => {
70
+ if (sinal && sinal.aborted) return resolve();
62
71
  const url = new URL(endpoint, serverUrl);
63
72
  const data = JSON.stringify(payload);
64
73
  const client = url.protocol === 'https:' ? https : http;
@@ -110,7 +119,19 @@ function requestStream(serverUrl, endpoint, payload, onEvent, token) {
110
119
  res.on('error', reject);
111
120
  });
112
121
 
113
- req.on('error', reject);
122
+ // Abortar fecha o socket e encerra a promessa como sucesso: quem pediu
123
+ // para parar não quer ver mensagem de erro.
124
+ let cortado = false;
125
+ const cortar = () => {
126
+ if (cortado) return;
127
+ cortado = true;
128
+ try { req.destroy(); } catch {}
129
+ resolve();
130
+ };
131
+ if (sinal) sinal.addEventListener('abort', cortar, { once: true });
132
+
133
+ req.on('error', (e) => { if (!cortado) reject(e); });
134
+ req.on('close', () => { if (sinal) sinal.removeEventListener('abort', cortar); });
114
135
  req.write(data);
115
136
  req.end();
116
137
  });
@@ -118,14 +139,14 @@ function requestStream(serverUrl, endpoint, payload, onEvent, token) {
118
139
 
119
140
  // Agente autônomo. `payload` carrega a conversa completa (messages).
120
141
  function requestAct(serverUrl, payload, onEvent) {
121
- const { token, ...body } = payload;
122
- return requestStream(serverUrl, '/api/act/stream', body, onEvent, token);
142
+ const { token, sinal, ...body } = payload;
143
+ return requestStream(serverUrl, '/api/act/stream', body, onEvent, token, sinal);
123
144
  }
124
145
 
125
146
  // Chat puro.
126
147
  function requestChat(serverUrl, payload, onEvent) {
127
- const { token, ...body } = payload;
128
- return requestStream(serverUrl, '/api/chat/stream', body, onEvent, token);
148
+ const { token, sinal, ...body } = payload;
149
+ return requestStream(serverUrl, '/api/chat/stream', body, onEvent, token, sinal);
129
150
  }
130
151
 
131
152
  // Resumo incremental da conversa. Vai e volta pequeno: manda o resumo de
@@ -135,6 +156,12 @@ function requestResumo(serverUrl, payload) {
135
156
  return request(serverUrl, '/api/resumir', body, token);
136
157
  }
137
158
 
159
+ // Refino do pedido: manda o texto cru, recebe a ordem de serviço.
160
+ function requestRefino(serverUrl, payload) {
161
+ const { token, ...body } = payload;
162
+ return request(serverUrl, '/api/refinar', body, token);
163
+ }
164
+
138
165
  function getHealth(serverUrl, token) {
139
166
  return new Promise((resolve, reject) => {
140
167
  const url = new URL('/health', serverUrl);
@@ -151,4 +178,4 @@ function getHealth(serverUrl, token) {
151
178
  });
152
179
  }
153
180
 
154
- module.exports = { request, requestStream, requestAct, requestChat, requestResumo, getHealth };
181
+ module.exports = { request, requestStream, requestAct, requestChat, requestResumo, requestRefino, getHealth };
package/lib/referencia.js CHANGED
@@ -59,6 +59,10 @@ function hostBloqueado(hostname) {
59
59
  const TIMEOUT_MS = 12000;
60
60
  const MAX_POR_FONTE = 4;
61
61
  const MAX_TEXTO = 4500;
62
+ // Quanto HTML baixar antes de parar. Só os primeiros MAX_TEXTO caracteres de
63
+ // TEXTO viram referência, e 400 KB de HTML dão isso com folga em qualquer
64
+ // página — inclusive nas de Wikipédia, que passam desse tamanho.
65
+ const LIMITE_CORPO = 400000;
62
66
 
63
67
  // Rede de empresa costuma exigir proxy. Sem isto, a pesquisa simplesmente não
64
68
  // funciona em metade dos computadores corporativos — e o usuário nunca
@@ -136,13 +140,25 @@ function pegar(url, { timeout = TIMEOUT_MS, saltos = 0 } = {}) {
136
140
  }
137
141
  if (res.statusCode !== 200) { res.resume(); return resolve(null); }
138
142
  let corpo = '';
143
+ let pronto = false;
144
+ const entregar = (valor) => { if (!pronto) { pronto = true; resolve(valor); } };
139
145
  res.setEncoding('utf8');
140
146
  res.on('data', (c) => {
141
147
  corpo += c;
142
- if (corpo.length > 400000) { req.destroy(); } // página gigante não serve
148
+ // Página grande: PARA de baixar e entrega o que já veio.
149
+ //
150
+ // Antes isto era `req.destroy()` sem entregar nada, e o
151
+ // destroy caía no req.on('error') -> resolve(null). Ou
152
+ // seja: TODA página acima de 400 KB era jogada fora
153
+ // inteira. Artigo de Wikipédia passa desse tamanho, então
154
+ // toda pesquisa que caía nela voltava "achei os links mas
155
+ // não consegui abrir nenhum" — com a página no ar e o
156
+ // conteúdo já na mão. E precisamos só dos primeiros
157
+ // milhares de caracteres.
158
+ if (corpo.length > LIMITE_CORPO) { res.destroy(); entregar(corpo); }
143
159
  });
144
- res.on('end', () => resolve(corpo));
145
- res.on('error', () => resolve(null));
160
+ res.on('end', () => entregar(corpo));
161
+ res.on('error', () => entregar(corpo || null));
146
162
  });
147
163
  } catch { return resolve(null); }
148
164
  req.on('timeout', () => { req.destroy(); resolve(null); });
@@ -151,6 +167,21 @@ function pegar(url, { timeout = TIMEOUT_MS, saltos = 0 } = {}) {
151
167
  });
152
168
  }
153
169
 
170
+ /**
171
+ * Isto é texto para ler, ou sobra de código?
172
+ *
173
+ * Site feito em construtor visual despeja CSS e JSON no corpo da página. Sem
174
+ * este filtro o "trecho" entregue ao modelo começava com
175
+ * `{"css":"@media all { *#dm .dmInner ...` — e era isso que ele levava como
176
+ * referência para escrever o vídeo.
177
+ */
178
+ function pareceCodigo(texto) {
179
+ const amostra = texto.slice(0, 1200);
180
+ if (!amostra) return true;
181
+ const simbolos = (amostra.match(/[{};:_#]/g) || []).length;
182
+ return simbolos / amostra.length > 0.08;
183
+ }
184
+
154
185
  /** HTML → texto legível. Sem dependência: corta script/style e as tags. */
155
186
  function textoDe(html) {
156
187
  return String(html || '')
@@ -165,6 +196,67 @@ function textoDe(html) {
165
196
  .trim();
166
197
  }
167
198
 
199
+ /**
200
+ * ── FONTE ERRADA É PIOR QUE FONTE NENHUMA ────────────────────────────────
201
+ * Pedido real: "pesquise sobre a Paris Group e crie um vídeo para anunciar a
202
+ * imersão founders ai". Os buscadores não acharam a empresa e a busca de
203
+ * texto da Wikipédia devolveu o que continha aquelas palavras soltas: Milan,
204
+ * History of autism, Berlitz Corporation, 42 (school). O agente ia escrever
205
+ * um vídeo com AQUILO. Melhor devolver "não achei" e deixar o modelo dizer
206
+ * isso ao usuário do que entregar fonte que não é sobre o assunto.
207
+ */
208
+ function palavrasDaConsulta(consulta) {
209
+ return String(consulta || '')
210
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
211
+ .split(/[^a-z0-9]+/)
212
+ .filter((p) => p.length >= 3 && !GENERICAS.has(p));
213
+ }
214
+
215
+ // Palavras que aparecem em qualquer pesquisa e não provam relevância nenhuma.
216
+ const GENERICAS = new Set(['sobre', 'empresa', 'company', 'overview', 'about', 'the', 'and',
217
+ 'para', 'com', 'que', 'dos', 'das', 'quem', 'programa', 'program', 'site', 'oficial']);
218
+
219
+ /**
220
+ * O link fala mesmo do que foi pedido?
221
+ *
222
+ * Exige DUAS palavras distintas da consulta no título ou na URL — uma só é
223
+ * coincidência ("Paris - Wikipedia" para "Paris Group founders AI" bate em
224
+ * "paris" e não tem nada a ver). Consulta de uma palavra só se contenta com
225
+ * ela mesma.
226
+ */
227
+ function relevante(cand, palavras) {
228
+ if (!palavras.length) return true;
229
+ const alvo = `${cand.titulo || ''} ${cand.url || ''}`
230
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
231
+ let batidas = 0;
232
+ for (const p of palavras) if (alvo.includes(p)) batidas++;
233
+ return batidas >= Math.min(2, palavras.length);
234
+ }
235
+
236
+ /**
237
+ * O site provável de uma empresa, tentado direto — sem buscador nenhum.
238
+ *
239
+ * É como uma pessoa faria: "Paris Group" -> parisgroup.com, parisgroup.ai.
240
+ * Foi o que salvou este caso — parisgroup.ai abre e tem o texto todo, enquanto
241
+ * DuckDuckGo devolvia 202 e o Bing devolvia Paris, a cidade.
242
+ *
243
+ * Só dispara quando a consulta tem DUAS PALAVRAS COM INICIAL MAIÚSCULA
244
+ * seguidas, que é como se escreve nome de empresa. "react hooks tutorial" não
245
+ * dispara; "Paris Group" dispara.
246
+ */
247
+ function sitesProvaveis(consulta) {
248
+ const nome = /\b([A-ZÀ-Ý][\w\u00C0-\u024F]{1,})\s+([A-ZÀ-Ý][\w\u00C0-\u024F]{1,})\b/.exec(String(consulta || ''));
249
+ if (!nome) return [];
250
+ const slug = `${nome[1]}${nome[2]}`
251
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
252
+ if (slug.length < 5) return [];
253
+ return ['com', 'ai', 'com.br', 'io', 'co'].map((tld) => ({
254
+ titulo: `${nome[1]} ${nome[2]} — site oficial`,
255
+ url: `https://www.${slug}.${tld}`,
256
+ nota: 'site provável da empresa',
257
+ }));
258
+ }
259
+
168
260
  // Links que não são referência de nada — resultado de busca sujo.
169
261
  const LIXO = /(facebook|twitter|x)\.com|instagram|pinterest|youtube\.com\/(?!watch)|\/search\?|duckduckgo|bing\.com\/(?!ck)|google\./i;
170
262
 
@@ -256,6 +348,15 @@ async function buscarBing(consulta, limite) {
256
348
  const html = await pegar(`https://www.bing.com/search?q=${encodeURIComponent(consulta)}&setlang=en`);
257
349
  if (!html) return [];
258
350
  const pares = [];
351
+ // O Bing largou o <h2><a href> e hoje mostra a URL num <cite>, com " › "
352
+ // no lugar da barra. O parser antigo casava ZERO resultados numa página
353
+ // que trazia dez — e a pesquisa caía na Wikipédia, que era o lixo.
354
+ const cites = html.match(/<cite[^>]*>[\s\S]{0,200}?<\/cite>/gi) || [];
355
+ for (const bruto of cites) {
356
+ const mostrado = textoDe(bruto);
357
+ if (!/^https?:\/\//i.test(mostrado) || /[…]/.test(mostrado)) continue;
358
+ pares.push({ titulo: mostrado.replace(/\s*›\s*/g, ' / '), url: mostrado.replace(/\s*›\s*/g, '/') });
359
+ }
259
360
  const re = /<h2>\s*<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
260
361
  let m;
261
362
  while ((m = re.exec(html))) pares.push({ titulo: textoDe(m[2]), url: m[1] });
@@ -352,8 +453,19 @@ async function pesquisar({ consulta, paginas = 3 }) {
352
453
  // portas vieram vazias: ela é a melhor fonte, e é o que salva a pesquisa
353
454
  // quando os links do buscador não abrem (rede corporativa, site fora do
354
455
  // ar, bloqueio). Foi assim que a pesquisa falhava mesmo achando links.
355
- const candidatos = [...web, ...so, ...docsConhecidas(termo)]
356
- .slice(0, Math.max(2, Math.min(8, paginas + 4)));
456
+ // O site provável da empresa vai NA FRENTE: quando a consulta nomeia um
457
+ // negócio, o site dele é a melhor fonte que existe, e não depende de
458
+ // buscador nenhum estar de bom humor.
459
+ const chutes = sitesProvaveis(termo);
460
+ const palavras = palavrasDaConsulta(termo);
461
+ const brutos = [...chutes, ...web, ...so, ...docsConhecidas(termo)];
462
+
463
+ // Fora o que não é sobre o assunto. Sem isto o modelo recebia "Milan" e
464
+ // "History of autism" para uma pesquisa sobre uma empresa, e escrevia o
465
+ // vídeo com aquilo.
466
+ const filtrados = brutos.filter((cand) => cand.nota === 'site provável da empresa' || relevante(cand, palavras));
467
+ const candidatos = filtrados.slice(0, Math.max(2, Math.min(10, paginas + 6)));
468
+ const descartados = brutos.length - filtrados.length;
357
469
  if (!candidatos.length) {
358
470
  return {
359
471
  ok: false,
@@ -362,7 +474,9 @@ async function pesquisar({ consulta, paginas = 3 }) {
362
474
  // "sem internet?" era o palpite errado, e mandava o usuário conferir
363
475
  // a conexão dele: medido, os buscadores devolvem 202/302 e página
364
476
  // vazia para quem não parece navegador, com a rede perfeita.
365
- provavelCausa: 'Os buscadores costumam recusar chamadas automáticas (bloqueio ou limite por excesso de buscas seguidas), e não a sua conexão.',
477
+ provavelCausa: descartados
478
+ ? `Vieram ${descartados} link(s), mas nenhum era sobre "${termo}" — o buscador não achou o assunto e devolveu página aleatória. Fonte errada é pior que fonte nenhuma, então foram descartadas.`
479
+ : 'Os buscadores costumam recusar chamadas automáticas (bloqueio ou limite por excesso de buscas seguidas), e não a sua conexão.',
366
480
  comoCorrigir: 'Tente OUTRA consulta, mais curta e específica. Se souber o site do assunto, vá direto nele com browser_open + browser_read — é o caminho mais confiável para fato de empresa.',
367
481
  };
368
482
  }
@@ -374,6 +488,12 @@ async function pesquisar({ consulta, paginas = 3 }) {
374
488
  if (!html) continue;
375
489
  const texto = textoDe(html);
376
490
  if (texto.length < 200) continue;
491
+ if (pareceCodigo(texto)) continue;
492
+ // O chute de domínio precisa PROVAR que acertou. "Paris Group" tem
493
+ // parisgroup.com (um grupo de varejo no exterior) e parisgroup.ai (a
494
+ // empresa que o usuário quis). O nome não distingue os dois — o texto
495
+ // 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;
377
497
  lidas.push({
378
498
  titulo: cand.titulo,
379
499
  url: cand.url,
@@ -383,7 +503,14 @@ async function pesquisar({ consulta, paginas = 3 }) {
383
503
  }
384
504
 
385
505
  if (!lidas.length) {
386
- return { ok: false, error: 'achei os links mas não consegui abrir nenhum', consulta: termo, links: candidatos.map((c) => c.url) };
506
+ return {
507
+ ok: false,
508
+ error: 'achei os links mas não consegui abrir nenhum',
509
+ consulta: termo,
510
+ links: candidatos.map((c) => c.url),
511
+ comoCorrigir: 'Tente browser_open + browser_read num desses links, ou pesquise com outras palavras. '
512
+ + 'Se nada abrir, diga ao usuário que não conseguiu confirmar os fatos — NUNCA invente dado sobre empresa, data, preço ou pessoa.',
513
+ };
387
514
  }
388
515
 
389
516
  return {
@@ -396,4 +523,5 @@ async function pesquisar({ consulta, paginas = 3 }) {
396
523
  };
397
524
  }
398
525
 
399
- module.exports = { pesquisar, buscar, stackoverflow, textoDe, buscarWikipedia, buscarRespostaDDG };
526
+ module.exports = { pesquisar, buscar, stackoverflow, textoDe, buscarWikipedia, buscarRespostaDDG, pegar,
527
+ relevante, palavrasDaConsulta, sitesProvaveis, pareceCodigo };
@@ -0,0 +1,186 @@
1
+ /**
2
+ * repeticao.js — o agente não pode ficar dando voltas.
3
+ *
4
+ * ── O QUE ACONTECEU DE VERDADE ───────────────────────────────────────────
5
+ * Pedido real: "pesquise sobre a paris group e crie um vídeo para anunciar a
6
+ * imersão founders ai". O agente pesquisou, leu a spec da ferramenta de vídeo,
7
+ * pesquisou de novo, leu a spec de novo — sete vezes — e morreu sem criar nada.
8
+ *
9
+ * A culpa não era do modelo. Era nossa. `compactar` encolhe os resultados
10
+ * antigos para a conversa caber no teto do plano, e a spec do estúdio é um
11
+ * resultado como outro qualquer: depois de três tools novas, ela virava 300
12
+ * caracteres. O modelo então não tinha mais o formato do documento para
13
+ * escrever o vídeo — e fazia a única coisa sensata: pedir a spec de novo. Que
14
+ * era encolhida de novo. Para sempre.
15
+ *
16
+ * ── AS DUAS DEFESAS ──────────────────────────────────────────────────────
17
+ * 1. REFERÊNCIA NÃO ENCOLHE. O manual e as specs do estúdio são documentos
18
+ * fixos: mesma pergunta, mesma resposta, sempre. A cópia mais nova de cada
19
+ * uma fica INTEIRA, e as cópias velhas somem (viram um ponteiro). Isso
20
+ * corta o motivo do loop e ainda economiza — sete cópias da mesma spec
21
+ * eram sete vezes o mesmo texto.
22
+ *
23
+ * 2. CHAMADA REPETIDA NÃO EXECUTA. Se ainda assim o modelo repetir a mesma
24
+ * tool com os mesmos argumentos, não roda de novo: ele leva um aviso que
25
+ * aperta a cada volta, até virar "produza agora com o que você já tem".
26
+ * Vale também para pesquisa quase igual — "paris group founders ai
27
+ * immersion" e "paris group company ai founders immersion" são a mesma
28
+ * busca, e as duas devolviam as mesmas três fontes.
29
+ *
30
+ * Puro de propósito: sem rede e sem I/O, para o teste medir a política inteira.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ /** Tools cuja resposta é documento fixo — repetir é sempre desperdício. */
36
+ const REFERENCIAS = new Set(['studio_manual', 'studio_spec']);
37
+
38
+ /** Acima disto, duas buscas são a mesma busca. */
39
+ const LIMITE_SEMELHANCA = 0.6;
40
+
41
+ /** Palavras que não distinguem uma busca da outra. */
42
+ const VAZIAS = new Set(['de', 'da', 'do', 'das', 'dos', 'a', 'o', 'as', 'os', 'e', 'em',
43
+ 'para', 'por', 'com', 'sobre', 'the', 'of', 'and', 'for', 'about', 'que', 'na', 'no']);
44
+
45
+ function normalizar(texto) {
46
+ return String(texto == null ? '' : texto)
47
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
48
+ .toLowerCase().replace(/\s+/g, ' ').trim();
49
+ }
50
+
51
+ /** As palavras que de fato dizem o que a busca procura. */
52
+ function palavras(texto) {
53
+ return new Set(normalizar(texto).split(/[^a-z0-9]+/)
54
+ .filter((p) => p.length >= 3 && !VAZIAS.has(p)));
55
+ }
56
+
57
+ /** Quanto duas buscas se sobrepõem, de 0 a 1. */
58
+ function semelhanca(a, b) {
59
+ const A = palavras(a), B = palavras(b);
60
+ if (!A.size || !B.size) return A.size === B.size ? 1 : 0;
61
+ let juntos = 0;
62
+ for (const p of A) if (B.has(p)) juntos++;
63
+ return juntos / (A.size + B.size - juntos);
64
+ }
65
+
66
+ /** A mesma chamada com as chaves em qualquer ordem tem a mesma assinatura. */
67
+ function assinatura(nome, args) {
68
+ const a = args && typeof args === 'object' ? args : {};
69
+ const partes = Object.keys(a).sort()
70
+ .map((k) => `${k}=${normalizar(typeof a[k] === 'string' ? a[k] : JSON.stringify(a[k]))}`);
71
+ return `${nome}(${partes.join('&')})`;
72
+ }
73
+
74
+ /** O texto que uma chamada de pesquisa procura, seja qual for o apelido do campo. */
75
+ function consultaDe(nome, args) {
76
+ if (nome !== 'pesquisar_referencia') return null;
77
+ const a = args || {};
78
+ return String(a.consulta || a.query || '');
79
+ }
80
+
81
+ function novo() {
82
+ return { vistos: new Map(), buscas: [] };
83
+ }
84
+
85
+ /**
86
+ * Registra a chamada e diz se ela é repetição.
87
+ *
88
+ * Devolve { repetida, vezes, aviso }. Quando `repetida` é true, quem chamou
89
+ * NÃO deve executar a tool: manda o `aviso` de volta como resultado.
90
+ */
91
+ function ver(registro, nome, args) {
92
+ const chave = assinatura(nome, args);
93
+ const busca = consultaDe(nome, args);
94
+
95
+ let vezes = (registro.vistos.get(chave) || 0) + 1;
96
+ let parecidaCom = null;
97
+
98
+ // Pesquisa quase igual conta como a mesma pesquisa: o buscador devolve as
99
+ // mesmas fontes, e o modelo fica convencido de que "ainda não pesquisou".
100
+ if (vezes === 1 && busca) {
101
+ const irma = registro.buscas.find((b) => semelhanca(b.texto, busca) >= LIMITE_SEMELHANCA);
102
+ if (irma) {
103
+ irma.vezes++;
104
+ vezes = irma.vezes;
105
+ parecidaCom = irma.texto;
106
+ } else {
107
+ registro.buscas.push({ texto: busca, vezes: 1 });
108
+ }
109
+ }
110
+
111
+ registro.vistos.set(chave, vezes);
112
+ if (vezes === 1) return { repetida: false, vezes, aviso: null };
113
+
114
+ return { repetida: true, vezes, aviso: aviso(nome, vezes, parecidaCom) };
115
+ }
116
+
117
+ /** O recado, apertando a cada volta. */
118
+ function aviso(nome, vezes, parecidaCom) {
119
+ const oQue = parecidaCom
120
+ ? `Você já pesquisou isto: "${parecidaCom}". A busca de agora é a mesma coisa com outras palavras, e traria as mesmas fontes.`
121
+ : `Você já chamou ${nome} com estes mesmos argumentos.`;
122
+
123
+ if (vezes === 2) {
124
+ return `${oQue} O resultado está na conversa acima — role para cima e use o que já tem. `
125
+ + 'Não chame esta tool de novo com os mesmos argumentos.';
126
+ }
127
+ return `${oQue} Esta é a ${vezes}ª vez, e a tool NÃO foi executada. PARE de repetir: `
128
+ + 'com o que você já tem, produza AGORA o resultado que o usuário pediu (crie o arquivo, o documento, o vídeo). '
129
+ + 'Se algo realmente impede, chame finish e diga ao usuário o que faltou.';
130
+ }
131
+
132
+ /**
133
+ * Mapa tool_call_id -> { nome, args }, lido dos turnos do assistente.
134
+ *
135
+ * A mensagem role:'tool' guarda só o id; para saber se ela é uma referência é
136
+ * preciso achar a chamada que a pediu.
137
+ */
138
+ function chamadasPorId(messages) {
139
+ const mapa = new Map();
140
+ for (const m of messages || []) {
141
+ if (!m || !m.tool_calls) continue;
142
+ for (const tc of m.tool_calls) {
143
+ const f = tc.function || {};
144
+ let args = {};
145
+ try { args = typeof f.arguments === 'string' ? JSON.parse(f.arguments) : (f.arguments || {}); } catch {}
146
+ mapa.set(tc.id, { nome: f.name || tc.name, args });
147
+ }
148
+ }
149
+ return mapa;
150
+ }
151
+
152
+ /**
153
+ * Onde estão as referências na conversa.
154
+ *
155
+ * Devolve { inteiras, duplicadas }: os índices que NÃO podem encolher (a cópia
156
+ * mais nova de cada referência) e os que podem sumir de vez (as cópias velhas
157
+ * da mesma coisa — texto idêntico, repetido).
158
+ */
159
+ function referenciasNaConversa(messages) {
160
+ const chamadas = chamadasPorId(messages);
161
+ const ultima = new Map();
162
+ const todas = new Map();
163
+
164
+ (messages || []).forEach((m, i) => {
165
+ if (!m || m.role !== 'tool') return;
166
+ const ch = chamadas.get(m.tool_call_id);
167
+ if (!ch || !REFERENCIAS.has(ch.nome)) return;
168
+ const chave = assinatura(ch.nome, ch.args);
169
+ ultima.set(chave, i);
170
+ if (!todas.has(chave)) todas.set(chave, []);
171
+ todas.get(chave).push(i);
172
+ });
173
+
174
+ const inteiras = new Set(ultima.values());
175
+ const duplicadas = new Set();
176
+ for (const [chave, indices] of todas) {
177
+ for (const i of indices) if (i !== ultima.get(chave)) duplicadas.add(i);
178
+ }
179
+ return { inteiras, duplicadas };
180
+ }
181
+
182
+ module.exports = {
183
+ REFERENCIAS, LIMITE_SEMELHANCA,
184
+ normalizar, palavras, semelhanca, assinatura, consultaDe,
185
+ novo, ver, chamadasPorId, referenciasNaConversa,
186
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.4.0",
3
+ "version": "8.5.0",
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": {
@@ -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/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.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"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"