primocode 8.4.1 → 8.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/primocode.js CHANGED
@@ -538,7 +538,11 @@ async function sendPrompt(userText) {
538
538
  // conversa inteira.
539
539
  await comprimirConversa();
540
540
 
541
- const r = await runAct(prompt, {
541
+ // O pedido passa por uma cabeça antes de virar trabalho: conserta o
542
+ // que está torto, diz qual é o entregável e o que conta como pronto.
543
+ const prompt2 = await refinarPedido(prompt);
544
+
545
+ const r = await runAct(prompt2, {
542
546
  server: state.config.server,
543
547
  model: state.config.model,
544
548
  effort: state.config.effort,
@@ -564,6 +568,39 @@ async function sendPrompt(userText) {
564
568
  refreshPrompt();
565
569
  }
566
570
 
571
+ /**
572
+ * Estrutura o pedido antes de mandar o agente executar.
573
+ *
574
+ * Gente pede curto e torto — "cria um vidro anunciando o founders ai". Sem
575
+ * isto o agente adivinha, e adivinha errado: sai o genérico, ou ele pesquisa
576
+ * em círculo porque não sabe o que é "pronto". Uma passada barata antes de
577
+ * começar vira ordem de serviço: o que entregar, com que fatos, quando parar.
578
+ *
579
+ * Mostra o que mudou — o usuário precisa poder discordar do que foi entendido,
580
+ * não descobrir no fim que o agente fez outra coisa.
581
+ *
582
+ * Nunca bloqueia: se o refino falhar ou vier estranho, vale o pedido original.
583
+ */
584
+ async function refinarPedido(cru) {
585
+ if (!cru || cru.trim().length < 12) return cru;
586
+ const sp = new Spinner('entendendo o pedido').start();
587
+ try {
588
+ const r = await api.requestRefino(state.config.server, {
589
+ prompt: cru, model: state.config.model, token: state.config.token,
590
+ });
591
+ sp.stop();
592
+ if (!r || !r.refinado || !r.prompt) return cru;
593
+ console.log(c.dim(' ⎿ entendi assim:'));
594
+ for (const linha of String(r.prompt).split('\n')) {
595
+ if (linha.trim()) console.log(c.dim(' ' + linha.trim()));
596
+ }
597
+ return r.prompt;
598
+ } catch {
599
+ sp.stop(); // refinar é melhoria, não requisito
600
+ return cru;
601
+ }
602
+ }
603
+
567
604
  /**
568
605
  * Atualiza o resumo da conversa, se já compensar.
569
606
  *
@@ -585,7 +622,12 @@ async function comprimirConversa() {
585
622
  }
586
623
  }
587
624
 
625
+ // O chat também precisa parar no Esc Esc. Sem isto o texto continuava saindo
626
+ // na tela depois de o usuário ter mandado parar.
627
+ let chatEmVoo = null;
628
+
588
629
  async function runChat(promptForModel, userTextForMemory) {
630
+ chatEmVoo = new AbortController();
589
631
  const sp = new Spinner('pensando').start();
590
632
  let started = false;
591
633
  let text = '';
@@ -595,6 +637,7 @@ async function runChat(promptForModel, userTextForMemory) {
595
637
  history: memoria.paraEnviar(state.memoria, state.history),
596
638
  model: state.config.model,
597
639
  token: state.config.token,
640
+ sinal: chatEmVoo.signal,
598
641
  }, (evt) => {
599
642
  if (evt.erro) { if (!started) sp.stop(); console.log('\n' + c.err(ui.friendlyError(evt.erro))); return; }
600
643
  if (evt.delta) {
@@ -1066,6 +1109,7 @@ async function main() {
1066
1109
  if (key.sequence === '\x1b\x1b' || agora - ultimoEsc < 2000) {
1067
1110
  ultimoEsc = 0;
1068
1111
  interromper();
1112
+ if (chatEmVoo) { try { chatEmVoo.abort(); } catch {} }
1069
1113
  process.stdout.write('\n' + c.warn(' ⏹ parando o agente…') + '\n');
1070
1114
  } else {
1071
1115
  ultimoEsc = agora;
package/lib/act.js CHANGED
@@ -28,7 +28,14 @@ const MAX_PROFUNDIDADE = 2; // principal + 1 nível de subagente
28
28
  // fez, ficou feito). A flag é do módulo de propósito: parar o principal para
29
29
  // os subagentes junto.
30
30
  let PARAR = false;
31
- function interromper() { PARAR = true; }
31
+ // O pedido que está em voo agora. Sem isto o Esc Esc só valia entre uma
32
+ // rodada e outra: o modelo seguia escrevendo do outro lado e o usuário ficava
33
+ // vendo o spinner girar depois de ter mandado parar.
34
+ let emVoo = null;
35
+ function interromper() {
36
+ PARAR = true;
37
+ if (emVoo) { try { emVoo.abort(); } catch {} }
38
+ }
32
39
 
33
40
  function ident(profundidade) {
34
41
  return ' '.repeat(profundidade);
@@ -297,8 +304,9 @@ async function runAct(userPrompt, opts) {
297
304
  let streamError = null;
298
305
  let streaming = false;
299
306
 
307
+ emVoo = new AbortController();
300
308
  try {
301
- 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) => {
302
310
  if (evt.erro) { streamError = evt.erro; return; }
303
311
  if (evt.tool_call) { toolCalls.push(evt.tool_call); return; }
304
312
  if (evt.delta) {
@@ -316,6 +324,8 @@ async function runAct(userPrompt, opts) {
316
324
  throw e;
317
325
  }
318
326
  sp.stop();
327
+ emVoo = null;
328
+ if (PARAR) break;
319
329
 
320
330
  if (streamError) {
321
331
  const amigavel = friendlyError(streamError);
package/lib/api.js CHANGED
@@ -57,8 +57,17 @@ function request(serverUrl, endpoint, payload, token) {
57
57
  });
58
58
  }
59
59
 
60
- function requestStream(serverUrl, endpoint, payload, onEvent, token) {
60
+ /**
61
+ * `sinal` (AbortSignal) corta o pedido NA HORA.
62
+ *
63
+ * Sem ele o Esc Esc só era visto entre uma rodada e outra: o modelo continuava
64
+ * escrevendo do outro lado e o usuário ficava olhando o spinner girar depois
65
+ * de ter mandado parar. Abortar não é erro — resolve normal, e quem chamou
66
+ * segue para a checagem de parada.
67
+ */
68
+ function requestStream(serverUrl, endpoint, payload, onEvent, token, sinal) {
61
69
  return new Promise((resolve, reject) => {
70
+ if (sinal && sinal.aborted) return resolve();
62
71
  const url = new URL(endpoint, serverUrl);
63
72
  const data = JSON.stringify(payload);
64
73
  const client = url.protocol === 'https:' ? https : http;
@@ -110,7 +119,19 @@ function requestStream(serverUrl, endpoint, payload, onEvent, token) {
110
119
  res.on('error', reject);
111
120
  });
112
121
 
113
- req.on('error', reject);
122
+ // Abortar fecha o socket e encerra a promessa como sucesso: quem pediu
123
+ // para parar não quer ver mensagem de erro.
124
+ let cortado = false;
125
+ const cortar = () => {
126
+ if (cortado) return;
127
+ cortado = true;
128
+ try { req.destroy(); } catch {}
129
+ resolve();
130
+ };
131
+ if (sinal) sinal.addEventListener('abort', cortar, { once: true });
132
+
133
+ req.on('error', (e) => { if (!cortado) reject(e); });
134
+ req.on('close', () => { if (sinal) sinal.removeEventListener('abort', cortar); });
114
135
  req.write(data);
115
136
  req.end();
116
137
  });
@@ -118,14 +139,14 @@ function requestStream(serverUrl, endpoint, payload, onEvent, token) {
118
139
 
119
140
  // Agente autônomo. `payload` carrega a conversa completa (messages).
120
141
  function requestAct(serverUrl, payload, onEvent) {
121
- const { token, ...body } = payload;
122
- return requestStream(serverUrl, '/api/act/stream', body, onEvent, token);
142
+ const { token, sinal, ...body } = payload;
143
+ return requestStream(serverUrl, '/api/act/stream', body, onEvent, token, sinal);
123
144
  }
124
145
 
125
146
  // Chat puro.
126
147
  function requestChat(serverUrl, payload, onEvent) {
127
- const { token, ...body } = payload;
128
- return requestStream(serverUrl, '/api/chat/stream', body, onEvent, token);
148
+ const { token, sinal, ...body } = payload;
149
+ return requestStream(serverUrl, '/api/chat/stream', body, onEvent, token, sinal);
129
150
  }
130
151
 
131
152
  // Resumo incremental da conversa. Vai e volta pequeno: manda o resumo de
@@ -135,6 +156,12 @@ function requestResumo(serverUrl, payload) {
135
156
  return request(serverUrl, '/api/resumir', body, token);
136
157
  }
137
158
 
159
+ // Refino do pedido: manda o texto cru, recebe a ordem de serviço.
160
+ function requestRefino(serverUrl, payload) {
161
+ const { token, ...body } = payload;
162
+ return request(serverUrl, '/api/refinar', body, token);
163
+ }
164
+
138
165
  function getHealth(serverUrl, token) {
139
166
  return new Promise((resolve, reject) => {
140
167
  const url = new URL('/health', serverUrl);
@@ -151,4 +178,4 @@ function getHealth(serverUrl, token) {
151
178
  });
152
179
  }
153
180
 
154
- module.exports = { request, requestStream, requestAct, requestChat, requestResumo, getHealth };
181
+ module.exports = { request, requestStream, requestAct, requestChat, requestResumo, requestRefino, getHealth };
package/lib/referencia.js CHANGED
@@ -59,6 +59,10 @@ function hostBloqueado(hostname) {
59
59
  const TIMEOUT_MS = 12000;
60
60
  const MAX_POR_FONTE = 4;
61
61
  const MAX_TEXTO = 4500;
62
+ // Quanto HTML baixar antes de parar. Só os primeiros MAX_TEXTO caracteres de
63
+ // TEXTO viram referência, e 400 KB de HTML dão isso com folga em qualquer
64
+ // página — inclusive nas de Wikipédia, que passam desse tamanho.
65
+ const LIMITE_CORPO = 400000;
62
66
 
63
67
  // Rede de empresa costuma exigir proxy. Sem isto, a pesquisa simplesmente não
64
68
  // funciona em metade dos computadores corporativos — e o usuário nunca
@@ -136,13 +140,25 @@ function pegar(url, { timeout = TIMEOUT_MS, saltos = 0 } = {}) {
136
140
  }
137
141
  if (res.statusCode !== 200) { res.resume(); return resolve(null); }
138
142
  let corpo = '';
143
+ let pronto = false;
144
+ const entregar = (valor) => { if (!pronto) { pronto = true; resolve(valor); } };
139
145
  res.setEncoding('utf8');
140
146
  res.on('data', (c) => {
141
147
  corpo += c;
142
- if (corpo.length > 400000) { req.destroy(); } // página gigante não serve
148
+ // Página grande: PARA de baixar e entrega o que já veio.
149
+ //
150
+ // Antes isto era `req.destroy()` sem entregar nada, e o
151
+ // destroy caía no req.on('error') -> resolve(null). Ou
152
+ // seja: TODA página acima de 400 KB era jogada fora
153
+ // inteira. Artigo de Wikipédia passa desse tamanho, então
154
+ // toda pesquisa que caía nela voltava "achei os links mas
155
+ // não consegui abrir nenhum" — com a página no ar e o
156
+ // conteúdo já na mão. E precisamos só dos primeiros
157
+ // milhares de caracteres.
158
+ if (corpo.length > LIMITE_CORPO) { res.destroy(); entregar(corpo); }
143
159
  });
144
- res.on('end', () => resolve(corpo));
145
- res.on('error', () => resolve(null));
160
+ res.on('end', () => entregar(corpo));
161
+ res.on('error', () => entregar(corpo || null));
146
162
  });
147
163
  } catch { return resolve(null); }
148
164
  req.on('timeout', () => { req.destroy(); resolve(null); });
@@ -151,6 +167,21 @@ function pegar(url, { timeout = TIMEOUT_MS, saltos = 0 } = {}) {
151
167
  });
152
168
  }
153
169
 
170
+ /**
171
+ * Isto é texto para ler, ou sobra de código?
172
+ *
173
+ * Site feito em construtor visual despeja CSS e JSON no corpo da página. Sem
174
+ * este filtro o "trecho" entregue ao modelo começava com
175
+ * `{"css":"@media all { *#dm .dmInner ...` — e era isso que ele levava como
176
+ * referência para escrever o vídeo.
177
+ */
178
+ function pareceCodigo(texto) {
179
+ const amostra = texto.slice(0, 1200);
180
+ if (!amostra) return true;
181
+ const simbolos = (amostra.match(/[{};:_#]/g) || []).length;
182
+ return simbolos / amostra.length > 0.08;
183
+ }
184
+
154
185
  /** HTML → texto legível. Sem dependência: corta script/style e as tags. */
155
186
  function textoDe(html) {
156
187
  return String(html || '')
@@ -165,6 +196,67 @@ function textoDe(html) {
165
196
  .trim();
166
197
  }
167
198
 
199
+ /**
200
+ * ── FONTE ERRADA É PIOR QUE FONTE NENHUMA ────────────────────────────────
201
+ * Pedido real: "pesquise sobre a Paris Group e crie um vídeo para anunciar a
202
+ * imersão founders ai". Os buscadores não acharam a empresa e a busca de
203
+ * texto da Wikipédia devolveu o que continha aquelas palavras soltas: Milan,
204
+ * History of autism, Berlitz Corporation, 42 (school). O agente ia escrever
205
+ * um vídeo com AQUILO. Melhor devolver "não achei" e deixar o modelo dizer
206
+ * isso ao usuário do que entregar fonte que não é sobre o assunto.
207
+ */
208
+ function palavrasDaConsulta(consulta) {
209
+ return String(consulta || '')
210
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
211
+ .split(/[^a-z0-9]+/)
212
+ .filter((p) => p.length >= 3 && !GENERICAS.has(p));
213
+ }
214
+
215
+ // Palavras que aparecem em qualquer pesquisa e não provam relevância nenhuma.
216
+ const GENERICAS = new Set(['sobre', 'empresa', 'company', 'overview', 'about', 'the', 'and',
217
+ 'para', 'com', 'que', 'dos', 'das', 'quem', 'programa', 'program', 'site', 'oficial']);
218
+
219
+ /**
220
+ * O link fala mesmo do que foi pedido?
221
+ *
222
+ * Exige DUAS palavras distintas da consulta no título ou na URL — uma só é
223
+ * coincidência ("Paris - Wikipedia" para "Paris Group founders AI" bate em
224
+ * "paris" e não tem nada a ver). Consulta de uma palavra só se contenta com
225
+ * ela mesma.
226
+ */
227
+ function relevante(cand, palavras) {
228
+ if (!palavras.length) return true;
229
+ const alvo = `${cand.titulo || ''} ${cand.url || ''}`
230
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase();
231
+ let batidas = 0;
232
+ for (const p of palavras) if (alvo.includes(p)) batidas++;
233
+ return batidas >= Math.min(2, palavras.length);
234
+ }
235
+
236
+ /**
237
+ * O site provável de uma empresa, tentado direto — sem buscador nenhum.
238
+ *
239
+ * É como uma pessoa faria: "Paris Group" -> parisgroup.com, parisgroup.ai.
240
+ * Foi o que salvou este caso — parisgroup.ai abre e tem o texto todo, enquanto
241
+ * DuckDuckGo devolvia 202 e o Bing devolvia Paris, a cidade.
242
+ *
243
+ * Só dispara quando a consulta tem DUAS PALAVRAS COM INICIAL MAIÚSCULA
244
+ * seguidas, que é como se escreve nome de empresa. "react hooks tutorial" não
245
+ * dispara; "Paris Group" dispara.
246
+ */
247
+ function sitesProvaveis(consulta) {
248
+ const nome = /\b([A-ZÀ-Ý][\w\u00C0-\u024F]{1,})\s+([A-ZÀ-Ý][\w\u00C0-\u024F]{1,})\b/.exec(String(consulta || ''));
249
+ if (!nome) return [];
250
+ const slug = `${nome[1]}${nome[2]}`
251
+ .normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
252
+ if (slug.length < 5) return [];
253
+ return ['com', 'ai', 'com.br', 'io', 'co'].map((tld) => ({
254
+ titulo: `${nome[1]} ${nome[2]} — site oficial`,
255
+ url: `https://www.${slug}.${tld}`,
256
+ nota: 'site provável da empresa',
257
+ }));
258
+ }
259
+
168
260
  // Links que não são referência de nada — resultado de busca sujo.
169
261
  const LIXO = /(facebook|twitter|x)\.com|instagram|pinterest|youtube\.com\/(?!watch)|\/search\?|duckduckgo|bing\.com\/(?!ck)|google\./i;
170
262
 
@@ -256,6 +348,15 @@ async function buscarBing(consulta, limite) {
256
348
  const html = await pegar(`https://www.bing.com/search?q=${encodeURIComponent(consulta)}&setlang=en`);
257
349
  if (!html) return [];
258
350
  const pares = [];
351
+ // O Bing largou o <h2><a href> e hoje mostra a URL num <cite>, com " › "
352
+ // no lugar da barra. O parser antigo casava ZERO resultados numa página
353
+ // que trazia dez — e a pesquisa caía na Wikipédia, que era o lixo.
354
+ const cites = html.match(/<cite[^>]*>[\s\S]{0,200}?<\/cite>/gi) || [];
355
+ for (const bruto of cites) {
356
+ const mostrado = textoDe(bruto);
357
+ if (!/^https?:\/\//i.test(mostrado) || /[…]/.test(mostrado)) continue;
358
+ pares.push({ titulo: mostrado.replace(/\s*›\s*/g, ' / '), url: mostrado.replace(/\s*›\s*/g, '/') });
359
+ }
259
360
  const re = /<h2>\s*<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
260
361
  let m;
261
362
  while ((m = re.exec(html))) pares.push({ titulo: textoDe(m[2]), url: m[1] });
@@ -352,8 +453,19 @@ async function pesquisar({ consulta, paginas = 3 }) {
352
453
  // portas vieram vazias: ela é a melhor fonte, e é o que salva a pesquisa
353
454
  // quando os links do buscador não abrem (rede corporativa, site fora do
354
455
  // ar, bloqueio). Foi assim que a pesquisa falhava mesmo achando links.
355
- const candidatos = [...web, ...so, ...docsConhecidas(termo)]
356
- .slice(0, Math.max(2, Math.min(8, paginas + 4)));
456
+ // O site provável da empresa vai NA FRENTE: quando a consulta nomeia um
457
+ // negócio, o site dele é a melhor fonte que existe, e não depende de
458
+ // buscador nenhum estar de bom humor.
459
+ const chutes = sitesProvaveis(termo);
460
+ const palavras = palavrasDaConsulta(termo);
461
+ const brutos = [...chutes, ...web, ...so, ...docsConhecidas(termo)];
462
+
463
+ // Fora o que não é sobre o assunto. Sem isto o modelo recebia "Milan" e
464
+ // "History of autism" para uma pesquisa sobre uma empresa, e escrevia o
465
+ // vídeo com aquilo.
466
+ const filtrados = brutos.filter((cand) => cand.nota === 'site provável da empresa' || relevante(cand, palavras));
467
+ const candidatos = filtrados.slice(0, Math.max(2, Math.min(10, paginas + 6)));
468
+ const descartados = brutos.length - filtrados.length;
357
469
  if (!candidatos.length) {
358
470
  return {
359
471
  ok: false,
@@ -362,7 +474,9 @@ async function pesquisar({ consulta, paginas = 3 }) {
362
474
  // "sem internet?" era o palpite errado, e mandava o usuário conferir
363
475
  // a conexão dele: medido, os buscadores devolvem 202/302 e página
364
476
  // vazia para quem não parece navegador, com a rede perfeita.
365
- provavelCausa: 'Os buscadores costumam recusar chamadas automáticas (bloqueio ou limite por excesso de buscas seguidas), e não a sua conexão.',
477
+ provavelCausa: descartados
478
+ ? `Vieram ${descartados} link(s), mas nenhum era sobre "${termo}" — o buscador não achou o assunto e devolveu página aleatória. Fonte errada é pior que fonte nenhuma, então foram descartadas.`
479
+ : 'Os buscadores costumam recusar chamadas automáticas (bloqueio ou limite por excesso de buscas seguidas), e não a sua conexão.',
366
480
  comoCorrigir: 'Tente OUTRA consulta, mais curta e específica. Se souber o site do assunto, vá direto nele com browser_open + browser_read — é o caminho mais confiável para fato de empresa.',
367
481
  };
368
482
  }
@@ -374,6 +488,12 @@ async function pesquisar({ consulta, paginas = 3 }) {
374
488
  if (!html) continue;
375
489
  const texto = textoDe(html);
376
490
  if (texto.length < 200) continue;
491
+ if (pareceCodigo(texto)) continue;
492
+ // O chute de domínio precisa PROVAR que acertou. "Paris Group" tem
493
+ // parisgroup.com (um grupo de varejo no exterior) e parisgroup.ai (a
494
+ // empresa que o usuário quis). O nome não distingue os dois — o texto
495
+ // da página, sim. Sem esta prova o agente escreveria sobre a errada.
496
+ if (cand.nota === 'site provável da empresa' && !relevante({ titulo: texto.slice(0, 3000), url: cand.url }, palavras)) continue;
377
497
  lidas.push({
378
498
  titulo: cand.titulo,
379
499
  url: cand.url,
@@ -383,7 +503,14 @@ async function pesquisar({ consulta, paginas = 3 }) {
383
503
  }
384
504
 
385
505
  if (!lidas.length) {
386
- return { ok: false, error: 'achei os links mas não consegui abrir nenhum', consulta: termo, links: candidatos.map((c) => c.url) };
506
+ return {
507
+ ok: false,
508
+ error: 'achei os links mas não consegui abrir nenhum',
509
+ consulta: termo,
510
+ links: candidatos.map((c) => c.url),
511
+ comoCorrigir: 'Tente browser_open + browser_read num desses links, ou pesquise com outras palavras. '
512
+ + 'Se nada abrir, diga ao usuário que não conseguiu confirmar os fatos — NUNCA invente dado sobre empresa, data, preço ou pessoa.',
513
+ };
387
514
  }
388
515
 
389
516
  return {
@@ -396,4 +523,5 @@ async function pesquisar({ consulta, paginas = 3 }) {
396
523
  };
397
524
  }
398
525
 
399
- module.exports = { pesquisar, buscar, stackoverflow, textoDe, buscarWikipedia, buscarRespostaDDG };
526
+ module.exports = { pesquisar, buscar, stackoverflow, textoDe, buscarWikipedia, buscarRespostaDDG, pegar,
527
+ relevante, palavrasDaConsulta, sitesProvaveis, pareceCodigo };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.4.1",
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 && node test/repeticao.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"