primocode 9.6.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.6.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.6.0
12
+ ▐▛███▜▌ Primo Code v9.7.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
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
 
@@ -460,6 +496,9 @@ async function runAct(userPrompt, opts) {
460
496
  Antes ela ia colada no texto, e a memória do projeto enchia de
461
497
  "[MODO VOZ] A sua resposta vai ser lida em voz alta…". */
462
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),
463
502
  } = opts || {};
464
503
 
465
504
  // A pasta de projeto só nasce quando o agente for mesmo MEXER EM ARQUIVO.
@@ -481,6 +520,37 @@ async function runAct(userPrompt, opts) {
481
520
  };
482
521
  const falhasPendentes = [];
483
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);
484
554
  // Quem já foi chamado, e quantas vezes. Ver lib/repeticao.js.
485
555
  const jaChamado = repeticao.novo();
486
556
  /* O livro deste pedido: arquivos, comandos, links, testes e falhas. Sai
@@ -822,6 +892,21 @@ async function runAct(userPrompt, opts) {
822
892
  finalText = '';
823
893
  continue;
824
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
+ }
825
910
  if (!subagent) {
826
911
  if (fluxo) fluxo.fechar();
827
912
  else fala.resposta(finalText);
@@ -873,6 +958,18 @@ async function runAct(userPrompt, opts) {
873
958
 
874
959
  for (const tc of toolCalls) {
875
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
+ }
876
973
  if (tc.name === 'finish') {
877
974
  // Encerrar com falha pendente é como o agente dizia "pronto!"
878
975
  // sem ter mudado nada. Devolvemos a tarefa para ele.
@@ -887,7 +984,7 @@ async function runAct(userPrompt, opts) {
887
984
  continue;
888
985
  }
889
986
  algoRodou = true;
890
- finished = (tc.arguments && tc.arguments.summary) || 'Concluído.';
987
+ finished = comAviso((tc.arguments && tc.arguments.summary) || 'Concluído.');
891
988
  messages.push({ role: 'tool', tool_call_id: tc.id, content: 'ok' });
892
989
  continue;
893
990
  }
@@ -987,6 +1084,10 @@ async function runAct(userPrompt, opts) {
987
1084
  // nasceu, mudou, rodou e falhou. Narrar sozinho não basta — numa
988
1085
  // tarefa de trinta ações, a primeira já saiu da tela.
989
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
+ }
990
1091
 
991
1092
  // Uma escrita bem-sucedida limpa a dívida — mas só a do MESMO
992
1093
  // arquivo. Escrever algo sem relação (ou um run_shell qualquer)
package/lib/cursor.js CHANGED
@@ -39,10 +39,16 @@ function pythonComQuartz() {
39
39
  if (!py) return { py: null };
40
40
  try {
41
41
  execFileSync(py, ['-c', 'import Quartz, AppKit, PyObjCTools'], { stdio: 'ignore', timeout: 20000 });
42
- return { py, tem: true };
43
42
  } catch {
44
43
  return { py, tem: false };
45
44
  }
45
+ // O Vision (ler a tela) é um extra: sem ele o mouse funciona igual.
46
+ try {
47
+ execFileSync(py, ['-c', 'import Vision'], { stdio: 'ignore', timeout: 20000 });
48
+ return { py, tem: true, vision: true };
49
+ } catch {
50
+ return { py, tem: true, vision: false };
51
+ }
46
52
  }
47
53
 
48
54
  /** Sobe o processo do cursor, instalando o PyObjC se faltar. Uma vez. */
