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 +45 -1
- package/lib/act.js +104 -10
- package/lib/api.js +34 -7
- package/lib/referencia.js +136 -8
- package/lib/repeticao.js +186 -0
- package/package.json +2 -2
package/bin/primocode.js
CHANGED
|
@@ -538,7 +538,11 @@ async function sendPrompt(userText) {
|
|
|
538
538
|
// conversa inteira.
|
|
539
539
|
await comprimirConversa();
|
|
540
540
|
|
|
541
|
-
|
|
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
|
-
|
|
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 (
|
|
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 <=
|
|
190
|
-
return { ...m, content: texto.slice(0,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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', () =>
|
|
145
|
-
res.on('error', () =>
|
|
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
|
-
|
|
356
|
-
|
|
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:
|
|
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 {
|
|
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 };
|
package/lib/repeticao.js
ADDED
|
@@ -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.
|
|
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"
|