primocode 8.2.2 → 8.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/primocode.js +42 -3
- package/lib/act.js +92 -8
- package/lib/api.js +8 -1
- package/lib/memoria-conversa.js +124 -0
- package/lib/repeticao.js +186 -0
- package/package.json +2 -2
package/bin/primocode.js
CHANGED
|
@@ -24,6 +24,7 @@ const claudeEngine = require('../lib/claude-engine');
|
|
|
24
24
|
const pasta = require('../lib/pasta');
|
|
25
25
|
const catalogo = require('../lib/catalogo');
|
|
26
26
|
const effort = require('../lib/effort');
|
|
27
|
+
const memoria = require('../lib/memoria-conversa');
|
|
27
28
|
|
|
28
29
|
// Rede de segurança de último recurso: sem isto, uma exceção fora de um
|
|
29
30
|
// try/catch (ex.: dentro de um callback de socket, como o parser de SSE) não
|
|
@@ -50,6 +51,9 @@ const state = {
|
|
|
50
51
|
mode: null, // 'primocode' | 'chat' | 'auto' — vem da config em main()
|
|
51
52
|
modoEfetivo: null, // no auto, o modo que o último pedido usou de fato
|
|
52
53
|
attachedFiles: [],
|
|
54
|
+
// O resumo incremental da conversa — um /compact automático. O porquê
|
|
55
|
+
// está em lib/memoria-conversa.js.
|
|
56
|
+
memoria: memoria.nova(),
|
|
53
57
|
lastResponse: '',
|
|
54
58
|
currentProject: null,
|
|
55
59
|
workDir: null, // pasta onde o agente trabalha nesta sessão
|
|
@@ -529,12 +533,17 @@ async function sendPrompt(userText) {
|
|
|
529
533
|
if (modo === 'primocode') {
|
|
530
534
|
const plan = prepararPasta(userText);
|
|
531
535
|
|
|
536
|
+
// Antes de gastar com a tarefa, dobra no resumo o que já passou. A
|
|
537
|
+
// conta é pequena: entra "resumo de antes + o que é novo", não a
|
|
538
|
+
// conversa inteira.
|
|
539
|
+
await comprimirConversa();
|
|
540
|
+
|
|
532
541
|
const r = await runAct(prompt, {
|
|
533
542
|
server: state.config.server,
|
|
534
543
|
model: state.config.model,
|
|
535
544
|
effort: state.config.effort,
|
|
536
545
|
token: state.config.token,
|
|
537
|
-
history: state.history,
|
|
546
|
+
history: memoria.paraEnviar(state.memoria, state.history),
|
|
538
547
|
projectDir: plan.dir,
|
|
539
548
|
projectName: plan.name || state.currentProject,
|
|
540
549
|
// Só chamado se o agente for mesmo MEXER EM ARQUIVO — abrir o Chrome
|
|
@@ -547,6 +556,7 @@ async function sendPrompt(userText) {
|
|
|
547
556
|
state.lastResponse = r.assistantMessage.content;
|
|
548
557
|
cfg.appendHistory({ mode: 'primocode', project: state.currentProject, prompt: userText });
|
|
549
558
|
} else {
|
|
559
|
+
await comprimirConversa();
|
|
550
560
|
await runChat(prompt, userText);
|
|
551
561
|
}
|
|
552
562
|
|
|
@@ -554,6 +564,27 @@ async function sendPrompt(userText) {
|
|
|
554
564
|
refreshPrompt();
|
|
555
565
|
}
|
|
556
566
|
|
|
567
|
+
/**
|
|
568
|
+
* Atualiza o resumo da conversa, se já compensar.
|
|
569
|
+
*
|
|
570
|
+
* Silencioso quando não há o que fazer: nas primeiras rodadas a conversa é
|
|
571
|
+
* curta e o resumo não pagaria a chamada.
|
|
572
|
+
*/
|
|
573
|
+
async function comprimirConversa() {
|
|
574
|
+
if (!memoria.precisaResumir(state.memoria, state.history)) return;
|
|
575
|
+
const sp = new Spinner('resumindo a conversa').start();
|
|
576
|
+
try {
|
|
577
|
+
state.memoria = await memoria.atualizar(state.memoria, state.history, ({ resumo, mensagens }) =>
|
|
578
|
+
api.requestResumo(state.config.server, { resumo, mensagens, token: state.config.token })
|
|
579
|
+
.then((r) => r.resumo));
|
|
580
|
+
sp.stop();
|
|
581
|
+
const e = memoria.economia(state.memoria, state.history);
|
|
582
|
+
if (e.poupado > 0) console.log(c.dim(` ⎿ conversa resumida · ${e.cheio} → ${e.enxuto} tokens por pedido`));
|
|
583
|
+
} catch {
|
|
584
|
+
sp.stop(); // resumo é economia, não requisito: falhou, segue a vida
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
557
588
|
async function runChat(promptForModel, userTextForMemory) {
|
|
558
589
|
const sp = new Spinner('pensando').start();
|
|
559
590
|
let started = false;
|
|
@@ -561,7 +592,7 @@ async function runChat(promptForModel, userTextForMemory) {
|
|
|
561
592
|
try {
|
|
562
593
|
await api.requestChat(state.config.server, {
|
|
563
594
|
prompt: promptForModel,
|
|
564
|
-
history: state.history
|
|
595
|
+
history: memoria.paraEnviar(state.memoria, state.history),
|
|
565
596
|
model: state.config.model,
|
|
566
597
|
token: state.config.token,
|
|
567
598
|
}, (evt) => {
|
|
@@ -854,7 +885,7 @@ async function handleSlash(text) {
|
|
|
854
885
|
break;
|
|
855
886
|
}
|
|
856
887
|
|
|
857
|
-
case 'clear': state.history = []; console.log(c.ok('Conversa limpa.')); break;
|
|
888
|
+
case 'clear': state.history = []; state.memoria = memoria.nova(); console.log(c.ok('Conversa limpa.')); break;
|
|
858
889
|
case 'cls': process.stdout.write('\x1Bc'); break;
|
|
859
890
|
|
|
860
891
|
case 'status': {
|
|
@@ -872,6 +903,14 @@ async function handleSlash(text) {
|
|
|
872
903
|
console.log(c.dim(` Reservas: ${fila.length - 1}`));
|
|
873
904
|
}
|
|
874
905
|
} catch (e) { sp.stop(); console.log(c.err(`Indisponível: ${e.message}`)); }
|
|
906
|
+
// A memória da conversa: quanto ela está poupando por pedido.
|
|
907
|
+
if (state.history.length) {
|
|
908
|
+
const e = memoria.economia(state.memoria, state.history);
|
|
909
|
+
console.log(c.muted(` Conversa: ${state.history.length} mensagens`)
|
|
910
|
+
+ (state.memoria.resumo
|
|
911
|
+
? c.dim(` · resumida, ${e.enxuto} tokens por pedido em vez de ${e.cheio}`)
|
|
912
|
+
: c.dim(` · ${e.enxuto} tokens por pedido`)));
|
|
913
|
+
}
|
|
875
914
|
break;
|
|
876
915
|
}
|
|
877
916
|
|
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) ─────────────────────────────────────────────────
|
|
@@ -164,6 +167,13 @@ function summarizeForModel(result) {
|
|
|
164
167
|
// que o modelo precisa para decidir o próximo passo; os velhos viram peso.
|
|
165
168
|
const RESULTADOS_INTEIROS = 3;
|
|
166
169
|
const RESTO_DO_RESULTADO = 300;
|
|
170
|
+
// A pesquisa é MATÉRIA-PRIMA, não passo intermediário: é dela que saem os
|
|
171
|
+
// fatos do vídeo, do post, do slide. Ela sempre fica velha (pesquisa-se no
|
|
172
|
+
// começo, escreve-se no fim), e a 300 caracteres não sobrava fato nenhum — o
|
|
173
|
+
// modelo escrevia genérico, que foi exatamente a reclamação de "está tudo
|
|
174
|
+
// padrão". Encolhe também, mas com espaço para os fatos sobreviverem.
|
|
175
|
+
const RESTO_DA_PESQUISA = 1500;
|
|
176
|
+
const MATERIA_PRIMA = new Set(['pesquisar_referencia']);
|
|
167
177
|
|
|
168
178
|
/**
|
|
169
179
|
* Encolhe os resultados de tool ANTIGOS antes de mandar a conversa.
|
|
@@ -174,23 +184,64 @@ const RESTO_DO_RESULTADO = 300;
|
|
|
174
184
|
* 27.454 tokens contra um teto de 6.000 por minuto do plano grátis — a tarefa
|
|
175
185
|
* morreu no meio, depois de todo o trabalho de pesquisa já feito.
|
|
176
186
|
*
|
|
187
|
+
* MAS: encolher REFERÊNCIA cria um loop. O manual e as specs do estúdio são o
|
|
188
|
+
* formato que o modelo precisa ter em mãos na hora de escrever o documento;
|
|
189
|
+
* encolhidos, ele pede de novo — e é encolhido de novo. Foi assim que um
|
|
190
|
+
* pedido de vídeo pesquisou e leu a mesma spec sete vezes sem criar nada. A
|
|
191
|
+
* cópia mais nova de cada referência fica INTEIRA; as velhas, que são o mesmo
|
|
192
|
+
* texto repetido, viram um ponteiro para ela.
|
|
193
|
+
*
|
|
177
194
|
* Nenhuma mensagem é REMOVIDA: a API exige que cada role:'tool' continue
|
|
178
195
|
* pareada com o tool_call que a pediu. Só o texto encolhe.
|
|
179
196
|
*/
|
|
180
197
|
function compactar(messages) {
|
|
198
|
+
const { inteiras, duplicadas } = repeticao.referenciasNaConversa(messages);
|
|
199
|
+
|
|
181
200
|
const indices = [];
|
|
182
201
|
for (let i = 0; i < messages.length; i++) if (messages[i].role === 'tool') indices.push(i);
|
|
183
|
-
if (indices.length <= RESULTADOS_INTEIROS) return messages;
|
|
202
|
+
if (indices.length <= RESULTADOS_INTEIROS && !duplicadas.size) return messages;
|
|
184
203
|
|
|
204
|
+
const chamadas = repeticao.chamadasPorId(messages);
|
|
185
205
|
const encolher = new Set(indices.slice(0, -RESULTADOS_INTEIROS));
|
|
186
206
|
return messages.map((m, i) => {
|
|
187
|
-
if (
|
|
207
|
+
if (duplicadas.has(i)) {
|
|
208
|
+
return { ...m, content: '(a mesma referência aparece inteira mais adiante nesta conversa — use aquela)' };
|
|
209
|
+
}
|
|
210
|
+
if (inteiras.has(i) || !encolher.has(i)) return m;
|
|
211
|
+
const chamada = chamadas.get(m.tool_call_id);
|
|
212
|
+
const limite = chamada && MATERIA_PRIMA.has(chamada.nome) ? RESTO_DA_PESQUISA : RESTO_DO_RESULTADO;
|
|
188
213
|
const texto = String(m.content || '');
|
|
189
|
-
if (texto.length <=
|
|
190
|
-
return { ...m, content: texto.slice(0,
|
|
214
|
+
if (texto.length <= limite) return m;
|
|
215
|
+
return { ...m, content: texto.slice(0, limite) + `…(resultado antigo encurtado; eram ${texto.length} caracteres)` };
|
|
191
216
|
});
|
|
192
217
|
}
|
|
193
218
|
|
|
219
|
+
/**
|
|
220
|
+
* Por que parou, quando o modelo não disse.
|
|
221
|
+
*
|
|
222
|
+
* "Parei sem concluir e sem explicação do modelo" era a resposta para tudo, e
|
|
223
|
+
* quase sempre mentia: na prática o agente tinha gastado as 15 voltas dando
|
|
224
|
+
* murro em ponta de faca. Dizer o motivo certo é a diferença entre o usuário
|
|
225
|
+
* saber o que fazer e ficar no escuro.
|
|
226
|
+
*/
|
|
227
|
+
function motivoDaParada({ voltas, voltasEmFalso, falhasPendentes, travou }) {
|
|
228
|
+
if (falhasPendentes.length) {
|
|
229
|
+
return `Parei sem concluir. A última falha foi: ${falhasPendentes[falhasPendentes.length - 1].texto}`;
|
|
230
|
+
}
|
|
231
|
+
if (travou) {
|
|
232
|
+
return 'Travei repetindo as mesmas chamadas sem sair do lugar, e parei antes de gastar sua cota à toa. '
|
|
233
|
+
+ 'Peça em partes: primeiro a pesquisa, depois o que criar com ela.';
|
|
234
|
+
}
|
|
235
|
+
if (voltas >= MAX_ITERATIONS) {
|
|
236
|
+
const emFalso = voltasEmFalso
|
|
237
|
+
? ` ${voltasEmFalso} ${voltasEmFalso === 1 ? 'chamada foi repetida' : 'chamadas foram repetidas'} e não rodaram de novo.`
|
|
238
|
+
: '';
|
|
239
|
+
return `Gastei as ${MAX_ITERATIONS} voltas sem chegar ao fim.${emFalso}`
|
|
240
|
+
+ ' Peça em partes: primeiro a pesquisa, depois o que criar com ela.';
|
|
241
|
+
}
|
|
242
|
+
return 'Parei sem concluir e sem explicação do modelo. Tente pedir de novo, em partes menores.';
|
|
243
|
+
}
|
|
244
|
+
|
|
194
245
|
function buildMemoryContext(projectDir, projectName) {
|
|
195
246
|
const parts = [];
|
|
196
247
|
const geral = cfg.readGeneralMemory();
|
|
@@ -216,6 +267,10 @@ async function runAct(userPrompt, opts) {
|
|
|
216
267
|
let temPasta = !criarPasta; // já é a pasta definitiva?
|
|
217
268
|
const falhasPendentes = [];
|
|
218
269
|
let tentativasDeEncerrar = 0;
|
|
270
|
+
// Quem já foi chamado, e quantas vezes. Ver lib/repeticao.js.
|
|
271
|
+
const jaChamado = repeticao.novo();
|
|
272
|
+
let voltasEmFalso = 0;
|
|
273
|
+
let rodadasParadas = 0;
|
|
219
274
|
|
|
220
275
|
const messages = [];
|
|
221
276
|
if (!subagent) {
|
|
@@ -232,7 +287,9 @@ async function runAct(userPrompt, opts) {
|
|
|
232
287
|
// Pedido novo zera o pedido de parada anterior.
|
|
233
288
|
if (!subagent) PARAR = false;
|
|
234
289
|
|
|
290
|
+
let voltas = 0;
|
|
235
291
|
for (let iteration = 0; iteration < MAX_ITERATIONS && !PARAR; iteration++) {
|
|
292
|
+
voltas++;
|
|
236
293
|
const sp = new Spinner(subagent ? 'subagente trabalhando' : 'Primo Code pensando').start();
|
|
237
294
|
|
|
238
295
|
const toolCalls = [];
|
|
@@ -292,6 +349,7 @@ async function runAct(userPrompt, opts) {
|
|
|
292
349
|
});
|
|
293
350
|
|
|
294
351
|
let finished = null;
|
|
352
|
+
let algoRodou = false;
|
|
295
353
|
for (const tc of toolCalls) {
|
|
296
354
|
if (PARAR) break;
|
|
297
355
|
if (tc.name === 'finish') {
|
|
@@ -307,12 +365,14 @@ async function runAct(userPrompt, opts) {
|
|
|
307
365
|
});
|
|
308
366
|
continue;
|
|
309
367
|
}
|
|
368
|
+
algoRodou = true;
|
|
310
369
|
finished = (tc.arguments && tc.arguments.summary) || 'Concluído.';
|
|
311
370
|
messages.push({ role: 'tool', tool_call_id: tc.id, content: 'ok' });
|
|
312
371
|
continue;
|
|
313
372
|
}
|
|
314
373
|
|
|
315
374
|
if (tc.name === 'spawn_agent') {
|
|
375
|
+
algoRodou = true;
|
|
316
376
|
const r = await runSubagent(tc.arguments || {}, {
|
|
317
377
|
server, model, token, projectDir: pastaAtual, projectName, profundidade, motor,
|
|
318
378
|
});
|
|
@@ -320,6 +380,18 @@ async function runAct(userPrompt, opts) {
|
|
|
320
380
|
continue;
|
|
321
381
|
}
|
|
322
382
|
|
|
383
|
+
// Repetiu a mesma chamada? Não executa de novo. Rodar outra vez
|
|
384
|
+
// gastaria a cota para devolver exatamente o mesmo texto, e é
|
|
385
|
+
// esse texto que convence o modelo a repetir mais uma vez.
|
|
386
|
+
const eco = repeticao.ver(jaChamado, tc.name, tc.arguments);
|
|
387
|
+
if (eco.repetida) {
|
|
388
|
+
voltasEmFalso++;
|
|
389
|
+
console.log(ident(profundidade) + actionHeader(tc.name, tc.arguments));
|
|
390
|
+
console.log(ident(profundidade) + c.muted(' ⎿ ') + c.warn(`repetido (${eco.vezes}ª vez) — não rodei de novo`));
|
|
391
|
+
messages.push({ role: 'tool', tool_call_id: tc.id, content: eco.aviso });
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
323
395
|
if (criarPasta && catalogo.escreveArquivo(tc.name)) {
|
|
324
396
|
const nova = criarPasta();
|
|
325
397
|
if (nova) pastaAtual = nova;
|
|
@@ -327,6 +399,7 @@ async function runAct(userPrompt, opts) {
|
|
|
327
399
|
temPasta = true;
|
|
328
400
|
}
|
|
329
401
|
|
|
402
|
+
algoRodou = true;
|
|
330
403
|
console.log(ident(profundidade) + actionHeader(tc.name, tc.arguments));
|
|
331
404
|
const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
|
|
332
405
|
logResult(tc.name, result, profundidade);
|
|
@@ -365,6 +438,19 @@ async function runAct(userPrompt, opts) {
|
|
|
365
438
|
messages.push({ role: 'tool', tool_call_id: tc.id, content: summarizeForModel(result) });
|
|
366
439
|
}
|
|
367
440
|
|
|
441
|
+
// Uma volta inteira sem executar nada é o modelo preso. Insistir custa
|
|
442
|
+
// um pedido ao servidor por volta, e as 15 voltas saíam caras para
|
|
443
|
+
// terminar no mesmo lugar — melhor parar e devolver a vez ao usuário.
|
|
444
|
+
if (!algoRodou && toolCalls.length) {
|
|
445
|
+
rodadasParadas++;
|
|
446
|
+
if (rodadasParadas >= MAX_RODADAS_PARADAS) {
|
|
447
|
+
if (!subagent) console.log('\n' + c.warn(`Travei repetindo as mesmas chamadas ${rodadasParadas} voltas seguidas — parei aqui.`));
|
|
448
|
+
break;
|
|
449
|
+
}
|
|
450
|
+
} else {
|
|
451
|
+
rodadasParadas = 0;
|
|
452
|
+
}
|
|
453
|
+
|
|
368
454
|
if (finished) {
|
|
369
455
|
finalText = finished;
|
|
370
456
|
if (!subagent) console.log('\n' + c.white(finished));
|
|
@@ -382,9 +468,7 @@ async function runAct(userPrompt, opts) {
|
|
|
382
468
|
// não dá nem para saber que acabou. Medido: um pedido de navegador morria assim
|
|
383
469
|
// depois de a primeira tentativa de seletor falhar.
|
|
384
470
|
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.';
|
|
471
|
+
finalText = motivoDaParada({ voltas, voltasEmFalso, falhasPendentes, travou: rodadasParadas >= MAX_RODADAS_PARADAS });
|
|
388
472
|
if (!subagent) console.log('\n' + c.warn(finalText));
|
|
389
473
|
}
|
|
390
474
|
|
|
@@ -430,4 +514,4 @@ async function runSubagent(args, ctx) {
|
|
|
430
514
|
return resumo;
|
|
431
515
|
}
|
|
432
516
|
|
|
433
|
-
module.exports = { runAct, interromper, compactar };
|
|
517
|
+
module.exports = { runAct, interromper, compactar, motivoDaParada, MAX_ITERATIONS };
|
package/lib/api.js
CHANGED
|
@@ -128,6 +128,13 @@ function requestChat(serverUrl, payload, onEvent) {
|
|
|
128
128
|
return requestStream(serverUrl, '/api/chat/stream', body, onEvent, token);
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
// Resumo incremental da conversa. Vai e volta pequeno: manda o resumo de
|
|
132
|
+
// antes + o que é novo, recebe o resumo atualizado.
|
|
133
|
+
function requestResumo(serverUrl, payload) {
|
|
134
|
+
const { token, ...body } = payload;
|
|
135
|
+
return request(serverUrl, '/api/resumir', body, token);
|
|
136
|
+
}
|
|
137
|
+
|
|
131
138
|
function getHealth(serverUrl, token) {
|
|
132
139
|
return new Promise((resolve, reject) => {
|
|
133
140
|
const url = new URL('/health', serverUrl);
|
|
@@ -144,4 +151,4 @@ function getHealth(serverUrl, token) {
|
|
|
144
151
|
});
|
|
145
152
|
}
|
|
146
153
|
|
|
147
|
-
module.exports = { request, requestStream, requestAct, requestChat, getHealth };
|
|
154
|
+
module.exports = { request, requestStream, requestAct, requestChat, requestResumo, getHealth };
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* memoria-conversa.js — a conversa que não engorda.
|
|
3
|
+
*
|
|
4
|
+
* ── O PROBLEMA ───────────────────────────────────────────────────────────
|
|
5
|
+
* O histórico ia inteiro (as últimas 20 mensagens) em todo pedido. Numa
|
|
6
|
+
* conversa de trabalho isso vira milhares de tokens repetidos a cada rodada, e
|
|
7
|
+
* num plano com teto por minuto a tarefa morre no meio — depois de o esforço
|
|
8
|
+
* já ter sido gasto. Cortar pelas últimas N é pior ainda: some justamente o
|
|
9
|
+
* começo, onde está o que o usuário pediu.
|
|
10
|
+
*
|
|
11
|
+
* ── A IDEIA (do dono) ────────────────────────────────────────────────────
|
|
12
|
+
* Resumo INCREMENTAL, como um /compact automático: guarda-se um resumo do que
|
|
13
|
+
* já passou e, a cada rodada, só o PEDAÇO NOVO é dobrado nele. A primeira vez
|
|
14
|
+
* custa; da segunda em diante o resumo já existe e só o que foi dito a mais
|
|
15
|
+
* precisa ser lido.
|
|
16
|
+
*
|
|
17
|
+
* O custo fica limitado por construção: a entrada do resumo é sempre
|
|
18
|
+
* "resumo de antes + o que aconteceu desde então", nunca a conversa inteira.
|
|
19
|
+
*
|
|
20
|
+
* ── O QUE NUNCA É RESUMIDO ───────────────────────────────────────────────
|
|
21
|
+
* As últimas rodadas vão CRUAS. Resumo é bom para lembrar do que foi feito,
|
|
22
|
+
* péssimo para continuar de onde parou: "agora põe música de fundo" precisa da
|
|
23
|
+
* frase anterior literal, não de um tópico dizendo que houve um vídeo.
|
|
24
|
+
*
|
|
25
|
+
* Este arquivo é puro — quem fala com o servidor é quem chama `atualizar`.
|
|
26
|
+
* Assim o teste mede a política inteira sem rede e sem chave de API.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
'use strict';
|
|
30
|
+
|
|
31
|
+
/** ~4 caracteres por token. Mesma conta do servidor, e boa o bastante aqui. */
|
|
32
|
+
function tokensDe(x) {
|
|
33
|
+
const texto = typeof x === 'string' ? x : JSON.stringify(x || '');
|
|
34
|
+
return Math.ceil(texto.length / 4);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Quantas mensagens do fim ficam sempre cruas (o "de onde parei").
|
|
38
|
+
const CRUAS = 4;
|
|
39
|
+
// Acima disto, a cauda vira resumo. Abaixo, não compensa pagar a chamada.
|
|
40
|
+
const LIMITE_TOKENS = 700;
|
|
41
|
+
|
|
42
|
+
/** Estado inicial — o que o CLI guarda entre um pedido e outro. */
|
|
43
|
+
function nova() {
|
|
44
|
+
return { resumo: '', resumidasAte: 0 };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* O que ainda não foi resumido e já não cabe entre as cruas.
|
|
49
|
+
* É exatamente o que a próxima chamada de resumo precisa ler.
|
|
50
|
+
*/
|
|
51
|
+
function pendentes(memoria, history) {
|
|
52
|
+
const fim = Math.max(0, (history || []).length - CRUAS);
|
|
53
|
+
if (fim <= memoria.resumidasAte) return [];
|
|
54
|
+
return history.slice(memoria.resumidasAte, fim);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Vale a pena pagar uma chamada de resumo agora? */
|
|
58
|
+
function precisaResumir(memoria, history) {
|
|
59
|
+
const novas = pendentes(memoria, history);
|
|
60
|
+
if (!novas.length) return false;
|
|
61
|
+
return tokensDe(novas) >= LIMITE_TOKENS;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Dobra o pedaço novo no resumo. `resumir` é quem fala com o servidor:
|
|
66
|
+
* recebe { resumo, mensagens } e devolve o texto do resumo atualizado.
|
|
67
|
+
*
|
|
68
|
+
* Devolve SEMPRE uma memória utilizável — se o resumo falhar (rede, cota), a
|
|
69
|
+
* anterior continua valendo e a conversa segue com o histórico cru.
|
|
70
|
+
*/
|
|
71
|
+
async function atualizar(memoria, history, resumir) {
|
|
72
|
+
const novas = pendentes(memoria, history);
|
|
73
|
+
if (!novas.length) return memoria;
|
|
74
|
+
|
|
75
|
+
let texto;
|
|
76
|
+
try {
|
|
77
|
+
texto = await resumir({ resumo: memoria.resumo, mensagens: novas });
|
|
78
|
+
} catch {
|
|
79
|
+
return memoria;
|
|
80
|
+
}
|
|
81
|
+
const limpo = String(texto || '').trim();
|
|
82
|
+
if (!limpo) return memoria;
|
|
83
|
+
|
|
84
|
+
return { resumo: limpo, resumidasAte: (history || []).length - CRUAS };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Onde a parte crua pode começar sem deixar resultado órfão.
|
|
89
|
+
*
|
|
90
|
+
* A API recusa um `role:'tool'` sem o turno do assistente que o pediu. Cortar
|
|
91
|
+
* "as últimas N" cai nisso na hora em que a conta não bate — então o começo
|
|
92
|
+
* recua até um ponto onde a rodada está inteira.
|
|
93
|
+
*/
|
|
94
|
+
function inicioSeguro(todas, alvo) {
|
|
95
|
+
let i = Math.max(0, Math.min(alvo, todas.length));
|
|
96
|
+
while (i > 0 && todas[i] && todas[i].role === 'tool') i--;
|
|
97
|
+
return i;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* O histórico que vai no pedido: o resumo (se houver) + as últimas cruas.
|
|
102
|
+
*
|
|
103
|
+
* O resumo entra como mensagem de sistema, não como fala do usuário — o modelo
|
|
104
|
+
* precisa saber que aquilo é memória, e não algo que alguém acabou de dizer.
|
|
105
|
+
*/
|
|
106
|
+
function paraEnviar(memoria, history) {
|
|
107
|
+
const todas = history || [];
|
|
108
|
+
if (!memoria || !memoria.resumo) return todas.slice(inicioSeguro(todas, todas.length - 20));
|
|
109
|
+
|
|
110
|
+
const cruas = todas.slice(inicioSeguro(todas, todas.length - CRUAS));
|
|
111
|
+
return [
|
|
112
|
+
{ role: 'system', content: `Resumo da conversa até aqui (memória, não é fala de ninguém):\n${memoria.resumo}` },
|
|
113
|
+
...cruas,
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Quanto este jeito economiza — para o /status e para o teste provarem. */
|
|
118
|
+
function economia(memoria, history) {
|
|
119
|
+
const cheio = tokensDe((history || []).slice(-20));
|
|
120
|
+
const enxuto = tokensDe(paraEnviar(memoria, history));
|
|
121
|
+
return { cheio, enxuto, poupado: Math.max(0, cheio - enxuto) };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = { nova, pendentes, precisaResumir, atualizar, paraEnviar, economia, tokensDe, inicioSeguro, CRUAS, LIMITE_TOKENS };
|
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.4.1",
|
|
4
4
|
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
|
|
5
5
|
"main": "bin/primocode.js",
|
|
6
6
|
"bin": {
|
|
@@ -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"
|
|
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"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|