@@ -54,8 +60,11 @@ function subir() {
54
60
  if (!py) { semCursor = 'sem Python 3'; return false; }
55
61
  if (!tem) {
56
62
  const voz = require('./voz.js');
57
- const r = await voz.instalar(py, ['pyobjc-framework-Quartz', 'pyobjc-framework-Cocoa']);
63
+ const r = await voz.instalar(py, ['pyobjc-framework-Quartz', 'pyobjc-framework-Cocoa', 'pyobjc-framework-Vision']);
58
64
  if (!r.ok || !pythonComQuartz().tem) { semCursor = `não instalou o PyObjC: ${r.error || ''}`; return false; }
65
+ } else if (!pythonComQuartz().vision) {
66
+ // Falhar aqui não impede o cursor; só "clicar pelo texto" fica sem OCR.
67
+ await require('./voz.js').instalar(py, ['pyobjc-framework-Vision']).catch(() => {});
59
68
  }
60
69
  return await new Promise((resolve) => {
61
70
  proc = spawn(py, ['-m', 'primo_voz.cursor'], {
package/lib/tools.js CHANGED
@@ -32,6 +32,14 @@ async function peloCursor(cmd, args, reserva) {
32
32
 
33
33
  function numero(v) { const n = Number(v); return Number.isFinite(n) ? n : null; }
34
34
 
35
+ /** Os textos visíveis na tela, pelo OCR do cursor, no formato dos elementos. */
36
+ async function textosDaTela() {
37
+ if (!cursor.disponivel()) return { ok: false, error: 'ler a tela é só no macOS' };
38
+ const r = await cursor.mandar('ler_tela', {}, 30000);
39
+ if (!r.ok) return { ok: false, error: r.error || 'não consegui ler a tela' };
40
+ return { ok: true, elementos: (r.textos || []).map((t) => ({ nome: t.texto, tipo: 'texto na tela', x: t.x, y: t.y })) };
41
+ }
42
+
35
43
  async function clicarPeloCursor(x, y, { botao = 'left', duplo = false, rotulo } = {}) {
36
44
  const [nx, ny] = [numero(x), numero(y)];
37
45
  if (nx == null || ny == null) return { ok: false, error: `Coordenada inválida: x=${x}, y=${y}` };
@@ -643,8 +651,16 @@ async function despachar(name, args, ctx) {
643
651
  return desktop.listWindows();
644
652
  case 'desktop_focus':
645
653
  return desktop.focusWindow(args.window || args.title || '');
646
- case 'desktop_elements':
647
- return desktop.uiElements(args.window);
654
+ case 'desktop_elements': {
655
+ const lidos = desktop.uiElements(args.window);
656
+ if (!cursor.disponivel()) return lidos;
657
+ // No Mac, junto dos botões da acessibilidade vão os TEXTOS
658
+ // que aparecem na tela (OCR), com a posição de cada um.
659
+ const naTela = await textosDaTela();
660
+ if (!naTela.ok) return lidos.ok ? { ...lidos, telaIndisponivel: naTela.error } : lidos;
661
+ return { ...(lidos.ok ? lidos : { ok: true, elementos: [] }),
662
+ textosNaTela: naTela.elementos.slice(0, 120).map((e) => ({ texto: e.nome, x: e.x, y: e.y })) };
663
+ }
648
664
  case 'desktop_click_element': {
649
665
  if (!cursor.disponivel()) {
650
666
  return desktop.clickElement(args.name || args.text || '', {
@@ -656,11 +672,21 @@ async function despachar(name, args, ctx) {
656
672
  const pedido = String(args.name || args.text || '').trim();
657
673
  if (!pedido) return { ok: false, error: 'Diga o nome do elemento (o texto do botão, ícone ou campo).' };
658
674
  const lidos = desktop.uiElements(args.window);
659
- if (!lidos.ok) return lidos;
660
- const alvo = desktop.escolherElemento(lidos.elementos, pedido);
675
+ let alvo = lidos.ok ? desktop.escolherElemento(lidos.elementos, pedido) : null;
676
+ /* A acessibilidade não achou (ou o app não expõe botões): procura
677
+ o TEXTO na tela, pelo OCR do cursor. É o que faz "clica em
678
+ Salvar" funcionar em qualquer app, como no Jarvis. */
679
+ let naTela = null;
680
+ if (!alvo) {
681
+ naTela = await textosDaTela();
682
+ if (naTela.ok) alvo = desktop.escolherElemento(naTela.elementos, pedido);
683
+ }
661
684
  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),
685
+ return { ok: false,
686
+ error: `Nenhum elemento parecido com "${pedido}"${lidos.ok ? ` na janela "${lidos.janela}"` : ''}, nem como texto na tela.`,
687
+ elementosDisponiveis: [...(lidos.ok ? lidos.elementos : []), ...((naTela && naTela.ok) ? naTela.elementos : [])]
688
+ .slice(0, 50).map((e) => e.nome),
689
+ ...(naTela && !naTela.ok ? { telaIndisponivel: naTela.error } : {}),
664
690
  comoCorrigir: 'Chame de novo com um nome de "elementosDisponiveis", copiado literalmente.' };
665
691
  }
666
692
  const r = await clicarPeloCursor(alvo.x, alvo.y, {
package/lib/voz.js CHANGED
@@ -430,6 +430,7 @@ const OPCIONAIS = () => [
430
430
  ...(process.platform === 'darwin' ? [
431
431
  ['AppKit', 'pyobjc-framework-Cocoa', 'a orb transparente do macOS'],
432
432
  ['Quartz', 'pyobjc-framework-Quartz', 'o cursor do Primo na tela'],
433
+ ['Vision', 'pyobjc-framework-Vision', 'ler o texto da tela (clicar pelo nome em qualquer app)'],
433
434
  ] : []),
434
435
  ];
435
436
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "9.6.0",
3
+ "version": "9.7.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/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"
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/conferir.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"
@@ -0,0 +1,32 @@
1
+ ---
2
+ nome: Projeto no GitHub
3
+ descricao: criar o repositório, subir o projeto e abrir pull request pelo gh
4
+ quando: github, repositório, repositorio, repo, subir pro github, sobe pro github, pull request, pr, gh, versionar, push, clonar
5
+ autor: PrimoCode
6
+ ---
7
+ Tudo pelo `gh` (GitHub CLI) e pelo `git`, rodando no terminal do projeto.
8
+
9
+ **Antes de tudo:** `gh auth status`. Sem login, diga à pessoa para rodar
10
+ `gh auth login` e pare — NUNCA peça token ou senha no chat.
11
+
12
+ **Projeto novo no GitHub:**
13
+ 1. Se não houver `.git`: `git init -b main`.
14
+ 2. `.gitignore` antes do primeiro commit (node_modules, dist, build, .env,
15
+ .DS_Store, __pycache__, .venv). Confira com `git status` que nenhum segredo
16
+ ou pasta pesada vai junto.
17
+ 3. README curto: o que é, como rodar.
18
+ 4. `git add -A` e `git commit -m "<o que o projeto é>"`.
19
+ 5. `gh repo create <nome-do-projeto> --private --source . --push` (privado
20
+ por padrão; público só se a pessoa pedir). Diga o link do repositório.
21
+
22
+ **Mudança num repositório que já existe (pull request):**
23
+ 1. `git status` e `git pull` primeiro. Branch nova com nome que diz a
24
+ mudança: `git switch -c feat/<assunto>`.
25
+ 2. Rode o que o projeto roda (teste, lint, build) antes de commitar.
26
+ 3. Commit com a mensagem dizendo o que MUDA; `git push -u origin <branch>`.
27
+ 4. `gh pr create --fill` (ou `--title` e `--body` dizendo o que e por quê).
28
+ Diga o link do PR.
29
+
30
+ **Nunca** faça `push --force` em `main`, nunca apague branch dos outros, nunca
31
+ commite `.env`. Conflito: resolva lendo os dois lados, não escolhendo um às
32
+ cegas.
@@ -0,0 +1,36 @@
1
+ ---
2
+ nome: Publicar na internet
3
+ descricao: colocar o projeto no ar com um link público, conferido depois de subir
4
+ quando: publicar, publica, deploy, colocar no ar, coloca no ar, no ar, subir o site, sobe o site, hospedar, hospedagem, link público, link publico, vercel, netlify, github pages, railway, dominio, domínio
5
+ autor: PrimoCode
6
+ ---
7
+ Publicar é terminar com um LINK que abre. Nada de "pronto, publicado" sem ter
8
+ aberto o link.
9
+
10
+ **1. Descubra o que é o projeto** (leia a pasta): página estática (index.html
11
+ solto), app com build (package.json com `build`: Vite, React, Next) ou servidor
12
+ (Express, Python). Rode o build antes (`npm install` e `npm run build`) e
13
+ conserte o que quebrar — publicar build quebrado é publicar tela branca.
14
+
15
+ **2. Escolha onde, nesta ordem, conferindo o login ANTES:**
16
+ - **Vercel** (sites e Next): `npx --yes vercel whoami`. Logado →
17
+ `npx --yes vercel --prod --yes` na pasta do projeto. A URL de produção sai no
18
+ fim da saída.
19
+ - **Netlify** (sites estáticos): `npx --yes netlify-cli status`. Logado →
20
+ `npx --yes netlify-cli deploy --prod --dir <pasta do build ou .>`.
21
+ - **GitHub Pages** (só estático, sem build de servidor): `gh auth status`.
22
+ Logado → publique o repositório (ver a skill "Projeto no GitHub") e ligue o
23
+ Pages com `gh api -X POST repos/<dono>/<repo>/pages -f "source[branch]=main" -f "source[path]=/"`.
24
+ - **Servidor** (API, backend): Railway (`npx --yes @railway/cli whoami`, depois
25
+ `railway up`) ou Render. Precisa de conta; não invente.
26
+
27
+ **Sem login em nenhum:** NÃO peça senha nem token no chat. Diga à pessoa, em
28
+ uma frase, o comando para ela entrar (ex.: `npx vercel login`) e pare ali. É
29
+ ela quem entra na conta dela.
30
+
31
+ **3. Confira:** abra a URL publicada com browser_open e leia a página. Só diga
32
+ que está no ar se ela abriu com o conteúdo certo. Termine dizendo o LINK.
33
+
34
+ **Nunca** publique `.env`, chave ou senha: confira se `.gitignore` e o
35
+ `.vercelignore`/`.netlifyignore` os deixam de fora. Variáveis secretas vão no
36
+ painel do serviço (ou `vercel env add`), não no código.
@@ -171,6 +171,72 @@ def digitar(texto: str) -> None:
171
171
  time.sleep(0.008)
172
172
 
173
173
 
174
+ # ── Ler a tela (OCR) ──────────────────────────────────────────────────────
175
+ # "Clicar no texto X" em QUALQUER app, e não só nos que expõem botões pela
176
+ # acessibilidade: fotografa a tela e reconhece o texto com o Vision do macOS
177
+ # (o mesmo do Jarvis, motor/screen.py). Cada trecho volta com o CENTRO em
178
+ # pontos da tela — a mesma unidade dos cliques.
179
+
180
+ def tela_liberada() -> bool:
181
+ try:
182
+ return bool(Quartz.CGPreflightScreenCaptureAccess())
183
+ except Exception:
184
+ return True
185
+
186
+
187
+ def ler_tela(largura: float, altura: float, arquivo: str = "") -> dict:
188
+ try:
189
+ import Vision
190
+ except Exception:
191
+ return {"ok": False, "error": "falta o pyobjc-framework-Vision para ler a tela"}
192
+ if arquivo:
193
+ # Só para o teste: lê uma imagem do disco em vez da tela.
194
+ from Foundation import NSURL
195
+ fonte = Quartz.CGImageSourceCreateWithURL(NSURL.fileURLWithPath_(arquivo), None)
196
+ img = Quartz.CGImageSourceCreateImageAtIndex(fonte, 0, None) if fonte else None
197
+ else:
198
+ if not tela_liberada():
199
+ try:
200
+ Quartz.CGRequestScreenCaptureAccess()
201
+ except Exception:
202
+ pass
203
+ return {"ok": False, "permissaoTela": False,
204
+ "error": "o macOS não deixou ver a tela. Libere o Terminal (ou o app de onde você "
205
+ "abriu o PrimoCode) em Ajustes do Sistema > Privacidade e Segurança > "
206
+ "Gravação de Tela, e peça de novo."}
207
+ img = Quartz.CGWindowListCreateImage(Quartz.CGRectInfinite, Quartz.kCGWindowListOptionOnScreenOnly,
208
+ Quartz.kCGNullWindowID, Quartz.kCGWindowImageDefault)
209
+ if img is None:
210
+ return {"ok": False, "error": "não consegui fotografar a tela"}
211
+
212
+ pedido = Vision.VNRecognizeTextRequest.alloc().init()
213
+ pedido.setRecognitionLevel_(Vision.VNRequestTextRecognitionLevelAccurate)
214
+ pedido.setUsesLanguageCorrection_(True)
215
+ try:
216
+ pedido.setRecognitionLanguages_(["pt-BR", "en-US"])
217
+ except Exception:
218
+ pass
219
+ leitor = Vision.VNImageRequestHandler.alloc().initWithCGImage_options_(img, None)
220
+ ok, erro = leitor.performRequests_error_([pedido], None)
221
+ if not ok:
222
+ return {"ok": False, "error": f"o Vision não leu a tela: {erro}"}
223
+ textos = []
224
+ for obs in (pedido.results() or []):
225
+ candidatos = obs.topCandidates_(1)
226
+ if not candidatos:
227
+ continue
228
+ texto = str(candidatos[0].string()).strip()
229
+ if not texto:
230
+ continue
231
+ bb = obs.boundingBox() # normalizado, origem embaixo à esquerda
232
+ cx = (bb.origin.x + bb.size.width / 2) * largura
233
+ cy = (1 - (bb.origin.y + bb.size.height / 2)) * altura
234
+ textos.append({"texto": texto, "x": round(cx), "y": round(cy),
235
+ "confianca": round(float(candidatos[0].confidence()), 2)})
236
+ textos.sort(key=lambda t: (t["y"], t["x"]))
237
+ return {"ok": True, "textos": textos}
238
+
239
+
174
240
  # ── A janela do cursor ──────────────────────────────────────────────────────
175
241
 
176
242
  class VistaCursor(NSView):
@@ -431,6 +497,8 @@ def executar(cur: Cursor, msg: dict) -> dict:
431
497
  return {"ok": ok}
432
498
  if cmd == "tamanho":
433
499
  return {"ok": True, "largura": int(cur.largura), "altura": int(cur.altura)}
500
+ if cmd == "ler_tela":
501
+ return ler_tela(cur.largura, cur.altura, str(msg.get("arquivo") or ""))
434
502
  if cmd == "rotulo":
435
503
  cur._ui(rotulo=str(msg.get("texto") or "")[:48], visivel=True, x=posicao()[0], y=posicao()[1])
436
504
  return {"ok": True}