primocode 9.4.0 → 9.6.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.4.0
1
+ # PrimoCode v9.6.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.4.0
12
+ ▐▛███▜▌ Primo Code v9.6.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();
@@ -1523,72 +1638,29 @@ async function handleSlash(text) {
1523
1638
  break;
1524
1639
  }
1525
1640
 
1526
- const pronto = vozModo.conferir();
1527
- /* `.length`, e não a lista: [] é VERDADEIRO em JavaScript.
1528
- Quando só falta o tkinter — que o pip não instala, porque ele
1529
- vem com o Python ou num pacote do sistema — a lista de
1530
- instaláveis volta VAZIA. Sem o `.length`, este ramo era pulado,
1531
- o "Instalar agora?" aparecia, e o pip era chamado sem pacote
1532
- nenhum: "You must give at least one requirement to install".
1533
- Visto na máquina do dono. */
1534
- if (!pronto.ok && !(pronto.instalavel && pronto.instalavel.length)) {
1535
- console.log(c.err(` ${pronto.error}`));
1536
- if (pronto.comoResolver) console.log(c.muted(` ${pronto.comoResolver}`));
1641
+ /* ── PREPARAR TUDO, SEM PERGUNTAR ───────────────────────────
1642
+ "Instale tudo que precisa, rode tudo que precisa." O que falta
1643
+ (bibliotecas do sistema, pacotes do Python, o Whisper local e o
1644
+ reconhecedor do "ei, primo") é instalado aqui, com uma linha de
1645
+ progresso por etapa. Da segunda vez em diante, tudo já está lá
1646
+ e isto leva um segundo. Ver `preparar` em lib/voz.js.
1647
+
1648
+ O reconhecedor do nome é o que deixa o microfone aberto sem o
1649
+ áudio sair da máquina: o nome é reconhecido AQUI. Se ele não
1650
+ ficar pronto, o modo voz abre já ouvindo — nunca manda o dia
1651
+ inteiro de áudio para o servidor. */
1652
+ const sp = new Spinner('preparando o modo voz').start();
1653
+ const prep = await vozModo.preparar((msg) => { sp.text = msg; });
1654
+ sp.stop();
1655
+ if (!prep.ok) {
1656
+ console.log(c.err(` ${prep.error}`));
1657
+ if (prep.comoResolver) console.log(c.muted(` ${prep.comoResolver}`));
1658
+ for (const a of prep.avisos) console.log(c.dim(` ${a}`));
1537
1659
  break;
1538
1660
  }
1539
-
1540
- if (!pronto.ok) {
1541
- console.log(c.muted(` ${pronto.error}`));
1542
- if (!(await confirm('Instalar agora?'))) {
1543
- console.log(c.muted(' Sem eles o modo voz não abre.'));
1544
- if (pronto.comoResolver) console.log(c.dim(` ${pronto.comoResolver}`));
1545
- break;
1546
- }
1547
- const sp = new Spinner('instalando').start();
1548
- const r = await vozModo.instalar(pronto.python, pronto.instalavel, () => {});
1549
- sp.stop();
1550
- if (!r.ok) {
1551
- console.log(c.err(` não deu: ${r.error}`));
1552
- console.log(c.muted(` ${pronto.comoResolver}`));
1553
- break;
1554
- }
1555
- const conferido = vozModo.conferir();
1556
- if (!conferido.ok) {
1557
- console.log(c.err(` ainda falta: ${conferido.error}`));
1558
- break;
1559
- }
1560
- }
1561
-
1562
- const py = (pronto.ok ? pronto : vozModo.conferir()).python;
1563
-
1564
- /* ── O RECONHECEDOR LOCAL ────────────────────────────────────
1565
- Ele é o que permite o microfone ficar aberto sem o áudio sair
1566
- da máquina: o nome é reconhecido AQUI, e só depois disso a
1567
- frase viaja. São ~31 MB, uma vez, e ficam em ~/.primocode.
1568
-
1569
- Se a pessoa recusar, o modo voz ainda abre — mas já ouvindo,
1570
- sem espera. O que NÃO acontece em hipótese nenhuma é cair para
1571
- "manda tudo para o servidor o dia inteiro": isso seria trocar
1572
- a privacidade dela por conveniência sem perguntar. */
1573
- const espera = await vozModo.conferirEspera(py);
1574
- let esperando = espera.ok;
1575
- if (!espera.ok && espera.baixavel) {
1576
- console.log('');
1577
- console.log(' ' + c.muted('Para ficar ouvindo só o nome, sem mandar áudio para lugar nenhum,'));
1578
- console.log(' ' + c.muted('ele precisa do reconhecedor local — ') + c.dim('~31 MB, uma vez.'));
1579
- if (await confirm('Baixar agora?')) {
1580
- const sp = new Spinner('baixando o reconhecedor').start();
1581
- const r = await vozModo.baixarEspera(py);
1582
- sp.stop();
1583
- if (r.ok) esperando = true;
1584
- else {
1585
- console.log(c.err(` não deu: ${r.error}`));
1586
- if (r.comoResolver) console.log(c.dim(` ${r.comoResolver}`));
1587
- }
1588
- }
1589
- } else if (!espera.ok && espera.error) {
1590
- console.log(c.dim(` ${espera.error}`));
1591
- }
1661
+ for (const a of prep.avisos) console.log(c.dim(` ${a}`));
1662
+ const py = prep.python;
1663
+ const esperando = prep.esperando;
1592
1664
 
1593
1665
  console.log('');
1594
1666
  console.log(' ' + c.bold(c.brand('Primo Code Voice')));
@@ -1616,17 +1688,21 @@ async function handleSlash(text) {
1616
1688
  porque resposta boa de ler é ruim de ouvir — o trabalho é o
1617
1689
  mesmo, o jeito de contar é que muda. */
1618
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();
1619
1694
  const plan = prepararPasta(texto);
1620
- const resultado = await runAct(`${vozModo.COMO_FALAR}\n\nPEDIDO: ${texto}`, {
1695
+ const resultado = await runAct(texto, {
1696
+ instrucao: vozModo.COMO_FALAR,
1621
1697
  server: state.config.server,
1622
1698
  model: state.config.model,
1623
1699
  effort: state.config.effort,
1624
1700
  token: state.config.token,
1625
- history: memoria.paraEnviar(state.memoria, state.history),
1701
+ history: memoria.paraEnviar(state.memoria, state.history, contextoDoProjeto()),
1626
1702
  projectDir: plan.dir,
1627
1703
  projectName: plan.name || state.currentProject,
1628
1704
  ensureWorkDir: plan.pending ? () => createPendingProject(plan) : null,
1629
- autorizar: autorizarControle,
1705
+ autorizar: autorizarPorVoz,
1630
1706
  controle: state.controle,
1631
1707
  });
1632
1708
  const resposta = (resultado.assistantMessage && resultado.assistantMessage.content) || '';
@@ -1634,6 +1710,7 @@ async function handleSlash(text) {
1634
1710
  // continuar digitando não pode começar do zero.
1635
1711
  state.history.push({ role: 'user', content: texto });
1636
1712
  state.history.push({ role: 'assistant', content: resposta });
1713
+ resumirDepois();
1637
1714
  const balanco = registro.resumo(resultado.registro, { colunas: process.stdout.columns || 80 });
1638
1715
  if (balanco) console.log(balanco);
1639
1716
  return resposta;
@@ -1988,6 +2065,14 @@ async function main() {
1988
2065
  state.workDir = cfg.ROOT_DIR;
1989
2066
  state.currentProject = null;
1990
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
+ }
1991
2076
 
1992
2077
  // Modo guardado entre sessões. O padrão é auto: sem ele, um "oi" gasta o
1993
2078
  // mesmo que uma refatoração.
package/lib/act.js CHANGED
@@ -454,6 +454,12 @@ async function runAct(userPrompt, opts) {
454
454
  `test/repeticao.js` e `test/continuar.js` teriam de falar com a
455
455
  internet para verificar o loop do agente. */
456
456
  motor = api,
457
+ /* Instrução de FORMA que vai junto do pedido mas não É o pedido — a
458
+ do modo voz ("responda em três frases faladas"). Ela chega ao
459
+ modelo; a memória e o histórico guardam só o que a pessoa disse.
460
+ Antes ela ia colada no texto, e a memória do projeto enchia de
461
+ "[MODO VOZ] A sua resposta vai ser lida em voz alta…". */
462
+ instrucao = '',
457
463
  } = opts || {};
458
464
 
459
465
  // A pasta de projeto só nasce quando o agente for mesmo MEXER EM ARQUIVO.
@@ -463,6 +469,16 @@ async function runAct(userPrompt, opts) {
463
469
  let criarPasta = ensureWorkDir || null;
464
470
  let pastaAtual = projectDir; // onde as tools agem AGORA
465
471
  let temPasta = !criarPasta; // já é a pasta definitiva?
472
+ /* Cria a pasta do projeto pendente, uma vez. Chamado antes de qualquer
473
+ ferramenta que faz código (ver `criaProjeto` no catálogo) — e ANTES do
474
+ subagente e do pipeline, que senão escreviam na raiz de ~/primocode. */
475
+ const garantirPasta = () => {
476
+ if (!criarPasta) return;
477
+ const nova = criarPasta();
478
+ if (nova) pastaAtual = nova;
479
+ criarPasta = null;
480
+ temPasta = true;
481
+ };
466
482
  const falhasPendentes = [];
467
483
  let tentativasDeEncerrar = 0;
468
484
  // Quem já foi chamado, e quantas vezes. Ver lib/repeticao.js.
@@ -492,6 +508,7 @@ async function runAct(userPrompt, opts) {
492
508
  if (!subagent) {
493
509
  const comSkills = skills.montar(userPrompt);
494
510
  pedido = comSkills.texto;
511
+ if (instrucao) pedido = `${instrucao}\n\nPEDIDO: ${pedido}`;
495
512
  if (comSkills.skills.length) {
496
513
  fala.pensamento(`seguindo: ${comSkills.skills.map((s) => s.titulo).join(', ')}`, profundidade);
497
514
  }
@@ -627,7 +644,7 @@ async function runAct(userPrompt, opts) {
627
644
 
628
645
  emVoo = new AbortController();
629
646
  try {
630
- await motor.requestAct(server, { messages: compactar(messages), model, effort, token, projectDir, projectName, subagent, sinal: emVoo.signal }, (evt) => {
647
+ await motor.requestAct(server, { messages: compactar(messages), model, effort, token, projectDir: pastaAtual, projectName, subagent, sinal: emVoo.signal }, (evt) => {
631
648
  if (evt.erro) { streamError = evt.erro; return; }
632
649
  if (evt.tool_call) { toolCalls.push(evt.tool_call); return; }
633
650
  if (evt.delta) {
@@ -843,6 +860,7 @@ async function runAct(userPrompt, opts) {
843
860
  // sinal de decomposição ruim, e o excedente roda em série mesmo.
844
861
  const spawns = toolCalls.filter((tc) => tc.name === 'spawn_agent');
845
862
  const resultadosSpawn = new Map();
863
+ if (spawns.length && !PARAR) garantirPasta();
846
864
  if (spawns.length > 1 && !PARAR) {
847
865
  const juntos = spawns.slice(0, 4);
848
866
  fala.paralelo(juntos.length, profundidade);
@@ -906,6 +924,7 @@ async function runAct(userPrompt, opts) {
906
924
  esquecer de passar o token ou a pasta. */
907
925
  if (tc.name === 'construir_app') {
908
926
  algoRodou = true;
927
+ garantirPasta();
909
928
  fala.acao(tc.name, tc.arguments, profundidade);
910
929
  const r = await rodarPipeline(tc.arguments || {}, {
911
930
  server, model, token, projectDir: pastaAtual, projectName, profundidade, motor, livro, autorizar, controle: portaoControle,
@@ -928,12 +947,7 @@ async function runAct(userPrompt, opts) {
928
947
  continue;
929
948
  }
930
949
 
931
- if (criarPasta && catalogo.escreveArquivo(tc.name)) {
932
- const nova = criarPasta();
933
- if (nova) pastaAtual = nova;
934
- criarPasta = null;
935
- temPasta = true;
936
- }
950
+ if (criarPasta && catalogo.criaProjeto(tc.name, tc.arguments)) garantirPasta();
937
951
 
938
952
  if (tc.name === 'pesquisar_referencia' && pesquisasSecas >= 3) {
939
953
  voltasEmFalso++;
@@ -1209,9 +1223,13 @@ async function rodarPipeline(args, ctx) {
1209
1223
  const path = require('path');
1210
1224
  const pedido = args.pedido || args.prompt || '';
1211
1225
  const base = projectDir || cfg.ROOT_DIR;
1226
+ /* Dentro de um projeto, o app é montado NA pasta dele. A subpasta com
1227
+ nome derivado do pedido só vale na raiz: era ela que fazia o app nascer
1228
+ num lugar que a conversa nunca mais achava. */
1229
+ const naRaiz = path.resolve(base) === path.resolve(cfg.ROOT_DIR);
1212
1230
  const pasta = args.pasta
1213
1231
  ? path.resolve(base, args.pasta)
1214
- : pipeline.pastaPara(pedido, base);
1232
+ : (naRaiz ? pipeline.pastaPara(pedido, base) : base);
1215
1233
 
1216
1234
  const correr = async (prompt, rotulo) => {
1217
1235
  if (PARAR) return '';
package/lib/catalogo.js CHANGED
@@ -206,6 +206,25 @@ function escreveArquivo(tool) {
206
206
  return ESCREVEM.has(tool);
207
207
  }
208
208
 
209
+ /* ── O QUE FAZ NASCER UMA PASTA DE PROJETO ──────────────────────────────
210
+ * "Ela deve saber quando criar pasta do projeto e quando não criar — só vai
211
+ * criar quando fizer código." `escreve` era largo demais para isso: um print
212
+ * de tela, uma narração do estúdio e um vídeo também escrevem arquivo, e cada
213
+ * um deles criava um projeto com o nome da frase. Aqui só o que é CÓDIGO:
214
+ * escrever ou editar arquivo, montar um app, delegar a um especialista, e o
215
+ * comando de terminal que cria arquivo (o agente que faz `npm create` ou
216
+ * `mkdir` estava pondo o projeto solto na raiz de ~/primocode). */
217
+ const CRIAM_PROJETO = new Set(['write_file', 'edit_file', 'multi_edit', 'construir_app', 'spawn_agent']);
218
+ const SHELL_QUE_CRIA = /\b(mkdir|touch|git\s+clone|npm\s+(init|create)|npx\s+(create-|degit|nuxi\s+init)|yarn\s+create|pnpm\s+(create|dlx\s+create-)|bun\s+create|cargo\s+(new|init)|python3?\s+-m\s+venv|django-admin\s+startproject|rails\s+new|flutter\s+create|dotnet\s+new|composer\s+create-project|expo\s+init)\b|(^|[^<>&|0-9])>>?\s*[\w./~-]/;
219
+
220
+ function criaProjeto(tool, args) {
221
+ if (CRIAM_PROJETO.has(tool)) return true;
222
+ if (tool === 'run_shell' || tool === 'run_in_new_terminal') {
223
+ return SHELL_QUE_CRIA.test(String((args && (args.command || args.comando)) || ''));
224
+ }
225
+ return false;
226
+ }
227
+
209
228
 
210
229
  /**
211
230
  * As ferramentas de nome mais parecido com o que foi chamado.
@@ -244,5 +263,5 @@ function parecidasCom(nome) {
244
263
  return pontos.slice(0, 6).map((x) => x.t);
245
264
  }
246
265
 
247
- module.exports = {
266
+ module.exports = { criaProjeto,
248
267
  parecidasCom, GRUPOS, TODAS, ESCREVEM, escreveArquivo };