primocode 8.4.0 → 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/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 };
@@ -0,0 +1,186 @@
1
+ /**
2
+ * repeticao.js — o agente não pode ficar dando voltas.
3
+ *
4
+ * ── O QUE ACONTECEU DE VERDADE ───────────────────────────────────────────
5
+ * Pedido real: "pesquise sobre a paris group e crie um vídeo para anunciar a
6
+ * imersão founders ai". O agente pesquisou, leu a spec da ferramenta de vídeo,
7
+ * pesquisou de novo, leu a spec de novo — sete vezes — e morreu sem criar nada.
8
+ *
9
+ * A culpa não era do modelo. Era nossa. `compactar` encolhe os resultados
10
+ * antigos para a conversa caber no teto do plano, e a spec do estúdio é um
11
+ * resultado como outro qualquer: depois de três tools novas, ela virava 300
12
+ * caracteres. O modelo então não tinha mais o formato do documento para
13
+ * escrever o vídeo — e fazia a única coisa sensata: pedir a spec de novo. Que
14
+ * era encolhida de novo. Para sempre.
15
+ *
16
+ * ── AS DUAS DEFESAS ──────────────────────────────────────────────────────
17
+ * 1. REFERÊNCIA NÃO ENCOLHE. O manual e as specs do estúdio são documentos
18
+ * fixos: mesma pergunta, mesma resposta, sempre. A cópia mais nova de cada
19
+ * uma fica INTEIRA, e as cópias velhas somem (viram um ponteiro). Isso
20
+ * corta o motivo do loop e ainda economiza — sete cópias da mesma spec
21
+ * eram sete vezes o mesmo texto.
22
+ *
23
+ * 2. CHAMADA REPETIDA NÃO EXECUTA. Se ainda assim o modelo repetir a mesma
24
+ * tool com os mesmos argumentos, não roda de novo: ele leva um aviso que
25
+ * aperta a cada volta, até virar "produza agora com o que você já tem".
26
+ * Vale também para pesquisa quase igual — "paris group founders ai
27
+ * immersion" e "paris group company ai founders immersion" são a mesma
28
+ * busca, e as duas devolviam as mesmas três fontes.
29
+ *
30
+ * Puro de propósito: sem rede e sem I/O, para o teste medir a política inteira.
31
+ */
32
+
33
+ 'use strict';
34
+
35
+ /** Tools cuja resposta é documento fixo — repetir é sempre desperdício. */
36
+ const REFERENCIAS = new Set(['studio_manual', 'studio_spec']);
37
+
38
+ /** Acima disto, duas buscas são a mesma busca. */
39
+ const LIMITE_SEMELHANCA = 0.6;
40
+
41
+ /** Palavras que não distinguem uma busca da outra. */
42
+ const VAZIAS = new Set(['de', 'da', 'do', 'das', 'dos', 'a', 'o', 'as', 'os', 'e', 'em',
43
+ 'para', 'por', 'com', 'sobre', 'the', 'of', 'and', 'for', 'about', 'que', 'na', 'no']);
44
+
45
+ function normalizar(texto) {
46
+ return String(texto == null ? '' : texto)
47
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '')
48
+ .toLowerCase().replace(/\s+/g, ' ').trim();
49
+ }
50
+
51
+ /** As palavras que de fato dizem o que a busca procura. */
52
+ function palavras(texto) {
53
+ return new Set(normalizar(texto).split(/[^a-z0-9]+/)
54
+ .filter((p) => p.length >= 3 && !VAZIAS.has(p)));
55
+ }
56
+
57
+ /** Quanto duas buscas se sobrepõem, de 0 a 1. */
58
+ function semelhanca(a, b) {
59
+ const A = palavras(a), B = palavras(b);
60
+ if (!A.size || !B.size) return A.size === B.size ? 1 : 0;
61
+ let juntos = 0;
62
+ for (const p of A) if (B.has(p)) juntos++;
63
+ return juntos / (A.size + B.size - juntos);
64
+ }
65
+
66
+ /** A mesma chamada com as chaves em qualquer ordem tem a mesma assinatura. */
67
+ function assinatura(nome, args) {
68
+ const a = args && typeof args === 'object' ? args : {};
69
+ const partes = Object.keys(a).sort()
70
+ .map((k) => `${k}=${normalizar(typeof a[k] === 'string' ? a[k] : JSON.stringify(a[k]))}`);
71
+ return `${nome}(${partes.join('&')})`;
72
+ }
73
+
74
+ /** O texto que uma chamada de pesquisa procura, seja qual for o apelido do campo. */
75
+ function consultaDe(nome, args) {
76
+ if (nome !== 'pesquisar_referencia') return null;
77
+ const a = args || {};
78
+ return String(a.consulta || a.query || '');
79
+ }
80
+
81
+ function novo() {
82
+ return { vistos: new Map(), buscas: [] };
83
+ }
84
+
85
+ /**
86
+ * Registra a chamada e diz se ela é repetição.
87
+ *
88
+ * Devolve { repetida, vezes, aviso }. Quando `repetida` é true, quem chamou
89
+ * NÃO deve executar a tool: manda o `aviso` de volta como resultado.
90
+ */
91
+ function ver(registro, nome, args) {
92
+ const chave = assinatura(nome, args);
93
+ const busca = consultaDe(nome, args);
94
+
95
+ let vezes = (registro.vistos.get(chave) || 0) + 1;
96
+ let parecidaCom = null;
97
+
98
+ // Pesquisa quase igual conta como a mesma pesquisa: o buscador devolve as
99
+ // mesmas fontes, e o modelo fica convencido de que "ainda não pesquisou".
100
+ if (vezes === 1 && busca) {
101
+ const irma = registro.buscas.find((b) => semelhanca(b.texto, busca) >= LIMITE_SEMELHANCA);
102
+ if (irma) {
103
+ irma.vezes++;
104
+ vezes = irma.vezes;
105
+ parecidaCom = irma.texto;
106
+ } else {
107
+ registro.buscas.push({ texto: busca, vezes: 1 });
108
+ }
109
+ }
110
+
111
+ registro.vistos.set(chave, vezes);
112
+ if (vezes === 1) return { repetida: false, vezes, aviso: null };
113
+
114
+ return { repetida: true, vezes, aviso: aviso(nome, vezes, parecidaCom) };
115
+ }
116
+
117
+ /** O recado, apertando a cada volta. */
118
+ function aviso(nome, vezes, parecidaCom) {
119
+ const oQue = parecidaCom
120
+ ? `Você já pesquisou isto: "${parecidaCom}". A busca de agora é a mesma coisa com outras palavras, e traria as mesmas fontes.`
121
+ : `Você já chamou ${nome} com estes mesmos argumentos.`;
122
+
123
+ if (vezes === 2) {
124
+ return `${oQue} O resultado está na conversa acima — role para cima e use o que já tem. `
125
+ + 'Não chame esta tool de novo com os mesmos argumentos.';
126
+ }
127
+ return `${oQue} Esta é a ${vezes}ª vez, e a tool NÃO foi executada. PARE de repetir: `
128
+ + 'com o que você já tem, produza AGORA o resultado que o usuário pediu (crie o arquivo, o documento, o vídeo). '
129
+ + 'Se algo realmente impede, chame finish e diga ao usuário o que faltou.';
130
+ }
131
+
132
+ /**
133
+ * Mapa tool_call_id -> { nome, args }, lido dos turnos do assistente.
134
+ *
135
+ * A mensagem role:'tool' guarda só o id; para saber se ela é uma referência é
136
+ * preciso achar a chamada que a pediu.
137
+ */
138
+ function chamadasPorId(messages) {
139
+ const mapa = new Map();
140
+ for (const m of messages || []) {
141
+ if (!m || !m.tool_calls) continue;
142
+ for (const tc of m.tool_calls) {
143
+ const f = tc.function || {};
144
+ let args = {};
145
+ try { args = typeof f.arguments === 'string' ? JSON.parse(f.arguments) : (f.arguments || {}); } catch {}
146
+ mapa.set(tc.id, { nome: f.name || tc.name, args });
147
+ }
148
+ }
149
+ return mapa;
150
+ }
151
+
152
+ /**
153
+ * Onde estão as referências na conversa.
154
+ *
155
+ * Devolve { inteiras, duplicadas }: os índices que NÃO podem encolher (a cópia
156
+ * mais nova de cada referência) e os que podem sumir de vez (as cópias velhas
157
+ * da mesma coisa — texto idêntico, repetido).
158
+ */
159
+ function referenciasNaConversa(messages) {
160
+ const chamadas = chamadasPorId(messages);
161
+ const ultima = new Map();
162
+ const todas = new Map();
163
+
164
+ (messages || []).forEach((m, i) => {
165
+ if (!m || m.role !== 'tool') return;
166
+ const ch = chamadas.get(m.tool_call_id);
167
+ if (!ch || !REFERENCIAS.has(ch.nome)) return;
168
+ const chave = assinatura(ch.nome, ch.args);
169
+ ultima.set(chave, i);
170
+ if (!todas.has(chave)) todas.set(chave, []);
171
+ todas.get(chave).push(i);
172
+ });
173
+
174
+ const inteiras = new Set(ultima.values());
175
+ const duplicadas = new Set();
176
+ for (const [chave, indices] of todas) {
177
+ for (const i of indices) if (i !== ultima.get(chave)) duplicadas.add(i);
178
+ }
179
+ return { inteiras, duplicadas };
180
+ }
181
+
182
+ module.exports = {
183
+ REFERENCIAS, LIMITE_SEMELHANCA,
184
+ normalizar, palavras, semelhanca, assinatura, consultaDe,
185
+ novo, ver, chamadasPorId, referenciasNaConversa,
186
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.4.0",
3
+ "version": "8.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 && 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"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"