primocode 9.5.0 → 9.7.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/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v9.5.0
1
+ # PrimoCode v9.7.0
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
9
9
  está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
10
10
 
11
11
  ```
12
- ▐▛███▜▌ Primo Code v9.5.0
12
+ ▐▛███▜▌ Primo Code v9.7.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
package/bin/primocode.js CHANGED
@@ -59,6 +59,7 @@ const VERSION = pkg.version;
59
59
  const state = {
60
60
  config: cfg.loadConfig(),
61
61
  history: [],
62
+ resumindo: null, // o resumo da última rodada, sendo feito em segundo plano
62
63
  mode: null, // 'primocode' | 'chat' | 'auto' — vem da config em main()
63
64
  modoEfetivo: null, // no auto, o modo que o último pedido usou de fato
64
65
  attachedFiles: [],
@@ -427,20 +428,88 @@ async function confirm(question) {
427
428
 
428
429
  // ── Projetos ───────────────────────────────────────────────────────────
429
430
 
430
- const STOPWORDS = new Set([
431
- 'crie', 'cria', 'criar', 'faca', 'faça', 'faz', 'fazer', 'monte', 'montar',
432
- 'desenvolva', 'desenvolver', 'construa', 'construir', 'gere', 'gerar',
433
- 'quero', 'queria', 'gostaria', 'preciso', 'me', 'meu', 'minha', 'favor',
434
- 'um', 'uma', 'uns', 'umas', 'o', 'a', 'os', 'as', 'por', 'pra', 'para',
431
+ /* ── O NOME DO PROJETO ───────────────────────────────────────────────────
432
+ * O nome saía das cinco primeiras palavras depois de "crie um": "agora deixa o
433
+ * carro mais rápido" virava a pasta `agora-deixa-o-carro-mais`, e "oi primo,
434
+ * faz um site" virava `oi-primo-faz-um-site`. Agora o começo da frase
435
+ * (saudação, o nome dele, verbo de pedido, artigo) é pulado, o assunto vai até
436
+ * a primeira palavra que emenda outra ideia ("com", "que", "usando"…), e
437
+ * sobram até quatro palavras que dizem O QUE é: "jogo de carro", "site
438
+ * padaria", "loja roupas". */
439
+ const ABERTURA = new Set([
440
+ 'oi', 'ola', 'ei', 'hey', 'e', 'ai', 'primo', 'bom', 'boa', 'dia', 'tarde', 'noite', 'ok', 'beleza',
441
+ 'entao', 'agora', 'tipo', 'por', 'favor', 'pfv', 'pf', 'voce', 'vc', 'pode', 'poderia', 'consegue',
442
+ 'crie', 'cria', 'criar', 'faca', 'faz', 'fazer', 'monte', 'monta', 'montar', 'desenvolva', 'desenvolver',
443
+ 'construa', 'construir', 'gere', 'gera', 'gerar', 'programe', 'programa', 'escreva', 'escreve',
444
+ 'quero', 'queria', 'gostaria', 'preciso', 'precisamos', 'vamos', 'bora', 'me', 'pra', 'para', 'mim',
445
+ 'um', 'uma', 'uns', 'umas', 'o', 'a', 'os', 'as', 'de', 'novo', 'nova', 'outro', 'outra', 'projeto',
446
+ 'simples', 'pequeno', 'pequena', 'rapido', 'bonito', 'bonita',
447
+ // os verbos de CONTINUAR: sem eles, "agora deixa o carro…" virava nome
448
+ 'deixa', 'deixe', 'continua', 'continue', 'edita', 'edite', 'muda', 'mude', 'melhora', 'melhore',
449
+ 'adiciona', 'adicione', 'coloca', 'coloque', 'poe', 'bota', 'corrige', 'corrija', 'arruma', 'arrume',
450
+ 'ajusta', 'ajuste', 'abre', 'abra', 'termina', 'termine',
435
451
  ]);
452
+ const EMENDA = new Set(['com', 'que', 'usando', 'onde', 'mas', 'porque', 'igual', 'como', 'sobre',
453
+ 'em', 'no', 'na', 'nos', 'nas', 'e', 'tipo', 'estilo', 'feito', 'utilizando', 'mais', 'menos']);
454
+ // Palavras que não dizem o que a coisa É — saem de qualquer posição.
455
+ const DE_ENCHER = new Set(['minha', 'meu', 'minhas', 'meus', 'nossa', 'nosso', 'para', 'pra', 'uma', 'um',
456
+ 'simples', 'bonito', 'bonita', 'pequeno', 'pequena', 'legal', 'rapido', 'rapida', 'basico', 'basica',
457
+ 'completo', 'completa', 'novo', 'nova']);
458
+
459
+ function semAcento(x) { return String(x || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase(); }
436
460
 
437
- // "crie um jogo de carro" -> "jogo de carro"
438
461
  function deriveProjectName(text) {
439
- const words = String(text).trim().split(/\s+/);
462
+ const palavras = String(text || '').split(/\s+/)
463
+ .map((w) => w.replace(/[^\wáéíóúâêôãõçÁÉÍÓÚÂÊÔÃÕÇ-]/gi, ''))
464
+ .filter(Boolean);
440
465
  let i = 0;
441
- while (i < words.length && STOPWORDS.has(words[i].toLowerCase().replace(/[^\wáéíóúâêôãõç]/gi, ''))) i++;
442
- const picked = words.slice(i, i + 5).join(' ').replace(/[^\w\sáéíóúâêôãõçÁÉÍÓÚÂÊÔÃÕÇ-]/gi, '').trim();
443
- return picked || 'projeto';
466
+ while (i < palavras.length && ABERTURA.has(semAcento(palavras[i]))) i++;
467
+ const assunto = [];
468
+ for (; i < palavras.length && assunto.length < 6; i++) {
469
+ const w = semAcento(palavras[i]);
470
+ if (assunto.length && EMENDA.has(w)) break;
471
+ if (DE_ENCHER.has(w)) continue;
472
+ assunto.push(palavras[i]);
473
+ }
474
+ while (assunto.length && /^(de|do|da|dos|das)$/i.test(semAcento(assunto[assunto.length - 1]))) assunto.pop();
475
+ const nome = assunto.slice(0, 4).join(' ').trim();
476
+ return nome || 'projeto';
477
+ }
478
+
479
+ /* ── "CRIE UM…" DENTRO DE UM PROJETO ────────────────────────────────────
480
+ * Depois de entrar num projeto, TUDO ia para ele — até "crie um site de
481
+ * receitas" no meio do jogo de carro. E fora dele, qualquer "continua" virava
482
+ * projeto novo. A regra agora: dentro de um projeto, só um pedido que CLARAMENTE
483
+ * abre outra coisa sai dele — verbo de criar + "um/uma/outro/novo", assunto
484
+ * que não tem nada a ver com o projeto atual, e nenhuma referência a ele
485
+ * ("isso", "esse", "nele"). Todo o resto ("continua", "muda a cor", "agora
486
+ * põe fases") fica. */
487
+ const RE_PROJETO_NOVO = /\b(novo projeto|projeto novo|outro projeto|do zero|come[cç]a(r)? (um|uma|outro|outra))\b/;
488
+ const RE_CRIAR_UM = /\b(crie|cria|criar|fa[cç]a|faz|fazer|monte|monta|montar|gere|gera|desenvolva|construa|programe|quero|preciso de)\s+(um|uma|outro|outra|novo|nova)\b/;
489
+ const RE_REFERE_ATUAL = /\b(isso|isto|esse|essa|este|esta|nele|nela|dele|dela|no projeto|nesse|nessa|neste|nesta|continua|continue|edita|edite|muda|mude|melhora|melhore|adiciona|adicione|coloca|coloque|p[oõ]e|corrige|corrija|arruma|arrume|ajusta|ajuste)\b/;
490
+
491
+ function pedeProjetoNovo(texto) {
492
+ const t = semAcento(texto);
493
+ if (RE_PROJETO_NOVO.test(t)) return true;
494
+ if (!RE_CRIAR_UM.test(t) || RE_REFERE_ATUAL.test(t)) return false;
495
+ // Mesmo assunto do projeto atual ("crie uma fase nova pro jogo de carro")
496
+ // é continuação, não projeto novo.
497
+ const atual = new Set(cfg.slugify(state.currentProject || '').split('-').filter((x) => x.length > 2));
498
+ const pedido = cfg.slugify(deriveProjectName(texto)).split('-').filter((x) => x.length > 2);
499
+ return !pedido.some((x) => atual.has(x));
500
+ }
501
+
502
+ /* Pedido de mexer no computador da pessoa (abrir app, clicar, digitar,
503
+ mostrar o cursor). Serve só para mandar a dica das ferramentas desktop_*. */
504
+ const RE_COMPUTADOR = /\b(abr[ae]|abrir|clica|clique|clicar|digita|digite|digitar|mouse|cursor|teclado|meu computador|no (meu )?mac|na (minha )?tela|aplicativo|spotify|safari|finder|whatsapp|mensagens|m[úu]sica|configura[çc][õo]es|ajustes)\b/i;
505
+ function querComputador(texto) { return RE_COMPUTADOR.test(String(texto || '')); }
506
+
507
+ /* Guarda onde se está, para a próxima sessão continuar aqui. */
508
+ function entrarNoProjeto(dir, origem = 'projeto') {
509
+ state.workDir = dir;
510
+ state.currentProject = path.basename(dir);
511
+ state.workDirOrigem = origem;
512
+ cfg.lembrarProjeto(dir, origem);
444
513
  }
445
514
 
446
515
  // Pasta em que é seguro deixar um agente criar e editar arquivo. A home e a raiz
@@ -477,7 +546,10 @@ function ehSoEstudio(texto) {
477
546
  }
478
547
 
479
548
  function planWorkDir(userText) {
480
- if (['/dir', 'detectada', 'projeto'].includes(state.workDirOrigem)) {
549
+ const escolhida = ['/dir', 'detectada', 'projeto'].includes(state.workDirOrigem);
550
+ // Pasta escolhida a dedo (/dir, ou dita no pedido) manda sempre. Projeto
551
+ // em andamento manda também — a menos que o pedido abra outra coisa.
552
+ if (escolhida && !(state.workDirOrigem === 'projeto' && pedeProjetoNovo(userText))) {
481
553
  return { dir: state.workDir, pending: false, name: state.currentProject };
482
554
  }
483
555
  // "Quando eu peço um vídeo, não é pra ficar com projeto. Só vai criar
@@ -493,9 +565,7 @@ function planWorkDir(userText) {
493
565
 
494
566
  function createPendingProject(plan) {
495
567
  const dir = cfg.createProjectDir(plan.name);
496
- state.workDir = dir;
497
- state.currentProject = path.basename(dir);
498
- state.workDirOrigem = 'projeto';
568
+ entrarNoProjeto(dir, 'projeto');
499
569
  const rotulo = plan.similar ? 'Continuando em' : 'Projeto novo:';
500
570
  console.log(c.ok(` ${rotulo} ${state.currentProject}`) + c.muted(` (${dir})`));
501
571
  refreshPrompt();
@@ -530,9 +600,7 @@ function talvezTrocarDePasta(userText) {
530
600
  if (path.resolve(achado.caminho) === path.resolve(projectDir())) return; // já está lá
531
601
  if (!pastaSegura(achado.caminho)) return; // home ou raiz: só por /dir explícito
532
602
 
533
- state.workDir = achado.caminho;
534
- state.currentProject = path.basename(achado.caminho);
535
- state.workDirOrigem = 'detectada';
603
+ entrarNoProjeto(achado.caminho, 'detectada');
536
604
  console.log(c.ok(` Trabalhando em ${shortPath(achado.caminho)}`) + c.dim(` (você falou "${candidato}")`));
537
605
  refreshPrompt();
538
606
  }
@@ -556,9 +624,7 @@ function prepararPasta(userText) {
556
624
  // buildMemoryContext deste turno leria a pasta errada e a memória do projeto
557
625
  // não carregaria.
558
626
  if (!plan.pending && path.resolve(plan.dir) !== path.resolve(projectDir())) {
559
- state.workDir = plan.dir;
560
- state.currentProject = plan.name || path.basename(plan.dir);
561
- state.workDirOrigem = 'projeto';
627
+ entrarNoProjeto(plan.dir, 'projeto');
562
628
  console.log(c.ok(` Continuando em ${state.currentProject}`) + c.muted(` (${shortPath(plan.dir)})`));
563
629
  refreshPrompt();
564
630
  }
@@ -602,6 +668,20 @@ async function autorizarControle(oQueVaiFazer, { primeira } = {}) {
602
668
  return 'nao';
603
669
  }
604
670
 
671
+ /* ── A PERMISSÃO NO MODO VOZ ─────────────────────────────────────────────
672
+ * No modo voz ninguém está olhando o terminal: a pergunta "1, 2 ou Enter"
673
+ * ficava lá embaixo, o agente parava esperando, e para quem só falava ele
674
+ * simplesmente travava. Abrir o /voice e PEDIR em voz para ele mexer no
675
+ * computador já é a autorização — é o produto funcionando como o Jarvis. Fica
676
+ * dito no terminal, a cada ação, o que ele fez; e fechar o PrimoCode esquece. */
677
+ async function autorizarPorVoz(oQueVaiFazer, { primeira } = {}) {
678
+ if (primeira) {
679
+ console.log(' ' + c.warn('▸▸') + ' ' + c.muted('Modo voz: o Primo vai usar o mouse e o teclado. Você vê o cursor dele na tela.'));
680
+ }
681
+ console.log(' ' + c.brand('●') + ' ' + c.dim(oQueVaiFazer));
682
+ return 'sessao';
683
+ }
684
+
605
685
  /* ── A TELA DO MARKETPLACE ───────────────────────────────────────────────
606
686
  * O desenho e as teclas vivem em lib/marketplace.js, que é função pura. O que
607
687
  * mora aqui é o que precisa do terminal: tirar a caixa de digitar da frente,
@@ -801,10 +881,9 @@ async function sendPrompt(userText) {
801
881
 
802
882
  const plan = prepararPasta(userText);
803
883
 
804
- // Antes de gastar com a tarefa, dobra no resumo o que já passou. A
805
- // conta é pequena: entra "resumo de antes + o que é novo", não a
806
- // conversa inteira.
807
- await comprimirConversa();
884
+ // O resumo da rodada anterior é feito em segundo plano logo depois
885
+ // dela; aqui só se espera ele terminar, para não sair com memória velha.
886
+ await esperarResumo();
808
887
 
809
888
  // O pedido passa por uma cabeça antes de virar trabalho: conserta o
810
889
  // que está torto, diz qual é o entregável e o que conta como pronto.
@@ -842,7 +921,7 @@ async function sendPrompt(userText) {
842
921
  token: state.config.token,
843
922
  // Só o primeiro passo enxerga a conversa: os seguintes recebem o
844
923
  // que interessa em `jaFeito`, e conversa a mais só ocupa o teto.
845
- history: i === 0 ? memoria.paraEnviar(state.memoria, state.history) : [],
924
+ history: i === 0 ? memoria.paraEnviar(state.memoria, state.history, contextoDoProjeto()) : [],
846
925
  projectDir: plan.dir,
847
926
  projectName: plan.name || state.currentProject,
848
927
  // Só chamado se o agente for mesmo MEXER EM ARQUIVO — abrir o Chrome
@@ -852,6 +931,9 @@ async function sendPrompt(userText) {
852
931
  // lembra da resposta é a sessão.
853
932
  autorizar: autorizarControle,
854
933
  controle: state.controle,
934
+ // Pedido de mexer no computador leva a dica que libera as
935
+ // ferramentas desktop_* no servidor (ver DICA_COMPUTADOR).
936
+ instrucao: i === 0 && querComputador(userText) ? vozModo.DICA_COMPUTADOR : '',
855
937
  });
856
938
 
857
939
  r = await rodarPasso();
@@ -877,6 +959,7 @@ async function sendPrompt(userText) {
877
959
  if (balanco) console.log(balanco);
878
960
  state.history.push({ role: 'user', content: userText });
879
961
  state.history.push({ role: 'assistant', content: r.assistantMessage.content });
962
+ resumirDepois();
880
963
  state.lastResponse = r.assistantMessage.content;
881
964
  cfg.appendHistory({ mode: 'primocode', project: state.currentProject, prompt: userText });
882
965
  // Sobe cifrado, atrás, sem segurar o prompt. Se não houver conta ou
@@ -884,7 +967,7 @@ async function sendPrompt(userText) {
884
967
  state.gravador.turno(userText, r.assistantMessage.content,
885
968
  { modo: 'primocode', projeto: state.currentProject || null });
886
969
  } else {
887
- await comprimirConversa();
970
+ await esperarResumo();
888
971
  await runChat(prompt, userText);
889
972
  }
890
973
 
@@ -986,21 +1069,55 @@ async function dividirTarefa(pedido) {
986
1069
  resumir. Sem isso o comando responderia "nada a fazer" justamente quando
987
1070
  ela está vendo a IA se perder — e "não fiz nada" é a pior resposta possível
988
1071
  para quem acabou de pedir socorro. */
989
- async function comprimirConversa(forcado = false) {
1072
+ async function comprimirConversa(forcado = false, { silencioso = false } = {}) {
990
1073
  if (!forcado && !memoria.precisaResumir(state.memoria, state.history)) return;
991
- const sp = new Spinner('resumindo a conversa').start();
1074
+ // Em segundo plano não há spinner: ele disputaria a tela com o prompt.
1075
+ const sp = silencioso ? { stop() {} } : new Spinner('resumindo a conversa').start();
992
1076
  try {
993
- state.memoria = await memoria.atualizar(state.memoria, state.history, ({ resumo, mensagens }) =>
1077
+ const conversa = state.history;
1078
+ const nova = await memoria.atualizar(state.memoria, conversa, ({ resumo, mensagens }) =>
994
1079
  api.requestResumo(state.config.server, { resumo, mensagens, token: state.config.token })
995
1080
  .then((r) => r.resumo));
1081
+ // /clear ou /chats trocaram a conversa enquanto o resumo era feito:
1082
+ // o resumo é da conversa velha e não pode voltar por cima da nova.
1083
+ if (state.history !== conversa) { sp.stop(); return; }
1084
+ state.memoria = nova;
996
1085
  sp.stop();
997
1086
  const e = memoria.economia(state.memoria, state.history);
998
- if (e.poupado > 0) console.log(c.dim(` ⎿ conversa resumida · ${e.cheio} → ${e.enxuto} tokens por pedido`));
1087
+ if (!silencioso && e.poupado > 0) console.log(c.dim(` ⎿ conversa resumida · ${e.cheio} → ${e.enxuto} tokens por pedido`));
999
1088
  } catch {
1000
1089
  sp.stop(); // resumo é economia, não requisito: falhou, segue a vida
1001
1090
  }
1002
1091
  }
1003
1092
 
1093
+ /* ── O RESUMO DE CADA RODADA ─────────────────────────────────────────────
1094
+ * "Gere um resumo a cada mensagem, pra na próxima ela ler só esse resumo."
1095
+ * O resumo começa assim que a resposta termina, atrás, sem segurar o prompt —
1096
+ * a pessoa lê a resposta enquanto ele é feito. O pedido seguinte espera por
1097
+ * ele (com teto: rede lenta não pode travar a conversa), e aí leva só o
1098
+ * resumo, não a conversa inteira. Ver lib/memoria-conversa.js. */
1099
+ function resumirDepois() {
1100
+ const anterior = state.resumindo || Promise.resolve();
1101
+ const atual = anterior.then(() => comprimirConversa(false, { silencioso: true })).catch(() => {});
1102
+ state.resumindo = atual;
1103
+ atual.then(() => { if (state.resumindo === atual) state.resumindo = null; });
1104
+ }
1105
+
1106
+ async function esperarResumo(tetoMs = 15000) {
1107
+ if (!state.resumindo) return;
1108
+ await Promise.race([state.resumindo, new Promise((ok) => setTimeout(ok, tetoMs))]);
1109
+ }
1110
+
1111
+ /* A linha que vai junto do resumo: em que projeto se está. É o que faz
1112
+ "continua o jogo" e "muda a cor" caírem no mesmo projeto em vez de inventar
1113
+ outro. */
1114
+ function contextoDoProjeto() {
1115
+ if (!state.currentProject) return '';
1116
+ return `Projeto atual: ${state.currentProject} (pasta ${shortPath(projectDir())}). `
1117
+ + 'Pedidos como "continua", "edita", "muda", "melhora" ou "isso" são sobre ELE: '
1118
+ + 'trabalhe nos arquivos que já existem lá, não crie outro projeto.';
1119
+ }
1120
+
1004
1121
  // O chat também precisa parar no Esc Esc. Sem isto o texto continuava saindo
1005
1122
  // na tela depois de o usuário ter mandado parar.
1006
1123
  let chatEmVoo = null;
@@ -1027,7 +1144,7 @@ async function runChat(promptForModel, userTextForMemory) {
1027
1144
  try {
1028
1145
  await api.requestChat(state.config.server, {
1029
1146
  prompt: promptForModel,
1030
- history: memoria.paraEnviar(state.memoria, state.history),
1147
+ history: memoria.paraEnviar(state.memoria, state.history, contextoDoProjeto()),
1031
1148
  model: state.config.model,
1032
1149
  token: state.config.token,
1033
1150
  sinal: chatEmVoo.signal,
@@ -1057,6 +1174,7 @@ async function runChat(promptForModel, userTextForMemory) {
1057
1174
  if (text) {
1058
1175
  state.history.push({ role: 'user', content: userTextForMemory });
1059
1176
  state.history.push({ role: 'assistant', content: text });
1177
+ resumirDepois();
1060
1178
  state.lastResponse = text;
1061
1179
  cfg.appendHistory({ mode: 'chat', prompt: userTextForMemory });
1062
1180
  state.gravador.turno(userTextForMemory, text, { modo: 'chat' });
@@ -1121,6 +1239,7 @@ async function rodarBash(text) {
1121
1239
  role: 'assistant',
1122
1240
  content: `Saída (código ${r.code ?? 0}):\n${saida.slice(0, 4000) || '(vazia)'}`,
1123
1241
  });
1242
+ resumirDepois();
1124
1243
  }
1125
1244
 
1126
1245
  /* ── OS NOMES ANTIGOS ────────────────────────────────────────────────────
@@ -1179,9 +1298,7 @@ async function handleSlash(text) {
1179
1298
  break;
1180
1299
  }
1181
1300
  const dir = cfg.createProjectDir(rest);
1182
- state.workDir = dir;
1183
- state.currentProject = path.basename(dir);
1184
- state.workDirOrigem = 'projeto';
1301
+ entrarNoProjeto(dir, 'projeto');
1185
1302
  console.log(c.ok(`Projeto: ${state.currentProject}`) + c.muted(` (${dir})`));
1186
1303
  refreshPrompt();
1187
1304
  break;
@@ -1232,9 +1349,7 @@ async function handleSlash(text) {
1232
1349
  }
1233
1350
  }
1234
1351
 
1235
- state.workDir = alvo;
1236
- state.currentProject = path.basename(alvo);
1237
- state.workDirOrigem = '/dir';
1352
+ entrarNoProjeto(alvo, '/dir');
1238
1353
  const quantos = (() => { try { return fs.readdirSync(alvo).length; } catch { return 0; } })();
1239
1354
  console.log(c.ok(` Trabalhando em ${shortPath(alvo)}`) + c.muted(` (${quantos} ${quantos === 1 ? 'item' : 'itens'})`));
1240
1355
  refreshPrompt();
@@ -1573,17 +1688,21 @@ async function handleSlash(text) {
1573
1688
  porque resposta boa de ler é ruim de ouvir — o trabalho é o
1574
1689
  mesmo, o jeito de contar é que muda. */
1575
1690
  pensar: async (texto) => {
1691
+ // Mesma memória do terminal: espera o resumo da rodada
1692
+ // anterior e leva só ele, com o projeto atual.
1693
+ await esperarResumo();
1576
1694
  const plan = prepararPasta(texto);
1577
- const resultado = await runAct(`${vozModo.COMO_FALAR}\n\nPEDIDO: ${texto}`, {
1695
+ const resultado = await runAct(texto, {
1696
+ instrucao: vozModo.COMO_FALAR,
1578
1697
  server: state.config.server,
1579
1698
  model: state.config.model,
1580
1699
  effort: state.config.effort,
1581
1700
  token: state.config.token,
1582
- history: memoria.paraEnviar(state.memoria, state.history),
1701
+ history: memoria.paraEnviar(state.memoria, state.history, contextoDoProjeto()),
1583
1702
  projectDir: plan.dir,
1584
1703
  projectName: plan.name || state.currentProject,
1585
1704
  ensureWorkDir: plan.pending ? () => createPendingProject(plan) : null,
1586
- autorizar: autorizarControle,
1705
+ autorizar: autorizarPorVoz,
1587
1706
  controle: state.controle,
1588
1707
  });
1589
1708
  const resposta = (resultado.assistantMessage && resultado.assistantMessage.content) || '';
@@ -1591,6 +1710,7 @@ async function handleSlash(text) {
1591
1710
  // continuar digitando não pode começar do zero.
1592
1711
  state.history.push({ role: 'user', content: texto });
1593
1712
  state.history.push({ role: 'assistant', content: resposta });
1713
+ resumirDepois();
1594
1714
  const balanco = registro.resumo(resultado.registro, { colunas: process.stdout.columns || 80 });
1595
1715
  if (balanco) console.log(balanco);
1596
1716
  return resposta;
@@ -1945,6 +2065,14 @@ async function main() {
1945
2065
  state.workDir = cfg.ROOT_DIR;
1946
2066
  state.currentProject = null;
1947
2067
  state.workDirOrigem = 'padrao';
2068
+ // O projeto em que se estava na última sessão (até uma semana atrás): o
2069
+ // "continua o jogo" de amanhã cai nele, e não num projeto novo.
2070
+ const lembrado = cfg.projetoLembrado();
2071
+ if (lembrado && pastaSegura(lembrado.dir)) {
2072
+ state.workDir = lembrado.dir;
2073
+ state.currentProject = path.basename(lembrado.dir);
2074
+ state.workDirOrigem = lembrado.origem === '/dir' ? '/dir' : 'projeto';
2075
+ }
1948
2076
 
1949
2077
  // Modo guardado entre sessões. O padrão é auto: sem ele, um "oi" gasta o
1950
2078
  // mesmo que uma refatoração.
package/lib/act.js CHANGED
@@ -429,12 +429,48 @@ function motivoDaParada({ voltas, voltasEmFalso, falhasPendentes, travou }) {
429
429
  return 'Parei sem concluir e sem explicação do modelo. Tente pedir de novo, em partes menores.';
430
430
  }
431
431
 
432
+ /* ── O MAPA DO PROJETO ───────────────────────────────────────────────────
433
+ * "Continua o jogo" num projeto de vinte arquivos: o agente começava às
434
+ * cegas, gastava uma volta listando a pasta — ou, pior, escrevia um index.html
435
+ * novo por cima do que existia. Agora ele recebe, junto da memória, a lista dos
436
+ * arquivos do projeto (até 80, sem node_modules nem build), e sabe de saída
437
+ * onde está cada coisa. Só em pasta de projeto: a raiz ~/primocode lista
438
+ * outros projetos, não este. */
439
+ const PULAR_NO_MAPA = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.nuxt', 'out',
440
+ '.cache', 'coverage', '__pycache__', '.venv', 'venv', '.turbo', '.vercel', '.idea', '.vscode']);
441
+
442
+ function mapaDoProjeto(dir, limite = 80) {
443
+ const path = require('path');
444
+ const fs = require('fs');
445
+ if (!dir || path.resolve(dir) === path.resolve(cfg.ROOT_DIR)) return '';
446
+ const achados = [];
447
+ const andar = (atual, fundo) => {
448
+ if (fundo > 4 || achados.length >= limite) return;
449
+ let itens = [];
450
+ try { itens = fs.readdirSync(atual, { withFileTypes: true }); } catch { return; }
451
+ itens.sort((a, b) => (a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? 1 : -1));
452
+ for (const it of itens) {
453
+ if (achados.length >= limite) return;
454
+ if (PULAR_NO_MAPA.has(it.name) || (it.name.startsWith('.') && it.name !== '.env.example')) continue;
455
+ const cheio = path.join(atual, it.name);
456
+ if (it.isDirectory()) andar(cheio, fundo + 1);
457
+ else if (it.name !== 'memory.md') achados.push(path.relative(dir, cheio));
458
+ }
459
+ };
460
+ andar(dir, 0);
461
+ if (!achados.length) return '';
462
+ const mais = achados.length >= limite ? `\n… (mais arquivos — use glob para achar)` : '';
463
+ return `Arquivos que JÁ existem neste projeto (edite-os em vez de recriar):\n${achados.join('\n')}${mais}`;
464
+ }
465
+
432
466
  function buildMemoryContext(projectDir, projectName) {
433
467
  const parts = [];
434
468
  const geral = cfg.readGeneralMemory();
435
469
  if (geral.trim()) parts.push(`[memory-geral.md]\n${geral}`);
436
470
  const projeto = cfg.readProjectMemory(projectDir);
437
471
  if (projeto.trim()) parts.push(`[${projectName || 'projeto'}/memory.md]\n${projeto}`);
472
+ const mapa = mapaDoProjeto(projectDir);
473
+ if (mapa) parts.push(`[${projectName || 'projeto'}/arquivos]\n${mapa}`);
438
474
  return parts.join('\n\n');
439
475
  }
440
476
 
@@ -454,6 +490,15 @@ async function runAct(userPrompt, opts) {
454
490
  `test/repeticao.js` e `test/continuar.js` teriam de falar com a
455
491
  internet para verificar o loop do agente. */
456
492
  motor = api,
493
+ /* Instrução de FORMA que vai junto do pedido mas não É o pedido — a
494
+ do modo voz ("responda em três frases faladas"). Ela chega ao
495
+ modelo; a memória e o histórico guardam só o que a pessoa disse.
496
+ Antes ela ia colada no texto, e a memória do projeto enchia de
497
+ "[MODO VOZ] A sua resposta vai ser lida em voz alta…". */
498
+ instrucao = '',
499
+ /* Quem abre a página para conferir antes do "pronto". Injetável pelo
500
+ mesmo motivo do `motor`: o teste mede a regra sem abrir um Chrome. */
501
+ medirPagina = (dir) => require('./pipeline.js').medirNoNavegador(dir),
457
502
  } = opts || {};
458
503
 
459
504
  // A pasta de projeto só nasce quando o agente for mesmo MEXER EM ARQUIVO.
@@ -463,8 +508,49 @@ async function runAct(userPrompt, opts) {
463
508
  let criarPasta = ensureWorkDir || null;
464
509
  let pastaAtual = projectDir; // onde as tools agem AGORA
465
510
  let temPasta = !criarPasta; // já é a pasta definitiva?
511
+ /* Cria a pasta do projeto pendente, uma vez. Chamado antes de qualquer
512
+ ferramenta que faz código (ver `criaProjeto` no catálogo) — e ANTES do
513
+ subagente e do pipeline, que senão escreviam na raiz de ~/primocode. */
514
+ const garantirPasta = () => {
515
+ if (!criarPasta) return;
516
+ const nova = criarPasta();
517
+ if (nova) pastaAtual = nova;
518
+ criarPasta = null;
519
+ temPasta = true;
520
+ };
466
521
  const falhasPendentes = [];
467
522
  let tentativasDeEncerrar = 0;
523
+ /* ── CONFERIR ANTES DE DIZER "PRONTO" ───────────────────────────────
524
+ * Mexeu em página (html, css, js) nesta tarefa? Antes de aceitar o
525
+ * `finish`, o PrimoCode abre a página num navegador invisível e olha: tela
526
+ * em branco, erro de JavaScript, recurso que não carregou. Achou problema,
527
+ * a tarefa volta para o agente com o relato — uma vez só, para não virar
528
+ * laço. É a diferença entre "pronto!" e "pronto, e abre". */
529
+ let mexeuNaPagina = false; // mexeu desde a última conferência?
530
+ let conferencias = 0; // até duas: entregar, e conferir o conserto
531
+ let avisoDaPagina = ''; // o que ainda estava quebrado na última
532
+ /* Abre a página e devolve o relato quando é para VOLTAR (achou problema e
533
+ ainda cabe uma volta); null quando é para entregar. O que ficar
534
+ quebrado na última conferência fica em `avisoDaPagina`, e a entrega sai
535
+ com ele — nunca "pronto" seco sobre uma página com erro. Vale para os
536
+ dois jeitos de terminar: a ferramenta finish e a resposta em texto. */
537
+ const conferirPagina = async () => {
538
+ if (subagent || !mexeuNaPagina || conferencias >= 2 || PARAR) return null;
539
+ conferencias++;
540
+ mexeuNaPagina = false;
541
+ let medida = null;
542
+ try {
543
+ fala.pensamento('abrindo a página para conferir antes de entregar', profundidade);
544
+ medida = await medirPagina(pastaAtual);
545
+ } catch { medida = null; }
546
+ if (!medida || !medida.testou) return null;
547
+ avisoDaPagina = medida.limpo ? ''
548
+ : String(medida.relato || '').split('\n').filter((l) => /·|BRANCO/.test(l)).slice(0, 3).join(' ');
549
+ if (medida.limpo) { fala.pensamento('a página abriu sem erro', profundidade); return null; }
550
+ return conferencias >= 2 ? null : medida.relato;
551
+ };
552
+ const comAviso = (texto) => (avisoDaPagina
553
+ ? `${texto}\n\nAtenção: abri a página e ela ainda tem problema — ${avisoDaPagina.trim()}` : texto);
468
554
  // Quem já foi chamado, e quantas vezes. Ver lib/repeticao.js.
469
555
  const jaChamado = repeticao.novo();
470
556
  /* O livro deste pedido: arquivos, comandos, links, testes e falhas. Sai
@@ -492,6 +578,7 @@ async function runAct(userPrompt, opts) {
492
578
  if (!subagent) {
493
579
  const comSkills = skills.montar(userPrompt);
494
580
  pedido = comSkills.texto;
581
+ if (instrucao) pedido = `${instrucao}\n\nPEDIDO: ${pedido}`;
495
582
  if (comSkills.skills.length) {
496
583
  fala.pensamento(`seguindo: ${comSkills.skills.map((s) => s.titulo).join(', ')}`, profundidade);
497
584
  }
@@ -627,7 +714,7 @@ async function runAct(userPrompt, opts) {
627
714
 
628
715
  emVoo = new AbortController();
629
716
  try {
630
- await motor.requestAct(server, { messages: compactar(messages), model, effort, token, projectDir, projectName, subagent, sinal: emVoo.signal }, (evt) => {
717
+ await motor.requestAct(server, { messages: compactar(messages), model, effort, token, projectDir: pastaAtual, projectName, subagent, sinal: emVoo.signal }, (evt) => {
631
718
  if (evt.erro) { streamError = evt.erro; return; }
632
719
  if (evt.tool_call) { toolCalls.push(evt.tool_call); return; }
633
720
  if (evt.delta) {
@@ -805,6 +892,21 @@ async function runAct(userPrompt, opts) {
805
892
  finalText = '';
806
893
  continue;
807
894
  }
895
+ // Terminou falando, sem chamar finish: confere a página do mesmo jeito.
896
+ if (finalText) {
897
+ const relato = await conferirPagina();
898
+ if (relato) {
899
+ messages.push({ role: 'assistant', content: finalText });
900
+ messages.push({
901
+ role: 'user',
902
+ content: `AINDA NÃO. Abri a página para conferir e ela tem problema:\n${relato}\n\n`
903
+ + 'Conserte (leia o arquivo antes de editar) e finalize de novo.',
904
+ });
905
+ finalText = '';
906
+ continue;
907
+ }
908
+ finalText = comAviso(finalText);
909
+ }
808
910
  if (!subagent) {
809
911
  if (fluxo) fluxo.fechar();
810
912
  else fala.resposta(finalText);
@@ -843,6 +945,7 @@ async function runAct(userPrompt, opts) {
843
945
  // sinal de decomposição ruim, e o excedente roda em série mesmo.
844
946
  const spawns = toolCalls.filter((tc) => tc.name === 'spawn_agent');
845
947
  const resultadosSpawn = new Map();
948
+ if (spawns.length && !PARAR) garantirPasta();
846
949
  if (spawns.length > 1 && !PARAR) {
847
950
  const juntos = spawns.slice(0, 4);
848
951
  fala.paralelo(juntos.length, profundidade);
@@ -855,6 +958,18 @@ async function runAct(userPrompt, opts) {
855
958
 
856
959
  for (const tc of toolCalls) {
857
960
  if (PARAR) break;
961
+ if (tc.name === 'finish') {
962
+ const relato = await conferirPagina();
963
+ if (relato) {
964
+ messages.push({
965
+ role: 'tool',
966
+ tool_call_id: tc.id,
967
+ content: `AINDA NÃO. Abri a página para conferir e ela tem problema:\n${relato}\n\n`
968
+ + 'Conserte (leia o arquivo antes de editar) e chame finish de novo.',
969
+ });
970
+ continue;
971
+ }
972
+ }
858
973
  if (tc.name === 'finish') {
859
974
  // Encerrar com falha pendente é como o agente dizia "pronto!"
860
975
  // sem ter mudado nada. Devolvemos a tarefa para ele.
@@ -869,7 +984,7 @@ async function runAct(userPrompt, opts) {
869
984
  continue;
870
985
  }
871
986
  algoRodou = true;
872
- finished = (tc.arguments && tc.arguments.summary) || 'Concluído.';
987
+ finished = comAviso((tc.arguments && tc.arguments.summary) || 'Concluído.');
873
988
  messages.push({ role: 'tool', tool_call_id: tc.id, content: 'ok' });
874
989
  continue;
875
990
  }
@@ -906,6 +1021,7 @@ async function runAct(userPrompt, opts) {
906
1021
  esquecer de passar o token ou a pasta. */
907
1022
  if (tc.name === 'construir_app') {
908
1023
  algoRodou = true;
1024
+ garantirPasta();
909
1025
  fala.acao(tc.name, tc.arguments, profundidade);
910
1026
  const r = await rodarPipeline(tc.arguments || {}, {
911
1027
  server, model, token, projectDir: pastaAtual, projectName, profundidade, motor, livro, autorizar, controle: portaoControle,
@@ -928,12 +1044,7 @@ async function runAct(userPrompt, opts) {
928
1044
  continue;
929
1045
  }
930
1046
 
931
- if (criarPasta && catalogo.escreveArquivo(tc.name)) {
932
- const nova = criarPasta();
933
- if (nova) pastaAtual = nova;
934
- criarPasta = null;
935
- temPasta = true;
936
- }
1047
+ if (criarPasta && catalogo.criaProjeto(tc.name, tc.arguments)) garantirPasta();
937
1048
 
938
1049
  if (tc.name === 'pesquisar_referencia' && pesquisasSecas >= 3) {
939
1050
  voltasEmFalso++;
@@ -973,6 +1084,10 @@ async function runAct(userPrompt, opts) {
973
1084
  // nasceu, mudou, rodou e falhou. Narrar sozinho não basta — numa
974
1085
  // tarefa de trinta ações, a primeira já saiu da tela.
975
1086
  registro.anotar(livro, tc.name, tc.arguments, result);
1087
+ if (result && result.ok !== false && catalogo.criaProjeto(tc.name, tc.arguments)
1088
+ && /\.(html?|css|m?jsx?|tsx?|vue|svelte)$/i.test(String((tc.arguments && tc.arguments.path) || ''))) {
1089
+ mexeuNaPagina = true;
1090
+ }
976
1091
 
977
1092
  // Uma escrita bem-sucedida limpa a dívida — mas só a do MESMO
978
1093
  // arquivo. Escrever algo sem relação (ou um run_shell qualquer)
@@ -1209,9 +1324,13 @@ async function rodarPipeline(args, ctx) {
1209
1324
  const path = require('path');
1210
1325
  const pedido = args.pedido || args.prompt || '';
1211
1326
  const base = projectDir || cfg.ROOT_DIR;
1327
+ /* Dentro de um projeto, o app é montado NA pasta dele. A subpasta com
1328
+ nome derivado do pedido só vale na raiz: era ela que fazia o app nascer
1329
+ num lugar que a conversa nunca mais achava. */
1330
+ const naRaiz = path.resolve(base) === path.resolve(cfg.ROOT_DIR);
1212
1331
  const pasta = args.pasta
1213
1332
  ? path.resolve(base, args.pasta)
1214
- : pipeline.pastaPara(pedido, base);
1333
+ : (naRaiz ? pipeline.pastaPara(pedido, base) : base);
1215
1334
 
1216
1335
  const correr = async (prompt, rotulo) => {
1217
1336
  if (PARAR) return '';