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/lib/config.js CHANGED
@@ -21,6 +21,7 @@ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
21
21
  const HISTORY_FILE = path.join(CONFIG_DIR, 'history.jsonl');
22
22
  const CONTA_FILE = path.join(CONFIG_DIR, 'conta.json');
23
23
  const GENERAL_MEMORY = path.join(ROOT_DIR, 'memory-geral.md');
24
+ const PROJETO_ATIVO_FILE = path.join(CONFIG_DIR, 'projeto-ativo.json');
24
25
 
25
26
  /* O token da conta, lido DIRETO do arquivo.
26
27
  *
@@ -309,7 +310,35 @@ function appendHistory(entry) {
309
310
  } catch {}
310
311
  }
311
312
 
313
+ /* ── O PROJETO EM QUE SE ESTAVA ─────────────────────────────────────────
314
+ * "Eu peço o jogo de carro, depois peço pra editar ou continuar, e ele cria
315
+ * outro projeto." Nada sobrevivia entre uma sessão e outra: cada abertura
316
+ * começava em ~/primocode, e o "continua" virava projeto novo. Agora o projeto
317
+ * ativo fica guardado aqui, num arquivo só dele (a config tem lista fechada de
318
+ * campos), e a próxima sessão continua nele. Vale por uma semana: depois
319
+ * disso, abrir o PrimoCode e cair num projeto esquecido seria a surpresa. */
320
+ function lembrarProjeto(dir, origem = 'projeto') {
321
+ try {
322
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
323
+ fs.writeFileSync(PROJETO_ATIVO_FILE, JSON.stringify({ dir, origem, quando: Date.now() }, null, 2), 'utf-8');
324
+ } catch { /* lembrar é conveniência: falhou, a sessão segue igual */ }
325
+ }
326
+
327
+ function projetoLembrado(maxDias = 7) {
328
+ try {
329
+ const j = JSON.parse(fs.readFileSync(PROJETO_ATIVO_FILE, 'utf-8'));
330
+ if (!j || !j.dir || !fs.existsSync(j.dir) || !fs.statSync(j.dir).isDirectory()) return null;
331
+ if (Date.now() - Number(j.quando || 0) > maxDias * 86400000) return null;
332
+ return { dir: j.dir, origem: j.origem || 'projeto' };
333
+ } catch { return null; }
334
+ }
335
+
336
+ function esquecerProjeto() {
337
+ try { fs.rmSync(PROJETO_ATIVO_FILE, { force: true }); } catch { /* nada a esquecer */ }
338
+ }
339
+
312
340
  module.exports = {
341
+ lembrarProjeto, projetoLembrado, esquecerProjeto, PROJETO_ATIVO_FILE,
313
342
  loadConfig, saveConfig, appendHistory,
314
343
  CONFIG_DIR, CONFIG_FILE, HISTORY_FILE, ROOT_DIR, GENERAL_MEMORY,
315
344
  ensureStructure, slugify, stripAccents,
package/lib/cursor.js ADDED
@@ -0,0 +1,120 @@
1
+ /**
2
+ * cursor.js — o lado Node do cursor do Primo (só macOS).
3
+ *
4
+ * "Quando ele tá no modo, eu consigo ver o cursor dele, o mouse, ver ele
5
+ * digitando, abrindo as coisas, ver ele fazer as coisas em tempo real."
6
+ *
7
+ * O cursor mora em voz/primo_voz/cursor.py: um processo Python que mexe no
8
+ * mouse e no teclado de verdade (Quartz) e desenha a seta dele por cima. Aqui
9
+ * é a ponta de cá: subir o processo na PRIMEIRA ação no computador (não antes
10
+ * — quem só escreve código nunca paga por ele), mandar os comandos e esperar
11
+ * cada um terminar.
12
+ *
13
+ * ── POR QUE ELE, E NÃO O cliclick ───────────────────────────────────────
14
+ * O cliclick precisava de Homebrew, e sem ele TODO clique do agente falhava —
15
+ * foi o que fez "mostra o teu mouse" virar uma página HTML com coordenadas. O
16
+ * Quartz vem com o sistema; o que falta instalar é o PyObjC, pelo pip, e isso
17
+ * este módulo faz sozinho.
18
+ *
19
+ * Fora do macOS, ou se o Python/PyObjC não estiverem lá, `disponivel()` diz
20
+ * que não e o desktop.js segue com o caminho de sempre.
21
+ */
22
+
23
+ 'use strict';
24
+
25
+ const path = require('path');
26
+ const { spawn, execFileSync } = require('child_process');
27
+
28
+ const PASTA = path.join(__dirname, '..', 'voz');
29
+
30
+ let proc = null; // o processo do cursor, quando de pé
31
+ let pronto = null; // Promise<boolean> da subida
32
+ let fila = new Map(); // id → { resolve, timer }
33
+ let proximo = 1;
34
+ let semCursor = null; // motivo, quando não dá para ter cursor nesta máquina
35
+
36
+ function pythonComQuartz() {
37
+ let py = null;
38
+ try { py = require('./voz.js').python(); } catch { /* sem voz.js: sem cursor */ }
39
+ if (!py) return { py: null };
40
+ try {
41
+ execFileSync(py, ['-c', 'import Quartz, AppKit, PyObjCTools'], { stdio: 'ignore', timeout: 20000 });
42
+ return { py, tem: true };
43
+ } catch {
44
+ return { py, tem: false };
45
+ }
46
+ }
47
+
48
+ /** Sobe o processo do cursor, instalando o PyObjC se faltar. Uma vez. */
49
+ function subir() {
50
+ if (pronto) return pronto;
51
+ pronto = (async () => {
52
+ if (process.platform !== 'darwin') { semCursor = 'só no macOS'; return false; }
53
+ let { py, tem } = pythonComQuartz();
54
+ if (!py) { semCursor = 'sem Python 3'; return false; }
55
+ if (!tem) {
56
+ const voz = require('./voz.js');
57
+ const r = await voz.instalar(py, ['pyobjc-framework-Quartz', 'pyobjc-framework-Cocoa']);
58
+ if (!r.ok || !pythonComQuartz().tem) { semCursor = `não instalou o PyObjC: ${r.error || ''}`; return false; }
59
+ }
60
+ return await new Promise((resolve) => {
61
+ proc = spawn(py, ['-m', 'primo_voz.cursor'], {
62
+ cwd: PASTA,
63
+ env: { ...process.env, PYTHONPATH: PASTA, PYTHONUNBUFFERED: '1' },
64
+ stdio: ['pipe', 'pipe', 'ignore'],
65
+ });
66
+ let buffer = '';
67
+ let subiu = false;
68
+ proc.stdout.on('data', (d) => {
69
+ buffer += d.toString();
70
+ const linhas = buffer.split('\n');
71
+ buffer = linhas.pop();
72
+ for (const linha of linhas) {
73
+ let msg;
74
+ try { msg = JSON.parse(linha); } catch { continue; }
75
+ if (msg.pronto && !subiu) { subiu = true; resolve(true); continue; }
76
+ const espera = fila.get(msg.id);
77
+ if (espera) { clearTimeout(espera.timer); fila.delete(msg.id); espera.resolve(msg); }
78
+ }
79
+ });
80
+ const morreu = () => {
81
+ proc = null;
82
+ pronto = null; // a próxima ação tenta subir de novo
83
+ for (const [, e] of fila) { clearTimeout(e.timer); e.resolve({ ok: false, error: 'o cursor fechou' }); }
84
+ fila = new Map();
85
+ if (!subiu) { semCursor = 'o processo do cursor não subiu'; resolve(false); }
86
+ };
87
+ proc.on('exit', morreu);
88
+ proc.on('error', morreu);
89
+ setTimeout(() => { if (!subiu) { semCursor = 'o cursor demorou demais para abrir'; resolve(false); } }, 20000);
90
+ // O cursor não pode segurar o PrimoCode aberto na saída.
91
+ proc.unref();
92
+ proc.stdout.unref && proc.stdout.unref();
93
+ });
94
+ })();
95
+ return pronto;
96
+ }
97
+
98
+ /** Manda um comando e espera a resposta (a animação inteira, no caso do mouse). */
99
+ async function mandar(cmd, args = {}, tetoMs = 60000) {
100
+ const ok = await subir();
101
+ if (!ok || !proc) return { ok: false, semCursor: true, error: semCursor || 'cursor indisponível' };
102
+ const id = proximo++;
103
+ return new Promise((resolve) => {
104
+ const timer = setTimeout(() => { fila.delete(id); resolve({ ok: false, error: 'o cursor não respondeu a tempo' }); }, tetoMs);
105
+ fila.set(id, { resolve, timer });
106
+ try { proc.stdin.write(JSON.stringify({ id, cmd, ...args }) + '\n'); }
107
+ catch (e) { clearTimeout(timer); fila.delete(id); resolve({ ok: false, error: e.message }); }
108
+ });
109
+ }
110
+
111
+ /** Esta máquina pode ter o cursor do Primo? (sem subir nada) */
112
+ function disponivel() {
113
+ return process.platform === 'darwin' && process.env.PRIMOCODE_SEM_CURSOR !== '1';
114
+ }
115
+
116
+ function fechar() {
117
+ if (proc) { try { proc.stdin.end(); } catch { /* já fechou */ } }
118
+ }
119
+
120
+ module.exports = { disponivel, mandar, subir, fechar };
@@ -17,10 +17,17 @@
17
17
  * O custo fica limitado por construção: a entrada do resumo é sempre
18
18
  * "resumo de antes + o que aconteceu desde então", nunca a conversa inteira.
19
19
  *
20
- * ── O QUE NUNCA É RESUMIDO ───────────────────────────────────────────────
21
- * As últimas rodadas vão CRUAS. Resumo é bom para lembrar do que foi feito,
22
- * péssimo para continuar de onde parou: "agora põe música de fundo" precisa da
23
- * frase anterior literal, não de um tópico dizendo que houve um vídeo.
20
+ * ── RESUMO A CADA MENSAGEM (23/09/2026) ──────────────────────────────────
21
+ * "Pra não demorar tanto as respostas, quero que ela gere um resumo a cada
22
+ * mensagem, pra daí na próxima mensagem ela ler só esse resumo, não precisa
23
+ * ler a conversa inteira."
24
+ *
25
+ * Antes, o resumo só nascia quando a conversa passava de ~700 tokens, e as
26
+ * últimas quatro mensagens iam sempre cruas. Agora TODA resposta é dobrada no
27
+ * resumo logo depois de terminar (em segundo plano, sem segurar o prompt), e o
28
+ * pedido seguinte leva só o resumo — mais o que, por falha ou pressa, ainda
29
+ * não entrou nele. O resumo é que carrega o "de onde parei": ele é instruído a
30
+ * guardar o último pedido, o que foi feito e o projeto em que se estava.
24
31
  *
25
32
  * Este arquivo é puro — quem fala com o servidor é quem chama `atualizar`.
26
33
  * Assim o teste mede a política inteira sem rede e sem chave de API.
@@ -34,10 +41,10 @@ function tokensDe(x) {
34
41
  return Math.ceil(texto.length / 4);
35
42
  }
36
43
 
37
- // Quantas mensagens do fim ficam sempre cruas (o "de onde parei").
38
- const CRUAS = 4;
39
- // Acima disto, a cauda vira resumo. Abaixo, não compensa pagar a chamada.
40
- const LIMITE_TOKENS = 700;
44
+ // Nenhuma mensagem fica de fora do resumo: ele é atualizado a cada rodada.
45
+ const CRUAS = 0;
46
+ // Qualquer coisa nova já vale o resumo — é o que deixa o pedido seguinte leve.
47
+ const LIMITE_TOKENS = 1;
41
48
 
42
49
  /** Estado inicial — o que o CLI guarda entre um pedido e outro. */
43
50
  function nova() {
@@ -103,13 +110,15 @@ function inicioSeguro(todas, alvo) {
103
110
  * O resumo entra como mensagem de sistema, não como fala do usuário — o modelo
104
111
  * precisa saber que aquilo é memória, e não algo que alguém acabou de dizer.
105
112
  */
106
- function paraEnviar(memoria, history) {
113
+ function paraEnviar(memoria, history, contexto = '') {
107
114
  const todas = history || [];
108
115
  if (!memoria || !memoria.resumo) return todas.slice(inicioSeguro(todas, todas.length - 20));
109
116
 
110
- const cruas = todas.slice(inicioSeguro(todas, todas.length - CRUAS));
117
+ // Só o que ainda NÃO entrou no resumo vai cru — normalmente, nada.
118
+ const cruas = todas.slice(inicioSeguro(todas, Math.min(memoria.resumidasAte, todas.length)));
119
+ const extra = contexto ? `\n\n${contexto}` : '';
111
120
  return [
112
- { role: 'system', content: `Resumo da conversa até aqui (memória, não é fala de ninguém):\n${memoria.resumo}` },
121
+ { role: 'system', content: `Resumo da conversa até aqui (memória, não é fala de ninguém):\n${memoria.resumo}${extra}` },
113
122
  ...cruas,
114
123
  ];
115
124
  }
package/lib/tools.js CHANGED
@@ -15,6 +15,29 @@ const os = require('os');
15
15
  const { exec, execFile } = require('child_process');
16
16
  const browser = require('./browser');
17
17
  const desktop = require('./desktop');
18
+ const cursor = require('./cursor');
19
+
20
+ /* ── O CURSOR DO PRIMO NO MAC ────────────────────────────────────────────
21
+ * No macOS, mouse e teclado passam pelo cursor dele (lib/cursor.js): a mesma
22
+ * ação acontece de verdade, e a pessoa VÊ a seta dele indo até lá, clicando,
23
+ * digitando. Sem o cursor (outro sistema, ou o Python não subiu), vale o
24
+ * caminho antigo do desktop.js. */
25
+ async function peloCursor(cmd, args, reserva) {
26
+ if (!cursor.disponivel()) return reserva();
27
+ const r = await cursor.mandar(cmd, args);
28
+ if (r.semCursor) return reserva();
29
+ if (!r.ok) return { ok: false, error: r.error || 'o cursor não conseguiu', ...(r.permissao === false ? { permissao: false } : {}) };
30
+ return { ok: true };
31
+ }
32
+
33
+ function numero(v) { const n = Number(v); return Number.isFinite(n) ? n : null; }
34
+
35
+ async function clicarPeloCursor(x, y, { botao = 'left', duplo = false, rotulo } = {}) {
36
+ const [nx, ny] = [numero(x), numero(y)];
37
+ if (nx == null || ny == null) return { ok: false, error: `Coordenada inválida: x=${x}, y=${y}` };
38
+ return peloCursor('clicar', { x: nx, y: ny, botao: botao === 'right' ? 'direito' : 'esquerdo', duplo, rotulo },
39
+ () => (duplo ? desktop.doubleClick(nx, ny) : desktop.click(nx, ny, botao)));
40
+ }
18
41
  const referencia = require('./referencia');
19
42
  const estudio = require('./estudio');
20
43
  const cfg = require('./config');
@@ -538,19 +561,46 @@ async function despachar(name, args, ctx) {
538
561
  // resolver dentro do projeto, não do cwd real do processo.
539
562
  return desktop.screenshot(resolveInProject(args.path || `desktop-${Date.now()}.png`, projectDir));
540
563
  case 'desktop_click':
541
- return desktop.click(args.x, args.y, args.button || 'left');
542
- case 'desktop_move':
543
- return desktop.move(args.x, args.y);
544
- case 'desktop_drag':
545
- return desktop.drag(args.from_x, args.from_y, args.to_x, args.to_y);
564
+ return clicarPeloCursor(args.x, args.y, { botao: args.button || 'left', rotulo: 'clicando' });
565
+ case 'desktop_move': {
566
+ const [x, y] = [numero(args.x), numero(args.y)];
567
+ if (x == null || y == null) return { ok: false, error: `Coordenada inválida: x=${args.x}, y=${args.y}` };
568
+ return peloCursor('mover', { x, y }, () => desktop.move(x, y));
569
+ }
570
+ case 'desktop_drag': {
571
+ const c4 = [args.from_x, args.from_y, args.to_x, args.to_y].map(numero);
572
+ if (c4.some((v) => v == null)) return { ok: false, error: 'Coordenada inválida no drag' };
573
+ return peloCursor('arrastar', { x1: c4[0], y1: c4[1], x2: c4[2], y2: c4[3], rotulo: 'arrastando' },
574
+ () => desktop.drag(...c4));
575
+ }
546
576
  case 'desktop_type':
547
- return desktop.type(args.text || '');
548
- case 'desktop_key':
549
- return desktop.key(args.key);
577
+ return peloCursor('digitar', { texto: args.text || '', rotulo: 'digitando' },
578
+ () => desktop.type(args.text || ''));
579
+ case 'desktop_key': {
580
+ const r = await peloCursor('tecla', { combo: args.key || '', rotulo: String(args.key || '') },
581
+ () => desktop.key(args.key));
582
+ // Tecla SOLTA que o cursor não conhece: o desktop.js sabe mais
583
+ // nomes. Combinação com tecla desconhecida ("cmd+xyz") NÃO cai
584
+ // lá: o osascript digitaria x, y e z com Command apertado —
585
+ // recortar e desfazer no app da frente.
586
+ if (!r.ok && /tecla desconhecida/.test(r.error || '') && !String(args.key || '').includes('+')) {
587
+ return desktop.key(args.key);
588
+ }
589
+ return r;
590
+ }
550
591
  case 'desktop_scroll':
551
- return desktop.scroll(args.dy != null ? args.dy : 300);
552
- case 'desktop_size':
592
+ return peloCursor('rolar', { dy: args.dy != null ? Number(args.dy) : 300, rotulo: 'rolando' },
593
+ () => desktop.scroll(args.dy != null ? args.dy : 300));
594
+ case 'desktop_size': {
595
+ // No Mac, em PONTOS — a mesma unidade dos cliques e das posições
596
+ // dos botões. O system_profiler dava pixels físicos (2880 num
597
+ // Retina de 1440), e o clique caía no lugar errado.
598
+ if (cursor.disponivel()) {
599
+ const t = await cursor.mandar('tamanho');
600
+ if (t.ok) return { ok: true, width: t.largura, height: t.altura, unidade: 'pontos' };
601
+ }
553
602
  return desktop.getScreenSize();
603
+ }
554
604
  /* ── O ESTÚDIO ─────────────────────────────────────────────────
555
605
  * Sete ferramentas, e nenhuma delas fala com servidor nenhum: o
556
606
  * Studio antigo subia um Python em localhost:7777 porque tinha
@@ -583,21 +633,42 @@ async function despachar(name, args, ctx) {
583
633
  return estudio.ferramentaSite(args);
584
634
 
585
635
  case 'desktop_double_click':
586
- return desktop.doubleClick(args.x, args.y);
587
- case 'desktop_open_app':
588
- return desktop.openApp(args.app || args.name || '');
636
+ return clicarPeloCursor(args.x, args.y, { duplo: true, rotulo: 'abrindo' });
637
+ case 'desktop_open_app': {
638
+ const app = args.app || args.name || '';
639
+ if (cursor.disponivel()) await cursor.mandar('rotulo', { texto: `abrindo ${app}` });
640
+ return desktop.openApp(app);
641
+ }
589
642
  case 'desktop_windows':
590
643
  return desktop.listWindows();
591
644
  case 'desktop_focus':
592
645
  return desktop.focusWindow(args.window || args.title || '');
593
646
  case 'desktop_elements':
594
647
  return desktop.uiElements(args.window);
595
- case 'desktop_click_element':
596
- return desktop.clickElement(args.name || args.text || '', {
597
- window: args.window,
598
- double: !!args.double,
599
- button: args.button,
648
+ case 'desktop_click_element': {
649
+ if (!cursor.disponivel()) {
650
+ return desktop.clickElement(args.name || args.text || '', {
651
+ window: args.window, double: !!args.double, button: args.button,
652
+ });
653
+ }
654
+ // Mesma escolha do desktop.clickElement, mas o clique vai pelo
655
+ // cursor: a pessoa vê a seta ir até o botão pelo nome.
656
+ const pedido = String(args.name || args.text || '').trim();
657
+ if (!pedido) return { ok: false, error: 'Diga o nome do elemento (o texto do botão, ícone ou campo).' };
658
+ const lidos = desktop.uiElements(args.window);
659
+ if (!lidos.ok) return lidos;
660
+ const alvo = desktop.escolherElemento(lidos.elementos, pedido);
661
+ if (!alvo) {
662
+ return { ok: false, error: `Nenhum elemento parecido com "${pedido}" na janela "${lidos.janela}".`,
663
+ elementosDisponiveis: lidos.elementos.slice(0, 40).map((e) => e.nome),
664
+ comoCorrigir: 'Chame de novo com um nome de "elementosDisponiveis", copiado literalmente.' };
665
+ }
666
+ const r = await clicarPeloCursor(alvo.x, alvo.y, {
667
+ botao: args.button || 'left', duplo: !!args.double, rotulo: `clicando em ${alvo.nome}`.slice(0, 44),
600
668
  });
669
+ if (!r.ok) return r;
670
+ return { ok: true, clicado: alvo.nome, tipo: alvo.tipo, x: alvo.x, y: alvo.y, duplo: !!args.double };
671
+ }
601
672
 
602
673
  default:
603
674
  /* O MODELO PRECISA CONSEGUIR SE CORRIGIR AQUI.
package/lib/ui.js CHANGED
@@ -319,6 +319,9 @@ const ROTULOS = {
319
319
  function actionHeader(name, args) {
320
320
  const label = ROTULOS[name] || name;
321
321
  let argStr = args ? (args.consulta || args.path || args.pattern || args.command || args.target || args.url || args.selector || args.text || args.app || args.window || args.name || args.ferramenta || args.titulo || args.description || (args.todos ? `${args.todos.length} tarefas` : '') || (args.x != null ? `${args.x},${args.y}` : '') || args.key || args.arquivo || args.id || JSON.stringify(args).slice(0,60)) : '';
322
+ // Campo que veio como objeto (o modelo às vezes manda {"window": {}})
323
+ // virava "[object Object]" na tela.
324
+ if (argStr && typeof argStr === 'object') argStr = JSON.stringify(argStr).slice(0, 60);
322
325
  argStr = String(argStr || '').replace(/\s+/g, ' ').trim();
323
326
  // Uma tool sem argumento nenhum caía no JSON.stringify e desenhava "{}"
324
327
  // ao lado do nome — ruído que não diz nada.
package/lib/voz.js CHANGED
@@ -32,10 +32,23 @@ const { spawn, execFileSync } = require('child_process');
32
32
 
33
33
  const PASTA = path.join(__dirname, '..', 'voz');
34
34
 
35
+ /* ── A DICA DO COMPUTADOR ────────────────────────────────────────────────
36
+ * Duas razões para existir, e as duas foram medidas:
37
+ *
38
+ * 1. O servidor só oferece as ferramentas desktop_* ao modelo quando o pedido
39
+ * cita palavras como "mouse", "teclado", "ícone", "atalho", "janela" (o
40
+ * grupo "computador" em lib/orcamento-entrada.js do servidor). "Abre o
41
+ * Safari e pesquisa X" não cita nenhuma — e o modelo ficava sem as mãos.
42
+ * Esta dica as cita de propósito.
43
+ * 2. "Mostra o teu mouse" virou uma página HTML com as coordenadas do mouse
44
+ * DA PESSOA. O certo é mexer o cursor dele, que ela vê na tela.
45
+ */
46
+ const DICA_COMPUTADOR = `[COMPUTADOR] Você pode usar o mouse, o teclado, as janelas e os ícones do computador da pessoa com as ferramentas desktop_* (desktop_open_app, desktop_click_element, desktop_click, desktop_type, desktop_key para atalho, desktop_scroll). Cada movimento aparece na tela como o SEU cursor. Para abrir um app, use desktop_open_app; para clicar em algo, prefira desktop_click_element pelo nome (leia os nomes com desktop_elements); coordenadas são em pontos da tela (desktop_size). Se pedirem para "mostrar o seu mouse/cursor", MEXA o cursor com desktop_move — nunca crie uma página ou arquivo para isso.`;
47
+
35
48
  /* A instrução de FORMA. Ela não muda o que o agente faz — muda como ele
36
49
  conta. O que está proibido aqui é o que não existe em voz: marcador,
37
50
  caminho, código, e parágrafo que ninguém segura de ouvido. */
38
- const COMO_FALAR = `[MODO VOZ] A sua resposta vai ser LIDA EM VOZ ALTA para a pessoa, e ela não vê tela nenhuma.
51
+ const COMO_FALAR = `[MODO VOZ] A sua resposta vai ser LIDA EM VOZ ALTA. A pessoa está olhando a tela: ela vê o SEU cursor (o mouse e o teclado do computador dela, que você controla) se mexendo em tempo real.
39
52
 
40
53
  Responda em no máximo 3 frases curtas, em português falado. Nada de lista com
41
54
  marcador, nada de caminho de arquivo, nada de bloco de código, nada de
@@ -44,6 +57,8 @@ markdown — nada disso existe no ouvido de alguém.
44
57
  Diga o que você FEZ e o que mudou, não como. Se algo deu errado, diga em uma
45
58
  frase o que foi e o que você precisa.
46
59
 
60
+ ${DICA_COMPUTADOR}
61
+
47
62
  Faça o trabalho inteiro normalmente, com as ferramentas de sempre. O que muda
48
63
  é só o jeito de contar no fim.`;
49
64
 
@@ -237,6 +252,10 @@ function abrir({ python: py, servidor, token, voz, pensar, mostrar = () => {}, e
237
252
  // Dormir até ouvir o nome, em vez de já entrar ouvindo.
238
253
  PRIMOCODE_VOZ_ESPERA: esperando ? '1' : '',
239
254
  PYTHONUNBUFFERED: '1',
255
+ // O Whisper deixa um semáforo para o sistema recolher na saída,
256
+ // e o Python avisa disso no stderr — que aqui vira texto no
257
+ // terminal da pessoa. É inofensivo; o aviso é que não é.
258
+ PYTHONWARNINGS: 'ignore::UserWarning:multiprocessing.resource_tracker',
240
259
  },
241
260
  });
242
261
 
@@ -387,4 +406,172 @@ print(json.dumps(desperta.baixar_modelo()))
387
406
  });
388
407
  }
389
408
 
390
- module.exports = { conferir, instalar, porqueOPipFalhou, abrir, conferirEspera, baixarEspera, python, COMO_FALAR, PASTA };
409
+ /* ── PREPARAR TUDO, SEM PERGUNTAR ────────────────────────────────────────
410
+ *
411
+ * "Instale tudo que precisa, rode tudo que precisa, então fica redondinho,
412
+ * já funcionando." (23/09/2026)
413
+ *
414
+ * O /voice perguntava duas vezes ("Instalar agora?", "Baixar agora?") e
415
+ * parava em cada falta com um comando para a pessoa copiar. Agora ele mesmo
416
+ * resolve o que dá para resolver, nesta ordem:
417
+ *
418
+ * 1. bibliotecas do SISTEMA (PortAudio e o Tk do Python) — pelo Homebrew no
419
+ * macOS; no Linux, `sudo -n` (só funciona sem senha; senão, o comando);
420
+ * 2. pacotes do Python — os obrigatórios (numpy, sounddevice) e os que
421
+ * deixam tudo bonito e local (faster-whisper, vosk, PyObjC no macOS);
422
+ * 3. os modelos — o Whisper (transcrição local) e o reconhecedor do nome.
423
+ *
424
+ * Só o que é OBRIGATÓRIO faltar impede o modo voz de abrir. Faltar um opcional
425
+ * vira um aviso de uma linha, e o modo voz abre mesmo assim.
426
+ */
427
+ const OPCIONAIS = () => [
428
+ ['faster_whisper', 'faster-whisper', 'a transcrição local (Whisper)'],
429
+ ['vosk', 'vosk', 'o reconhecedor local do "ei, primo"'],
430
+ ...(process.platform === 'darwin' ? [
431
+ ['AppKit', 'pyobjc-framework-Cocoa', 'a orb transparente do macOS'],
432
+ ['Quartz', 'pyobjc-framework-Quartz', 'o cursor do Primo na tela'],
433
+ ] : []),
434
+ ];
435
+
436
+ function temModulo(py, modulo) {
437
+ try {
438
+ execFileSync(py, ['-c', `import ${modulo}`], { stdio: ['ignore', 'pipe', 'pipe'], timeout: 30000 });
439
+ return true;
440
+ } catch { return false; }
441
+ }
442
+
443
+ function rodar(cmd, args, timeout = 15 * 60 * 1000) {
444
+ return new Promise((resolve) => {
445
+ let saida = '';
446
+ let p;
447
+ try { p = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); }
448
+ catch (e) { return resolve({ ok: false, saida: e.message }); }
449
+ const t = setTimeout(() => { try { p.kill(); } catch {} }, timeout);
450
+ p.stdout.on('data', (d) => { saida += d.toString(); });
451
+ p.stderr.on('data', (d) => { saida += d.toString(); });
452
+ p.on('close', (code) => { clearTimeout(t); resolve({ ok: code === 0, saida }); });
453
+ p.on('error', (e) => { clearTimeout(t); resolve({ ok: false, saida: e.message }); });
454
+ });
455
+ }
456
+
457
+ function brew() {
458
+ for (const b of ['brew', '/opt/homebrew/bin/brew', '/usr/local/bin/brew']) {
459
+ try { execFileSync(b, ['--version'], { stdio: 'ignore', timeout: 10000 }); return b; } catch { /* próximo */ }
460
+ }
461
+ return null;
462
+ }
463
+
464
+ /** As bibliotecas do sistema que faltam, instaladas por quem der. */
465
+ async function instalarSistema(py, falta, aoFalar) {
466
+ const avisos = [];
467
+ const precisa = [];
468
+ if (falta.some((f) => f.modulo === 'sounddevice' && f.sistema)) precisa.push('portaudio');
469
+ if (falta.some((f) => f.modulo === 'tkinter')) precisa.push('tkinter');
470
+ if (!precisa.length) return avisos;
471
+
472
+ if (process.platform === 'darwin') {
473
+ const b = brew();
474
+ if (!b) {
475
+ avisos.push('o Homebrew não está instalado, então não deu para instalar '
476
+ + precisa.join(' e ') + ' sozinho — instale em brew.sh e rode /voice de novo.');
477
+ return avisos;
478
+ }
479
+ // O Tk do Homebrew tem versão casada com a do Python: python-tk@3.14
480
+ // serve ao python3.14, e só a ele.
481
+ let versao = '';
482
+ try { versao = execFileSync(py, ['-c', 'import sys;print("%d.%d"%sys.version_info[:2])']).toString().trim(); } catch {}
483
+ const formulas = precisa.map((x) => (x === 'tkinter' ? (versao ? `python-tk@${versao}` : 'python-tk') : x));
484
+ aoFalar(`instalando ${formulas.join(', ')} pelo Homebrew`);
485
+ const r = await rodar(b, ['install', ...formulas]);
486
+ if (!r.ok) avisos.push(`o Homebrew não instalou ${formulas.join(', ')}: ${porqueOPipFalhou(r.saida)}`);
487
+ return avisos;
488
+ }
489
+ if (process.platform === 'linux') {
490
+ const pacotes = precisa.map((x) => (x === 'tkinter' ? 'python3-tk' : 'libportaudio2'));
491
+ aoFalar(`instalando ${pacotes.join(', ')}`);
492
+ const r = await rodar('sudo', ['-n', 'apt-get', 'install', '-y', ...pacotes]);
493
+ if (!r.ok) avisos.push(`precisa de senha para instalar: sudo apt install ${pacotes.join(' ')}`);
494
+ return avisos;
495
+ }
496
+ avisos.push(precisa.map((x) => COMO_SISTEMA[x === 'tkinter' ? 'tkinter' : 'portaudio'].win32).join(' · '));
497
+ return avisos;
498
+ }
499
+
500
+ /** O Whisper local, baixado antes da primeira frase. ~480 MB, uma vez. */
501
+ async function baixarWhisper(py) {
502
+ const r = await new Promise((resolve) => {
503
+ const p = spawn(py, ['-m', 'primo_voz.transcrever', 'baixar'], {
504
+ cwd: PASTA, env: { ...process.env, PYTHONPATH: PASTA },
505
+ });
506
+ let fora = '';
507
+ let erro = '';
508
+ p.stdout.on('data', (d) => { fora += d.toString(); });
509
+ p.stderr.on('data', (d) => { erro += d.toString(); });
510
+ p.on('close', () => {
511
+ try { resolve(JSON.parse(fora.trim().split('\n').pop())); }
512
+ catch { resolve({ ok: false, error: erro.trim().split('\n').pop() || 'o download não respondeu' }); }
513
+ });
514
+ p.on('error', (e) => resolve({ ok: false, error: e.message }));
515
+ });
516
+ return r;
517
+ }
518
+
519
+ /**
520
+ * Deixa o modo voz pronto: instala e baixa tudo o que falta, sem perguntar.
521
+ *
522
+ * @param {(msg:string)=>void} aoFalar uma linha de progresso por etapa
523
+ * @returns {Promise<{ok:boolean, python?:string, esperando?:boolean,
524
+ * avisos:string[], error?:string, comoResolver?:string}>}
525
+ */
526
+ async function preparar(aoFalar = () => {}) {
527
+ const py = python();
528
+ if (!py) {
529
+ return { ok: false, avisos: [], error: 'o modo voz precisa do Python 3, e não achei nenhum nesta máquina.',
530
+ comoResolver: process.platform === 'darwin' ? 'brew install python' : 'instale em python.org e abra o terminal de novo' };
531
+ }
532
+ const avisos = [];
533
+
534
+ // 1. sistema, depois 2. os obrigatórios pelo pip.
535
+ let falta = faltando(py);
536
+ avisos.push(...await instalarSistema(py, falta, aoFalar));
537
+ falta = faltando(py);
538
+ const pipObrigatorio = falta.filter((f) => f.pacote).map((f) => f.pacote);
539
+ if (pipObrigatorio.length) {
540
+ const r = await instalar(py, pipObrigatorio, aoFalar);
541
+ if (!r.ok) avisos.push(`o pip não instalou ${pipObrigatorio.join(', ')}: ${r.error}`);
542
+ falta = faltando(py);
543
+ }
544
+ if (falta.length) {
545
+ const c = conferir();
546
+ return { ok: false, python: py, avisos, error: c.error, comoResolver: c.comoResolver };
547
+ }
548
+
549
+ // 2b. os opcionais: faltar um deles não impede o modo voz de abrir.
550
+ const opcionais = OPCIONAIS().filter(([m]) => !temModulo(py, m));
551
+ if (opcionais.length) {
552
+ const r = await instalar(py, opcionais.map(([, pacote]) => pacote), aoFalar);
553
+ for (const [m, , para] of opcionais) {
554
+ if (!temModulo(py, m)) avisos.push(`sem ${para}: ${r.ok ? 'o pacote não carregou' : r.error}`);
555
+ }
556
+ }
557
+
558
+ // 3. os modelos.
559
+ if (temModulo(py, 'faster_whisper')) {
560
+ aoFalar('preparando a transcrição local (Whisper, ~480 MB na primeira vez)');
561
+ const w = await baixarWhisper(py);
562
+ if (!w.ok) avisos.push(`a transcrição local não ficou pronta (${w.error}) — vai pelo servidor.`);
563
+ }
564
+ let esperando = false;
565
+ const espera = conferirEspera(py);
566
+ if (espera.ok) esperando = true;
567
+ else if (espera.baixavel) {
568
+ aoFalar('baixando o reconhecedor do "ei, primo" (~31 MB)');
569
+ const r = await baixarEspera(py);
570
+ if (r.ok) esperando = true;
571
+ else avisos.push(`sem o "ei, primo": ${r.error} — o modo voz abre já ouvindo.`);
572
+ }
573
+
574
+ return { ok: true, python: py, esperando, avisos };
575
+ }
576
+
577
+ module.exports = { DICA_COMPUTADOR, conferir, instalar, preparar, porqueOPipFalhou, abrir, conferirEspera, baixarEspera, python, COMO_FALAR, PASTA };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.4.0",
3
+ "version": "9.6.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). 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/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/estudio.test.js && node test/voz.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/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.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 && node test/continuar.test.js && node test/pipeline.test.js && node test/app.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/janela.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"