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 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.slice(-20),
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 (!encolher.has(i)) return m;
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 <= RESTO_DO_RESULTADO) return m;
190
- return { ...m, content: texto.slice(0, RESTO_DO_RESULTADO) + `…(resultado antigo encurtado; eram ${texto.length} caracteres)` };
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.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.';
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 };
@@ -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.2.2",
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"