primocode 9.6.0 → 9.8.0-beta.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 +2 -2
- package/bin/primocode.js +107 -11
- package/lib/act.js +132 -7
- package/lib/browser.js +80 -6
- package/lib/cursor.js +11 -2
- package/lib/nome-projeto.js +97 -0
- package/lib/repeticao.js +65 -5
- package/lib/tools.js +47 -7
- package/lib/voz.js +8 -1
- package/package.json +2 -2
- package/skills/projeto-no-github/SKILL.md +32 -0
- package/skills/publicar-na-internet/SKILL.md +36 -0
- package/voz/primo_voz/__main__.py +4 -2
- package/voz/primo_voz/cursor.py +68 -0
- package/voz/primo_voz/transcrever.py +37 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v9.
|
|
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.
|
|
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
|
@@ -20,6 +20,7 @@ const { escolherModo } = require('../lib/modo.js');
|
|
|
20
20
|
const api = require('../lib/api');
|
|
21
21
|
const tools = require('../lib/tools');
|
|
22
22
|
const cfg = require('../lib/config');
|
|
23
|
+
const nomeProjeto = require('../lib/nome-projeto');
|
|
23
24
|
const { runAct, interromper, ehTetoDeToken, ehTransitorio, esperaDoErro } = require('../lib/act');
|
|
24
25
|
const fala = require('../lib/fala');
|
|
25
26
|
const boasVindas = require('../lib/boas-vindas');
|
|
@@ -563,8 +564,25 @@ function planWorkDir(userText) {
|
|
|
563
564
|
return { dir: cfg.ROOT_DIR, projeto: cfg.getProjectDir(proposto), pending: true, name: proposto };
|
|
564
565
|
}
|
|
565
566
|
|
|
567
|
+
/* O nome da pasta nova vem do modelo (lib/nome-projeto.js), pedido em
|
|
568
|
+
paralelo com o trabalho: a pasta só nasce na primeira escrita, e até lá a
|
|
569
|
+
resposta já chegou. Só pergunta quando o pedido é de CRIAR algo — pedir
|
|
570
|
+
para abrir o Chrome não gasta chamada com nome de pasta. */
|
|
571
|
+
function batizarDepois(plan, userText) {
|
|
572
|
+
if (!plan.pending || !(RE_CRIAR_UM.test(semAcento(userText)) || PEDIDO_DE_CODIGO.test(userText))) return;
|
|
573
|
+
const perguntar = async (texto) => {
|
|
574
|
+
let resposta = '';
|
|
575
|
+
await api.requestChat(state.config.server, {
|
|
576
|
+
prompt: texto, history: [], model: state.config.model, token: state.config.token,
|
|
577
|
+
}, (evt) => { if (evt.delta) resposta += evt.delta; });
|
|
578
|
+
return resposta;
|
|
579
|
+
};
|
|
580
|
+
nomeProjeto.escolher({ pedido: userText, nomes: cfg.listProjects(), perguntar })
|
|
581
|
+
.then((nome) => { if (nome) plan.nomeEscolhido = nome; });
|
|
582
|
+
}
|
|
583
|
+
|
|
566
584
|
function createPendingProject(plan) {
|
|
567
|
-
const dir = cfg.createProjectDir(plan.name);
|
|
585
|
+
const dir = cfg.createProjectDir(plan.nomeEscolhido || plan.name);
|
|
568
586
|
entrarNoProjeto(dir, 'projeto');
|
|
569
587
|
const rotulo = plan.similar ? 'Continuando em' : 'Projeto novo:';
|
|
570
588
|
console.log(c.ok(` ${rotulo} ${state.currentProject}`) + c.muted(` (${dir})`));
|
|
@@ -619,6 +637,7 @@ function prepararPasta(userText) {
|
|
|
619
637
|
talvezTrocarDePasta(userText);
|
|
620
638
|
|
|
621
639
|
const plan = planWorkDir(userText);
|
|
640
|
+
batizarDepois(plan, userText);
|
|
622
641
|
|
|
623
642
|
// Projeto que já existe: entra nele AGORA. Se deixasse para depois, o
|
|
624
643
|
// buildMemoryContext deste turno leria a pasta errada e a memória do projeto
|
|
@@ -914,7 +933,7 @@ async function sendPrompt(userText) {
|
|
|
914
933
|
: '';
|
|
915
934
|
const pedido = passos[i].pedido + contexto + semFatos;
|
|
916
935
|
|
|
917
|
-
const rodarPasso = () => runAct(
|
|
936
|
+
const rodarPasso = (texto = pedido) => runAct(texto, {
|
|
918
937
|
server: state.config.server,
|
|
919
938
|
model: state.config.model,
|
|
920
939
|
effort: state.config.effort,
|
|
@@ -937,15 +956,38 @@ async function sendPrompt(userText) {
|
|
|
937
956
|
});
|
|
938
957
|
|
|
939
958
|
r = await rodarPasso();
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
959
|
+
/* Passo que parou no meio NÃO devolve a tarefa para a pessoa
|
|
960
|
+
dividir. Se ele já tinha feito alguma coisa (arquivo, comando),
|
|
961
|
+
o próprio CLI pede a continuação — com a lista do que já existe
|
|
962
|
+
no disco, para não refazer. Até CONTINUACOES vezes, e só
|
|
963
|
+
enquanto cada volta produz algo: continuação que não mexe em
|
|
964
|
+
nada é laço, e laço para.
|
|
965
|
+
Num plano de vários passos, o passo que desistiu sem fazer nada
|
|
966
|
+
ganha UMA segunda chance — costuma ser tropeço transitório do
|
|
967
|
+
modelo grátis. Falhou de novo: o
|
|
968
|
+
plano segue com o aviso de "sem fatos", nunca com o texto da
|
|
969
|
+
falha fantasiado de resultado. */
|
|
970
|
+
let continuacoes = 0;
|
|
971
|
+
let tentouDeNovo = false;
|
|
972
|
+
while (!r.parado && r.concluiu === false) {
|
|
973
|
+
registro.juntar(livro, r.registro);
|
|
974
|
+
// Sem registro é erro do provedor (cota, rede): retomar só bate
|
|
975
|
+
// no mesmo muro.
|
|
976
|
+
if (r.registro && !registro.vazio(r.registro) && continuacoes < CONTINUACOES) {
|
|
977
|
+
continuacoes++;
|
|
978
|
+
console.log(c.dim(' ⎿ continuando de onde parou'));
|
|
979
|
+
r = await rodarPasso(pedidoDeContinuacao(passos[i].pedido, livro));
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (!tentouDeNovo && passos.length > 1 && registro.vazio(r.registro) && continuacoes === 0) {
|
|
983
|
+
tentouDeNovo = true;
|
|
984
|
+
console.log(c.dim(' ⎿ o passo não concluiu — tentando de novo'));
|
|
985
|
+
r = await rodarPasso();
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
break;
|
|
947
989
|
}
|
|
948
|
-
registro.juntar(livro, r.registro);
|
|
990
|
+
if (r.concluiu !== false || r.parado) registro.juntar(livro, r.registro);
|
|
949
991
|
jaFeito = (r.concluiu !== false)
|
|
950
992
|
? String((r.assistantMessage && r.assistantMessage.content) || '').slice(0, 1200)
|
|
951
993
|
: '';
|
|
@@ -1002,6 +1044,18 @@ async function sendPrompt(userText) {
|
|
|
1002
1044
|
const RE_DESCOBRIR = /\b(pesquis|busca|procur|descubr|investig|analis|leia|estud|olh[ae] (o site|a p[áa]gina)|ver o que)\w*/i;
|
|
1003
1045
|
const RE_CRIAR_DEPOIS = /\b(cri[ae]|faz|faça|mont[ae]|ger[ae]|escrev[ae]|produz|desenvolv)\w*/i;
|
|
1004
1046
|
|
|
1047
|
+
/* A outra divisão que compensa: o pedido LONGO que emenda entregas diferentes
|
|
1048
|
+
("cria o site, depois põe login e publica"). Estreita de propósito — o
|
|
1049
|
+
planejador dividindo pedido de uma fase só era o "faz coisas nada a ver" —
|
|
1050
|
+
e o servidor ainda devolve passo único na dúvida. */
|
|
1051
|
+
const RE_ACAO = /\b(cri[ae]r?|fa[zç]a?|fazer|mont[ae]r?|ger[ae]r?|escrev[ae]r?|adicion[ae]r?|coloc[ae]r?|p[oõ]e|implement[ae]r?|public[ae]r?|sob[ea]|instal[ae]r?|configur[ae]r?|test[ae]r?|corrig[ei]r?|arrum[ae]r?|mud[ae]r?|integr[ae]r?|conect[ae]r?)\b/gi;
|
|
1052
|
+
function pedidoDeVariasPartes(pedido) {
|
|
1053
|
+
const t = String(pedido || '');
|
|
1054
|
+
if (t.length < 200) return false;
|
|
1055
|
+
const acoes = new Set((t.match(RE_ACAO) || []).map((x) => x.toLowerCase().slice(0, 4)));
|
|
1056
|
+
return acoes.size >= 3 && /\b(depois|em seguida|e tamb[ée]m|al[ée]m disso|por fim|e ent[ãa]o)\b|\n\s*(\d+[.)]|[-*•])\s/i.test(t);
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1005
1059
|
/* "me manda o vídeo" resolvido na hora, sem gastar modelo.
|
|
1006
1060
|
*
|
|
1007
1061
|
* Antes isso abria a última criação do Studio no navegador com `#gravar`, e a
|
|
@@ -1038,12 +1092,33 @@ async function baixarCriacaoDireto(texto) {
|
|
|
1038
1092
|
}
|
|
1039
1093
|
}
|
|
1040
1094
|
|
|
1095
|
+
/* Quantas vezes um passo que parou no meio é retomado sozinho. */
|
|
1096
|
+
const CONTINUACOES = 2;
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* O pedido de retomada: o pedido original + o que já existe, para o agente
|
|
1100
|
+
* seguir em vez de recomeçar. A conversa do passo anterior não vai junto (é
|
|
1101
|
+
* ela que estourou) — vai o que aconteceu no disco, que é o que importa.
|
|
1102
|
+
*/
|
|
1103
|
+
function pedidoDeContinuacao(original, livro) {
|
|
1104
|
+
const linhas = [];
|
|
1105
|
+
if (livro.criados.length) linhas.push(`Arquivos criados: ${livro.criados.slice(0, 20).join(', ')}`);
|
|
1106
|
+
if (livro.alterados.length) linhas.push(`Arquivos alterados: ${livro.alterados.slice(0, 20).join(', ')}`);
|
|
1107
|
+
const cmds = livro.comandos.slice(-8).map((x) => `${x.comando} (${x.ok ? 'ok' : 'falhou'})`);
|
|
1108
|
+
if (cmds.length) linhas.push(`Comandos rodados: ${cmds.join('; ')}`);
|
|
1109
|
+
const falhas = livro.falhas.slice(-3).map((f) => `${f.acao}: ${f.motivo}`);
|
|
1110
|
+
if (falhas.length) linhas.push(`Falhas: ${falhas.join('; ')}`);
|
|
1111
|
+
return `CONTINUAÇÃO — a tarefa abaixo já foi começada e parou no meio. NÃO recomece do zero: `
|
|
1112
|
+
+ `leia o que já existe, veja o que falta, divida o que falta em passos pequenos (write_todos) e termine.\n\n`
|
|
1113
|
+
+ `O QUE JÁ FOI FEITO:\n${linhas.join('\n') || '(nada registrado)'}\n\nA TAREFA:\n${original}`;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1041
1116
|
async function dividirTarefa(pedido) {
|
|
1042
1117
|
const inteiro = [{ titulo: 'tarefa', pedido }];
|
|
1043
1118
|
if (!pedido || pedido.trim().length < 12) return inteiro;
|
|
1044
1119
|
// Sem as duas fases no texto, nem chama o servidor: mais rápido, e sem
|
|
1045
1120
|
// dar ao modelo a chance de dividir o que não devia.
|
|
1046
|
-
if (!(RE_DESCOBRIR.test(pedido) && RE_CRIAR_DEPOIS.test(pedido))) return inteiro;
|
|
1121
|
+
if (!(RE_DESCOBRIR.test(pedido) && RE_CRIAR_DEPOIS.test(pedido)) && !pedidoDeVariasPartes(pedido)) return inteiro;
|
|
1047
1122
|
const sp = new Spinner('Primo Code pensando').start();
|
|
1048
1123
|
try {
|
|
1049
1124
|
const r = await api.requestPlano(state.config.server, {
|
|
@@ -1691,6 +1766,27 @@ async function handleSlash(text) {
|
|
|
1691
1766
|
// Mesma memória do terminal: espera o resumo da rodada
|
|
1692
1767
|
// anterior e leva só ele, com o projeto atual.
|
|
1693
1768
|
await esperarResumo();
|
|
1769
|
+
/* O mesmo roteador do terminal: cumprimento e pergunta vão
|
|
1770
|
+
para a conversa, curta; só pedido de trabalho vai para o
|
|
1771
|
+
agente. Antes TUDO ia para o agente — até um "oi". */
|
|
1772
|
+
const escolha = escolherModo(texto, { ultimoModo: state.modoVoz });
|
|
1773
|
+
state.modoVoz = escolha.modo;
|
|
1774
|
+
if (escolha.modo === 'chat') {
|
|
1775
|
+
let resposta = '';
|
|
1776
|
+
try {
|
|
1777
|
+
await api.requestChat(state.config.server, {
|
|
1778
|
+
prompt: `${vozModo.COMO_CONVERSAR}\n\nA PESSOA DISSE: ${texto}`,
|
|
1779
|
+
history: state.history.slice(-4),
|
|
1780
|
+
model: state.config.model,
|
|
1781
|
+
token: state.config.token,
|
|
1782
|
+
}, (evt) => { if (evt.delta) resposta += evt.delta; });
|
|
1783
|
+
} catch { /* sem resposta: a orb volta a ouvir */ }
|
|
1784
|
+
resposta = resposta.trim();
|
|
1785
|
+
state.history.push({ role: 'user', content: texto });
|
|
1786
|
+
state.history.push({ role: 'assistant', content: resposta });
|
|
1787
|
+
resumirDepois();
|
|
1788
|
+
return resposta;
|
|
1789
|
+
}
|
|
1694
1790
|
const plan = prepararPasta(texto);
|
|
1695
1791
|
const resultado = await runAct(texto, {
|
|
1696
1792
|
instrucao: vozModo.COMO_FALAR,
|
package/lib/act.js
CHANGED
|
@@ -138,7 +138,10 @@ function logResult(kind, result, profundidade) {
|
|
|
138
138
|
// vermelho que faz parecer que a tarefa morreu.
|
|
139
139
|
if (result.ok === false) {
|
|
140
140
|
const texto = String(result.error || `exit ${result.code ?? 'erro'}`).replace(/\s+/g, ' ').trim();
|
|
141
|
-
|
|
141
|
+
// Erro de permissão não corta: a instrução que resolve ("Acessibilidade…
|
|
142
|
+
// peça de novo") fica justamente depois do 160º caractere.
|
|
143
|
+
const teto = result.permissao === false ? 400 : 160;
|
|
144
|
+
console.log(p + c.warn(texto.length > teto ? texto.slice(0, teto - 1) + '…' : texto));
|
|
142
145
|
return;
|
|
143
146
|
}
|
|
144
147
|
|
|
@@ -411,22 +414,63 @@ function pareceParadaNoMeio(texto, { todosPendentes = 0, rodouTool = false } = {
|
|
|
411
414
|
return false;
|
|
412
415
|
}
|
|
413
416
|
|
|
417
|
+
/* O recado da troca de rota: não repete o que já tentou, quebra o que falta
|
|
418
|
+
em pedaços e faz o primeiro. Quem divide a tarefa é o agente, não a pessoa. */
|
|
419
|
+
const TROCA_DE_ROTA = 'Você ficou três voltas sem conseguir rodar nada, repetindo chamadas que já tinha feito. '
|
|
420
|
+
+ 'Não repita nenhuma delas. Olhe o que já está pronto na conversa, divida o que falta em passos pequenos '
|
|
421
|
+
+ '(write_todos) e faça AGORA o primeiro passo, por um caminho diferente: outra ferramenta, outro arquivo, '
|
|
422
|
+
+ 'outro comando. Se não houver caminho, chame finish e diga em uma frase o que impediu.';
|
|
423
|
+
|
|
414
424
|
function motivoDaParada({ voltas, voltasEmFalso, falhasPendentes, travou }) {
|
|
415
425
|
if (falhasPendentes.length) {
|
|
416
426
|
return `Parei sem concluir. A última falha foi: ${falhasPendentes[falhasPendentes.length - 1].texto}`;
|
|
417
427
|
}
|
|
418
428
|
if (travou) {
|
|
419
|
-
return '
|
|
420
|
-
+ '
|
|
429
|
+
return 'Fiquei preso: tentei dois caminhos e os dois voltaram ao mesmo lugar, então parei antes de gastar sua cota à toa. '
|
|
430
|
+
+ 'O que já fiz está salvo. Diga "continua" que eu retomo daqui.';
|
|
421
431
|
}
|
|
422
432
|
if (voltas >= MAX_ITERATIONS) {
|
|
423
433
|
const emFalso = voltasEmFalso
|
|
424
434
|
? ` ${voltasEmFalso} ${voltasEmFalso === 1 ? 'chamada foi repetida' : 'chamadas foram repetidas'} e não rodaram de novo.`
|
|
425
435
|
: '';
|
|
426
|
-
return `
|
|
427
|
-
+ '
|
|
436
|
+
return `Usei as ${MAX_ITERATIONS} voltas deste pedido sem chegar ao fim.${emFalso}`
|
|
437
|
+
+ ' O que já fiz está salvo. Diga "continua" que eu retomo daqui.';
|
|
428
438
|
}
|
|
429
|
-
return 'Parei sem concluir e sem explicação do modelo.
|
|
439
|
+
return 'Parei sem concluir e sem explicação do modelo. Diga "continua" que eu tento de novo daqui.';
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* ── O MAPA DO PROJETO ───────────────────────────────────────────────────
|
|
443
|
+
* "Continua o jogo" num projeto de vinte arquivos: o agente começava às
|
|
444
|
+
* cegas, gastava uma volta listando a pasta — ou, pior, escrevia um index.html
|
|
445
|
+
* novo por cima do que existia. Agora ele recebe, junto da memória, a lista dos
|
|
446
|
+
* arquivos do projeto (até 80, sem node_modules nem build), e sabe de saída
|
|
447
|
+
* onde está cada coisa. Só em pasta de projeto: a raiz ~/primocode lista
|
|
448
|
+
* outros projetos, não este. */
|
|
449
|
+
const PULAR_NO_MAPA = new Set(['node_modules', '.git', 'dist', 'build', '.next', '.nuxt', 'out',
|
|
450
|
+
'.cache', 'coverage', '__pycache__', '.venv', 'venv', '.turbo', '.vercel', '.idea', '.vscode']);
|
|
451
|
+
|
|
452
|
+
function mapaDoProjeto(dir, limite = 80) {
|
|
453
|
+
const path = require('path');
|
|
454
|
+
const fs = require('fs');
|
|
455
|
+
if (!dir || path.resolve(dir) === path.resolve(cfg.ROOT_DIR)) return '';
|
|
456
|
+
const achados = [];
|
|
457
|
+
const andar = (atual, fundo) => {
|
|
458
|
+
if (fundo > 4 || achados.length >= limite) return;
|
|
459
|
+
let itens = [];
|
|
460
|
+
try { itens = fs.readdirSync(atual, { withFileTypes: true }); } catch { return; }
|
|
461
|
+
itens.sort((a, b) => (a.isDirectory() === b.isDirectory() ? a.name.localeCompare(b.name) : a.isDirectory() ? 1 : -1));
|
|
462
|
+
for (const it of itens) {
|
|
463
|
+
if (achados.length >= limite) return;
|
|
464
|
+
if (PULAR_NO_MAPA.has(it.name) || (it.name.startsWith('.') && it.name !== '.env.example')) continue;
|
|
465
|
+
const cheio = path.join(atual, it.name);
|
|
466
|
+
if (it.isDirectory()) andar(cheio, fundo + 1);
|
|
467
|
+
else if (it.name !== 'memory.md') achados.push(path.relative(dir, cheio));
|
|
468
|
+
}
|
|
469
|
+
};
|
|
470
|
+
andar(dir, 0);
|
|
471
|
+
if (!achados.length) return '';
|
|
472
|
+
const mais = achados.length >= limite ? `\n… (mais arquivos — use glob para achar)` : '';
|
|
473
|
+
return `Arquivos que JÁ existem neste projeto (edite-os em vez de recriar):\n${achados.join('\n')}${mais}`;
|
|
430
474
|
}
|
|
431
475
|
|
|
432
476
|
function buildMemoryContext(projectDir, projectName) {
|
|
@@ -435,6 +479,8 @@ function buildMemoryContext(projectDir, projectName) {
|
|
|
435
479
|
if (geral.trim()) parts.push(`[memory-geral.md]\n${geral}`);
|
|
436
480
|
const projeto = cfg.readProjectMemory(projectDir);
|
|
437
481
|
if (projeto.trim()) parts.push(`[${projectName || 'projeto'}/memory.md]\n${projeto}`);
|
|
482
|
+
const mapa = mapaDoProjeto(projectDir);
|
|
483
|
+
if (mapa) parts.push(`[${projectName || 'projeto'}/arquivos]\n${mapa}`);
|
|
438
484
|
return parts.join('\n\n');
|
|
439
485
|
}
|
|
440
486
|
|
|
@@ -460,6 +506,9 @@ async function runAct(userPrompt, opts) {
|
|
|
460
506
|
Antes ela ia colada no texto, e a memória do projeto enchia de
|
|
461
507
|
"[MODO VOZ] A sua resposta vai ser lida em voz alta…". */
|
|
462
508
|
instrucao = '',
|
|
509
|
+
/* Quem abre a página para conferir antes do "pronto". Injetável pelo
|
|
510
|
+
mesmo motivo do `motor`: o teste mede a regra sem abrir um Chrome. */
|
|
511
|
+
medirPagina = (dir) => require('./pipeline.js').medirNoNavegador(dir),
|
|
463
512
|
} = opts || {};
|
|
464
513
|
|
|
465
514
|
// A pasta de projeto só nasce quando o agente for mesmo MEXER EM ARQUIVO.
|
|
@@ -481,6 +530,37 @@ async function runAct(userPrompt, opts) {
|
|
|
481
530
|
};
|
|
482
531
|
const falhasPendentes = [];
|
|
483
532
|
let tentativasDeEncerrar = 0;
|
|
533
|
+
/* ── CONFERIR ANTES DE DIZER "PRONTO" ───────────────────────────────
|
|
534
|
+
* Mexeu em página (html, css, js) nesta tarefa? Antes de aceitar o
|
|
535
|
+
* `finish`, o PrimoCode abre a página num navegador invisível e olha: tela
|
|
536
|
+
* em branco, erro de JavaScript, recurso que não carregou. Achou problema,
|
|
537
|
+
* a tarefa volta para o agente com o relato — uma vez só, para não virar
|
|
538
|
+
* laço. É a diferença entre "pronto!" e "pronto, e abre". */
|
|
539
|
+
let mexeuNaPagina = false; // mexeu desde a última conferência?
|
|
540
|
+
let conferencias = 0; // até duas: entregar, e conferir o conserto
|
|
541
|
+
let avisoDaPagina = ''; // o que ainda estava quebrado na última
|
|
542
|
+
/* Abre a página e devolve o relato quando é para VOLTAR (achou problema e
|
|
543
|
+
ainda cabe uma volta); null quando é para entregar. O que ficar
|
|
544
|
+
quebrado na última conferência fica em `avisoDaPagina`, e a entrega sai
|
|
545
|
+
com ele — nunca "pronto" seco sobre uma página com erro. Vale para os
|
|
546
|
+
dois jeitos de terminar: a ferramenta finish e a resposta em texto. */
|
|
547
|
+
const conferirPagina = async () => {
|
|
548
|
+
if (subagent || !mexeuNaPagina || conferencias >= 2 || PARAR) return null;
|
|
549
|
+
conferencias++;
|
|
550
|
+
mexeuNaPagina = false;
|
|
551
|
+
let medida = null;
|
|
552
|
+
try {
|
|
553
|
+
fala.pensamento('abrindo a página para conferir antes de entregar', profundidade);
|
|
554
|
+
medida = await medirPagina(pastaAtual);
|
|
555
|
+
} catch { medida = null; }
|
|
556
|
+
if (!medida || !medida.testou) return null;
|
|
557
|
+
avisoDaPagina = medida.limpo ? ''
|
|
558
|
+
: String(medida.relato || '').split('\n').filter((l) => /·|BRANCO/.test(l)).slice(0, 3).join(' ');
|
|
559
|
+
if (medida.limpo) { fala.pensamento('a página abriu sem erro', profundidade); return null; }
|
|
560
|
+
return conferencias >= 2 ? null : medida.relato;
|
|
561
|
+
};
|
|
562
|
+
const comAviso = (texto) => (avisoDaPagina
|
|
563
|
+
? `${texto}\n\nAtenção: abri a página e ela ainda tem problema — ${avisoDaPagina.trim()}` : texto);
|
|
484
564
|
// Quem já foi chamado, e quantas vezes. Ver lib/repeticao.js.
|
|
485
565
|
const jaChamado = repeticao.novo();
|
|
486
566
|
/* O livro deste pedido: arquivos, comandos, links, testes e falhas. Sai
|
|
@@ -521,6 +601,7 @@ async function runAct(userPrompt, opts) {
|
|
|
521
601
|
// assim que "crie um vídeo para a tostudy" morreu sem entregar nada.
|
|
522
602
|
let pesquisasSecas = 0;
|
|
523
603
|
let rodadasParadas = 0;
|
|
604
|
+
let trocouDeRota = false;
|
|
524
605
|
/* Para o `pareceParadaNoMeio`: se alguma tool já rodou nesta tarefa, e o
|
|
525
606
|
último plano que o modelo escreveu (write_todos) — desta TAREFA, não da
|
|
526
607
|
sessão: um plano velho, de um pedido interrompido, não pode empurrar o
|
|
@@ -822,6 +903,21 @@ async function runAct(userPrompt, opts) {
|
|
|
822
903
|
finalText = '';
|
|
823
904
|
continue;
|
|
824
905
|
}
|
|
906
|
+
// Terminou falando, sem chamar finish: confere a página do mesmo jeito.
|
|
907
|
+
if (finalText) {
|
|
908
|
+
const relato = await conferirPagina();
|
|
909
|
+
if (relato) {
|
|
910
|
+
messages.push({ role: 'assistant', content: finalText });
|
|
911
|
+
messages.push({
|
|
912
|
+
role: 'user',
|
|
913
|
+
content: `AINDA NÃO. Abri a página para conferir e ela tem problema:\n${relato}\n\n`
|
|
914
|
+
+ 'Conserte (leia o arquivo antes de editar) e finalize de novo.',
|
|
915
|
+
});
|
|
916
|
+
finalText = '';
|
|
917
|
+
continue;
|
|
918
|
+
}
|
|
919
|
+
finalText = comAviso(finalText);
|
|
920
|
+
}
|
|
825
921
|
if (!subagent) {
|
|
826
922
|
if (fluxo) fluxo.fechar();
|
|
827
923
|
else fala.resposta(finalText);
|
|
@@ -873,6 +969,18 @@ async function runAct(userPrompt, opts) {
|
|
|
873
969
|
|
|
874
970
|
for (const tc of toolCalls) {
|
|
875
971
|
if (PARAR) break;
|
|
972
|
+
if (tc.name === 'finish') {
|
|
973
|
+
const relato = await conferirPagina();
|
|
974
|
+
if (relato) {
|
|
975
|
+
messages.push({
|
|
976
|
+
role: 'tool',
|
|
977
|
+
tool_call_id: tc.id,
|
|
978
|
+
content: `AINDA NÃO. Abri a página para conferir e ela tem problema:\n${relato}\n\n`
|
|
979
|
+
+ 'Conserte (leia o arquivo antes de editar) e chame finish de novo.',
|
|
980
|
+
});
|
|
981
|
+
continue;
|
|
982
|
+
}
|
|
983
|
+
}
|
|
876
984
|
if (tc.name === 'finish') {
|
|
877
985
|
// Encerrar com falha pendente é como o agente dizia "pronto!"
|
|
878
986
|
// sem ter mudado nada. Devolvemos a tarefa para ele.
|
|
@@ -887,7 +995,7 @@ async function runAct(userPrompt, opts) {
|
|
|
887
995
|
continue;
|
|
888
996
|
}
|
|
889
997
|
algoRodou = true;
|
|
890
|
-
finished = (tc.arguments && tc.arguments.summary) || 'Concluído.';
|
|
998
|
+
finished = comAviso((tc.arguments && tc.arguments.summary) || 'Concluído.');
|
|
891
999
|
messages.push({ role: 'tool', tool_call_id: tc.id, content: 'ok' });
|
|
892
1000
|
continue;
|
|
893
1001
|
}
|
|
@@ -899,6 +1007,7 @@ async function runAct(userPrompt, opts) {
|
|
|
899
1007
|
: await runSubagent(tc.arguments || {}, {
|
|
900
1008
|
server, model, token, projectDir: pastaAtual, projectName, profundidade, motor,
|
|
901
1009
|
});
|
|
1010
|
+
repeticao.executou(jaChamado, tc.name, tc.arguments);
|
|
902
1011
|
messages.push({ role: 'tool', tool_call_id: tc.id, content: r });
|
|
903
1012
|
continue;
|
|
904
1013
|
}
|
|
@@ -930,6 +1039,7 @@ async function runAct(userPrompt, opts) {
|
|
|
930
1039
|
server, model, token, projectDir: pastaAtual, projectName, profundidade, motor, livro, autorizar, controle: portaoControle,
|
|
931
1040
|
});
|
|
932
1041
|
logResult(tc.name, r, profundidade);
|
|
1042
|
+
repeticao.executou(jaChamado, tc.name, tc.arguments);
|
|
933
1043
|
messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify(r) });
|
|
934
1044
|
if (r && r.ok && catalogo.escreveArquivo(tc.name)) temPasta = true;
|
|
935
1045
|
continue;
|
|
@@ -982,11 +1092,16 @@ async function runAct(userPrompt, opts) {
|
|
|
982
1092
|
fala.acao(tc.name, tc.arguments, profundidade);
|
|
983
1093
|
const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
|
|
984
1094
|
logResult(tc.name, result, profundidade);
|
|
1095
|
+
repeticao.executou(jaChamado, tc.name, tc.arguments);
|
|
985
1096
|
if (tc.name === 'write_todos' && result && Array.isArray(result.todos)) todosDaTarefa = result.todos;
|
|
986
1097
|
// Além de narrar, ANOTA: no fim do pedido sai o balanço do que
|
|
987
1098
|
// nasceu, mudou, rodou e falhou. Narrar sozinho não basta — numa
|
|
988
1099
|
// tarefa de trinta ações, a primeira já saiu da tela.
|
|
989
1100
|
registro.anotar(livro, tc.name, tc.arguments, result);
|
|
1101
|
+
if (result && result.ok !== false && catalogo.criaProjeto(tc.name, tc.arguments)
|
|
1102
|
+
&& /\.(html?|css|m?jsx?|tsx?|vue|svelte)$/i.test(String((tc.arguments && tc.arguments.path) || ''))) {
|
|
1103
|
+
mexeuNaPagina = true;
|
|
1104
|
+
}
|
|
990
1105
|
|
|
991
1106
|
// Uma escrita bem-sucedida limpa a dívida — mas só a do MESMO
|
|
992
1107
|
// arquivo. Escrever algo sem relação (ou um run_shell qualquer)
|
|
@@ -1032,6 +1147,16 @@ async function runAct(userPrompt, opts) {
|
|
|
1032
1147
|
// terminar no mesmo lugar — melhor parar e devolver a vez ao usuário.
|
|
1033
1148
|
if (!algoRodou && toolCalls.length) {
|
|
1034
1149
|
rodadasParadas++;
|
|
1150
|
+
/* Antes de desistir, UMA troca de rota, sem aparecer na tela. Parar
|
|
1151
|
+
aqui devolvia ao usuário o trabalho de dividir a tarefa — que é
|
|
1152
|
+
justamente o que ele não sabe fazer e o sistema sabe: o que já
|
|
1153
|
+
foi feito está na conversa, e o que falta é outro caminho. */
|
|
1154
|
+
if (rodadasParadas >= MAX_RODADAS_PARADAS && !trocouDeRota) {
|
|
1155
|
+
trocouDeRota = true;
|
|
1156
|
+
rodadasParadas = 0;
|
|
1157
|
+
messages.push({ role: 'user', content: TROCA_DE_ROTA });
|
|
1158
|
+
continue;
|
|
1159
|
+
}
|
|
1035
1160
|
// Quem fala é o motivoDaParada, uma vez só — aqui havia uma
|
|
1036
1161
|
// segunda frase de "travei", e as duas saíam em sequência.
|
|
1037
1162
|
if (rodadasParadas >= MAX_RODADAS_PARADAS) break;
|
package/lib/browser.js
CHANGED
|
@@ -463,6 +463,9 @@ async function ensure(url) {
|
|
|
463
463
|
// ---- Ações ------------------------------------------------------
|
|
464
464
|
async function open(url) {
|
|
465
465
|
await ensure(url);
|
|
466
|
+
// A janela vem para a frente: quem pediu "abre o youtube" quer VER abrir,
|
|
467
|
+
// não descobrir depois que havia um Chrome atrás do terminal.
|
|
468
|
+
if (verSeta()) { try { await currentSession.send('Page.bringToFront'); } catch { /* segue */ } }
|
|
466
469
|
// Navega até URL solicitada
|
|
467
470
|
await currentSession.send('Page.navigate', { url });
|
|
468
471
|
// Espera "load" simples via polling do document.readyState
|
|
@@ -486,8 +489,73 @@ async function evaluate(js) {
|
|
|
486
489
|
return r.result ? r.result.value : undefined;
|
|
487
490
|
}
|
|
488
491
|
|
|
489
|
-
|
|
492
|
+
/* ── A SETA DO PRIMO DENTRO DA PÁGINA ────────────────────────────────────
|
|
493
|
+
* "Quando a IA vai mexer no computador, eu não consigo ver ela mexendo."
|
|
494
|
+
*
|
|
495
|
+
* O clique no navegador vai pelo CDP (Input.dispatchMouseEvent): acontece de
|
|
496
|
+
* verdade, mas nada anda na tela — a página só muda, do nada. Medido no
|
|
497
|
+
* pedido "abra o youtube e abra o vídeo mais recente". Então, antes de cada
|
|
498
|
+
* clique e de cada digitação, uma seta desenhada NA página desliza até o
|
|
499
|
+
* alvo com o que ela vai fazer ("clicando em Pesquisar"), e o clique deixa
|
|
500
|
+
* uma onda. É DOM, não o mouse do sistema: funciona em qualquer SO, sem
|
|
501
|
+
* permissão, e não rouba o mouse de quem está usando o computador.
|
|
502
|
+
*
|
|
503
|
+
* PRIMOCODE_SEM_CURSOR=1 (e o navegador sem tela) desliga: nos testes e no
|
|
504
|
+
* servidor ninguém está olhando, e a animação só custaria tempo. */
|
|
505
|
+
const DESLIZE_MS = 450;
|
|
506
|
+
function verSeta() {
|
|
507
|
+
return process.env.PRIMOCODE_SEM_CURSOR !== '1'
|
|
508
|
+
&& !(process.env.PRIMOCODE_BROWSER_HEADLESS === '1' || process.env.PRIMOCODE_BROWSER_HEADLESS === 'true');
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function scriptDaSeta(x, y, rotulo, onda) {
|
|
512
|
+
return `(function(){
|
|
513
|
+
var id = '__primo_seta';
|
|
514
|
+
var s = document.getElementById(id);
|
|
515
|
+
if (!s) {
|
|
516
|
+
s = document.createElement('div');
|
|
517
|
+
s.id = id;
|
|
518
|
+
s.setAttribute('aria-hidden', 'true');
|
|
519
|
+
s.style.cssText = 'position:fixed;left:0;top:0;z-index:2147483647;pointer-events:none;'
|
|
520
|
+
+ 'transition:transform ${DESLIZE_MS}ms cubic-bezier(.2,.7,.2,1);will-change:transform;'
|
|
521
|
+
+ 'transform:translate(' + (innerWidth / 2) + 'px,' + (innerHeight / 2) + 'px)';
|
|
522
|
+
s.innerHTML = '<svg width="26" height="26" viewBox="0 0 24 24" style="display:block;filter:drop-shadow(0 2px 3px rgba(2,6,23,.45))">'
|
|
523
|
+
+ '<path d="M3 2 L3 19 L8 14.5 L11.5 22 L14.5 20.6 L11 13.2 L18 13.2 Z" fill="#22d3ee" stroke="#0f172a" stroke-width="1.4" stroke-linejoin="round"/></svg>'
|
|
524
|
+
+ '<div data-rotulo style="position:absolute;left:24px;top:18px;white-space:nowrap;font:600 12px/1 system-ui,sans-serif;'
|
|
525
|
+
+ 'color:#0f172a;background:#22d3ee;padding:5px 8px;border-radius:6px;box-shadow:0 2px 6px rgba(2,6,23,.3)"></div>';
|
|
526
|
+
document.documentElement.appendChild(s);
|
|
527
|
+
s.getBoundingClientRect();
|
|
528
|
+
}
|
|
529
|
+
var r = s.querySelector('[data-rotulo]');
|
|
530
|
+
r.textContent = ${JSON.stringify(String(rotulo || '').slice(0, 48))};
|
|
531
|
+
r.style.display = r.textContent ? 'block' : 'none';
|
|
532
|
+
s.style.transform = 'translate(${Math.round(x)}px,${Math.round(y)}px)';
|
|
533
|
+
if (${onda ? 'true' : 'false'}) setTimeout(function(){
|
|
534
|
+
var o = document.createElement('div');
|
|
535
|
+
o.style.cssText = 'position:fixed;left:${Math.round(x) - 16}px;top:${Math.round(y) - 16}px;width:32px;height:32px;'
|
|
536
|
+
+ 'border-radius:50%;border:3px solid #22d3ee;z-index:2147483646;pointer-events:none;'
|
|
537
|
+
+ 'transition:transform .5s ease-out,opacity .5s ease-out;transform:scale(.3);opacity:1';
|
|
538
|
+
document.documentElement.appendChild(o);
|
|
539
|
+
o.getBoundingClientRect();
|
|
540
|
+
o.style.transform = 'scale(1.6)'; o.style.opacity = '0';
|
|
541
|
+
setTimeout(function(){ o.remove(); }, 600);
|
|
542
|
+
}, ${DESLIZE_MS});
|
|
543
|
+
return true;
|
|
544
|
+
})()`;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/** Leva a seta até (x, y) e espera ela chegar. Nunca falha a ação por causa dela. */
|
|
548
|
+
async function mostrarSeta(x, y, rotulo = '', { onda = true } = {}) {
|
|
549
|
+
if (!verSeta() || !currentSession) return;
|
|
550
|
+
try {
|
|
551
|
+
await currentSession.send('Runtime.evaluate', { expression: scriptDaSeta(x, y, rotulo, onda) });
|
|
552
|
+
await sleep(DESLIZE_MS + 60);
|
|
553
|
+
} catch { /* a seta é para quem olha; a ação segue sem ela */ }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function click(x, y, rotulo = 'clicando') {
|
|
490
557
|
await ensure();
|
|
558
|
+
await mostrarSeta(x, y, rotulo);
|
|
491
559
|
await currentSession.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y });
|
|
492
560
|
await currentSession.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
|
|
493
561
|
await currentSession.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
|
|
@@ -507,7 +575,8 @@ async function clickText(text) {
|
|
|
507
575
|
if (r.width && r.height) {
|
|
508
576
|
el.scrollIntoView({block:'center'});
|
|
509
577
|
const r2 = el.getBoundingClientRect();
|
|
510
|
-
|
|
578
|
+
const nome = (el.innerText || el.value || el.getAttribute('aria-label') || '').trim().replace(/\s+/g, ' ');
|
|
579
|
+
return { x: r2.left + r2.width/2, y: r2.top + r2.height/2, tag: el.tagName, text: s.slice(0,80), nome: nome.slice(0,30) };
|
|
511
580
|
}
|
|
512
581
|
}
|
|
513
582
|
}
|
|
@@ -516,7 +585,7 @@ async function clickText(text) {
|
|
|
516
585
|
const found = await evaluate(js);
|
|
517
586
|
if (!found) throw new Error(`nenhum elemento clicável encontrado com texto: ${text}`);
|
|
518
587
|
await sleep(200);
|
|
519
|
-
await click(found.x, found.y);
|
|
588
|
+
await click(found.x, found.y, `clicando em ${found.nome || found.text}`.slice(0, 44));
|
|
520
589
|
return { ok: true, ...found };
|
|
521
590
|
}
|
|
522
591
|
|
|
@@ -595,13 +664,14 @@ async function clickSelector(selector) {
|
|
|
595
664
|
if (!el) return null;
|
|
596
665
|
el.scrollIntoView({block:'center'});
|
|
597
666
|
const r = el.getBoundingClientRect();
|
|
598
|
-
|
|
667
|
+
const nome = (el.getAttribute('aria-label') || el.innerText || el.value || el.placeholder || '').trim().replace(/\s+/g, ' ').slice(0, 30);
|
|
668
|
+
return { x: r.left + r.width/2, y: r.top + r.height/2, tag: el.tagName, nome };
|
|
599
669
|
})()`;
|
|
600
670
|
let found = await evaluate(js);
|
|
601
671
|
if (!found && await esperarSeletor(selector)) found = await evaluate(js);
|
|
602
672
|
if (!found) throw await erroDeSeletor(selector);
|
|
603
673
|
await sleep(200);
|
|
604
|
-
await click(found.x, found.y);
|
|
674
|
+
await click(found.x, found.y, found.nome ? `clicando em ${found.nome}`.slice(0, 44) : 'clicando');
|
|
605
675
|
return { ok: true, selector, ...found };
|
|
606
676
|
}
|
|
607
677
|
|
|
@@ -619,13 +689,16 @@ async function typeInSelector(selector, text, clear = true) {
|
|
|
619
689
|
const focar = () => evaluate(`(function(){
|
|
620
690
|
const el = document.querySelector(${JSON.stringify(selector)});
|
|
621
691
|
if (!el) return false;
|
|
692
|
+
el.scrollIntoView({block:'center'});
|
|
622
693
|
el.focus();
|
|
623
694
|
${clear ? "if ('value' in el) el.value = '';" : ''}
|
|
624
|
-
|
|
695
|
+
const r = el.getBoundingClientRect();
|
|
696
|
+
return { x: r.left + Math.min(24, r.width / 2), y: r.top + r.height / 2 };
|
|
625
697
|
})()`);
|
|
626
698
|
let focused = await focar();
|
|
627
699
|
if (!focused && await esperarSeletor(selector)) focused = await focar();
|
|
628
700
|
if (!focused) throw await erroDeSeletor(selector);
|
|
701
|
+
await mostrarSeta(focused.x, focused.y, `digitando: ${text}`.slice(0, 44), { onda: false });
|
|
629
702
|
await type(text);
|
|
630
703
|
// dispara evento input para frameworks reativos
|
|
631
704
|
await evaluate(`(function(){
|
|
@@ -1043,6 +1116,7 @@ function fecharRevisor() {
|
|
|
1043
1116
|
}
|
|
1044
1117
|
|
|
1045
1118
|
module.exports = {
|
|
1119
|
+
scriptDaSeta, verSeta,
|
|
1046
1120
|
open, click, clickText, clickSelector, type, typeInSelector,
|
|
1047
1121
|
key, screenshot, evaluate, waitFor, wait, scroll, close,
|
|
1048
1122
|
inspecionarEscondido, rodarEscondido, abrirEscondido, fecharRevisor,
|
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'], {
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nome-projeto.js — quem dá nome à pasta é quem entendeu o pedido.
|
|
3
|
+
*
|
|
4
|
+
* "Quero que ele dê nome às pastas que criar. Ele escolhe o nome, ele
|
|
5
|
+
* entendeu o contexto, ele analisa os outros projetos." (23/09/2026)
|
|
6
|
+
*
|
|
7
|
+
* O nome saía de `deriveProjectName`: cortar as palavras de abertura da frase
|
|
8
|
+
* e ficar com até quatro. Funciona para "crie um jogo de carro", e erra tudo
|
|
9
|
+
* que não começa assim — "aquela ideia do cardápio que falei, bora fazer" vira
|
|
10
|
+
* `aquela-ideia-do-cardapio`. Quem sabe o que está sendo feito é o modelo.
|
|
11
|
+
*
|
|
12
|
+
* ── COMO ─────────────────────────────────────────────────────────────────
|
|
13
|
+
* Uma pergunta curta ao modelo, com o pedido e os nomes das pastas que já
|
|
14
|
+
* existem, para ele seguir o mesmo jeito (idioma, tamanho, prefixo). Ela
|
|
15
|
+
* roda em PARALELO com o trabalho: a pasta só nasce na primeira escrita, e
|
|
16
|
+
* até lá a resposta já chegou. Se não chegou, ou veio lixo, vale o nome de
|
|
17
|
+
* antes — nome pior é melhor que pasta nenhuma.
|
|
18
|
+
*
|
|
19
|
+
* Puro, menos `escolher`, que recebe quem pergunta de fora (teste sem rede).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const { slugify } = require('./config.js');
|
|
25
|
+
|
|
26
|
+
// Nome que não diz nada: o modelo devolveu isso, é como não ter respondido.
|
|
27
|
+
const GENERICOS = new Set(['projeto', 'project', 'app', 'site', 'novo', 'teste', 'test', 'meu-projeto', 'my-project']);
|
|
28
|
+
const TEMPO_MAXIMO_MS = 8000;
|
|
29
|
+
|
|
30
|
+
/** O jeito das pastas que a pessoa já tem, para o modelo seguir. */
|
|
31
|
+
function convencao(nomes) {
|
|
32
|
+
const lista = (nomes || []).filter((n) => n && !n.startsWith('.'));
|
|
33
|
+
if (!lista.length) return null;
|
|
34
|
+
const hifen = lista.filter((n) => n.includes('-')).length;
|
|
35
|
+
const sublinhado = lista.filter((n) => n.includes('_')).length;
|
|
36
|
+
const separador = sublinhado > hifen ? '_' : '-';
|
|
37
|
+
const palavras = lista.map((n) => n.split(/[-_]/).length);
|
|
38
|
+
const media = Math.round(palavras.reduce((a, b) => a + b, 0) / palavras.length);
|
|
39
|
+
return { separador, palavras: Math.min(4, Math.max(1, media)), exemplos: lista.slice(0, 12) };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A pergunta. Curta: vai numa chamada à parte, e cada token conta. */
|
|
43
|
+
function pergunta(pedido, nomes) {
|
|
44
|
+
const c = convencao(nomes);
|
|
45
|
+
const jeito = c
|
|
46
|
+
? `Pastas que a pessoa já tem: ${c.exemplos.join(', ')}. Siga o mesmo jeito (idioma, tamanho, separador "${c.separador}"), `
|
|
47
|
+
+ 'e NÃO repita um nome que já existe.'
|
|
48
|
+
: 'Use kebab-case em português, de 1 a 3 palavras.';
|
|
49
|
+
return 'Dê um nome curto para a pasta do projeto deste pedido. O nome diz O QUE é '
|
|
50
|
+
+ '(ex.: site-padaria, jogo-de-carro, loja-roupas), nunca o que a pessoa pediu para fazer. '
|
|
51
|
+
+ `${jeito} Responda SÓ com o nome, sem aspas e sem explicação.\n\nPEDIDO: ${String(pedido || '').slice(0, 600)}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Limpa a resposta do modelo. Devolve null quando não dá para confiar nela. */
|
|
55
|
+
function limpar(resposta, nomes) {
|
|
56
|
+
const primeira = String(resposta || '').trim().split('\n')[0]
|
|
57
|
+
.replace(/^[`"'*\s]+|[`"'*.\s]+$/g, '')
|
|
58
|
+
.replace(/^(nome|pasta)\s*:\s*/i, '');
|
|
59
|
+
if (!primeira || primeira.length > 60 || primeira.split(/\s+/).length > 5) return null;
|
|
60
|
+
const c = convencao(nomes);
|
|
61
|
+
let nome = slugify(primeira).split('-').filter(Boolean).slice(0, 4).join('-');
|
|
62
|
+
if (!nome || GENERICOS.has(nome)) return null;
|
|
63
|
+
if (c && c.separador === '_') nome = nome.replace(/-/g, '_');
|
|
64
|
+
return semColisao(nome, nomes);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Nome que já existe ganha -2, -3… — escrever na pasta de outro projeto é pior que um sufixo. */
|
|
68
|
+
function semColisao(nome, nomes) {
|
|
69
|
+
const tomados = new Set((nomes || []).map((n) => String(n).toLowerCase()));
|
|
70
|
+
if (!tomados.has(nome)) return nome;
|
|
71
|
+
for (let i = 2; i < 100; i++) {
|
|
72
|
+
const outro = `${nome}-${i}`;
|
|
73
|
+
if (!tomados.has(outro)) return outro;
|
|
74
|
+
}
|
|
75
|
+
return `${nome}-${Date.now()}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Pergunta o nome ao modelo. Nunca rejeita: sem resposta boa a tempo,
|
|
80
|
+
* devolve null e quem chamou fica com o nome de antes.
|
|
81
|
+
*
|
|
82
|
+
* `perguntar(texto)` → Promise<string> é quem fala com o servidor.
|
|
83
|
+
*/
|
|
84
|
+
async function escolher({ pedido, nomes, perguntar, tempo = TEMPO_MAXIMO_MS }) {
|
|
85
|
+
let relogio;
|
|
86
|
+
const estourou = new Promise((r) => { relogio = setTimeout(() => r(null), tempo); });
|
|
87
|
+
try {
|
|
88
|
+
const resposta = await Promise.race([perguntar(pergunta(pedido, nomes)), estourou]);
|
|
89
|
+
return resposta == null ? null : limpar(resposta, nomes);
|
|
90
|
+
} catch {
|
|
91
|
+
return null;
|
|
92
|
+
} finally {
|
|
93
|
+
clearTimeout(relogio);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { convencao, pergunta, limpar, semColisao, escolher, GENERICOS };
|
package/lib/repeticao.js
CHANGED
|
@@ -83,8 +83,61 @@ function consultaDe(nome, args) {
|
|
|
83
83
|
return String(a.consulta || a.query || '');
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/* ── A ÉPOCA: repetir depois de mudar algo não é repetir ────────────────
|
|
87
|
+
* Medido: "edita o arquivo → roda npm test → edita → roda npm test". O
|
|
88
|
+
* segundo `npm test` tinha os mesmos argumentos do primeiro e era barrado —
|
|
89
|
+
* o agente nunca via se o conserto funcionou, ficava três voltas sem rodar
|
|
90
|
+
* nada e morria com "peça em partes". O mesmo com reler um arquivo depois de
|
|
91
|
+
* editá-lo, ou tirar outra foto da tela depois de clicar.
|
|
92
|
+
*
|
|
93
|
+
* Então olhar o mundo (ler, listar, rodar teste, fotografar a tela) vale por
|
|
94
|
+
* ÉPOCA: cada mudança de verdade (escrever, editar, clicar, rodar um comando
|
|
95
|
+
* que não é só de leitura) abre uma época nova, e a mesma leitura volta a
|
|
96
|
+
* valer. O laço de verdade — a mesma chamada sem nada mudar no meio — segue
|
|
97
|
+
* barrado. Pesquisa na web e documento de referência não dependem do disco:
|
|
98
|
+
* ficam fora da época. */
|
|
99
|
+
const OBSERVAM = new Set(['read_file', 'list_dir', 'glob', 'grep', 'browser_read', 'browser_screenshot',
|
|
100
|
+
'desktop_screenshot', 'desktop_size', 'desktop_windows', 'desktop_elements']);
|
|
101
|
+
const NAO_MUDAM = new Set(['write_todos', 'remember_general', 'pesquisar_referencia', ...REFERENCIAS]);
|
|
102
|
+
const SHELL_SO_LEITURA = /^\s*(ls|cat|head|tail|pwd|echo|grep|rg|find|wc|tree|stat|file|which|type|du|df|env|printenv|date|git\s+(status|log|diff|show|branch|remote))\b[^;&|>]*$/;
|
|
103
|
+
|
|
104
|
+
function comandoDe(args) {
|
|
105
|
+
return String((args && (args.command || args.comando)) || '');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Só olha: ler de novo depois de uma mudança é trabalho, não laço. */
|
|
109
|
+
function observa(nome, args) {
|
|
110
|
+
if (OBSERVAM.has(nome)) return true;
|
|
111
|
+
return nome === 'run_shell' && SHELL_SO_LEITURA.test(comandoDe(args));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Muda o mundo: abre uma época nova. */
|
|
115
|
+
function muda(nome, args) {
|
|
116
|
+
return !observa(nome, args) && !NAO_MUDAM.has(nome);
|
|
117
|
+
}
|
|
118
|
+
|
|
86
119
|
function novo() {
|
|
87
|
-
return { vistos: new Map(), buscas: [] };
|
|
120
|
+
return { vistos: new Map(), buscas: [], epoca: 0, epocaDaMudanca: 0, ultimaMudanca: null, olhouDesde: false };
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Avisa que a chamada RODOU. Quem chama faz isto depois de executar (e não
|
|
125
|
+
* de barrar): é o que abre a época nova.
|
|
126
|
+
*
|
|
127
|
+
* A mesma mudança repetida sem olhar nada no meio (escrever o mesmo conteúdo
|
|
128
|
+
* no mesmo arquivo, clicar no mesmo ponto às cegas) não abre época e é
|
|
129
|
+
* barrada já na segunda vez — senão o laço de escrita passaria livre.
|
|
130
|
+
*/
|
|
131
|
+
function executou(registro, nome, args) {
|
|
132
|
+
if (observa(nome, args)) { registro.olhouDesde = true; return; }
|
|
133
|
+
if (!muda(nome, args)) return;
|
|
134
|
+
const chave = assinatura(nome, args);
|
|
135
|
+
if (chave !== registro.ultimaMudanca || registro.olhouDesde) {
|
|
136
|
+
registro.epocaDaMudanca = registro.epoca;
|
|
137
|
+
registro.epoca++;
|
|
138
|
+
}
|
|
139
|
+
registro.ultimaMudanca = chave;
|
|
140
|
+
registro.olhouDesde = false;
|
|
88
141
|
}
|
|
89
142
|
|
|
90
143
|
/**
|
|
@@ -94,8 +147,14 @@ function novo() {
|
|
|
94
147
|
* NÃO deve executar a tool: manda o `aviso` de volta como resultado.
|
|
95
148
|
*/
|
|
96
149
|
function ver(registro, nome, args) {
|
|
97
|
-
const chave = assinatura(nome, args);
|
|
98
150
|
const busca = consultaDe(nome, args);
|
|
151
|
+
const foraDaEpoca = busca !== null || REFERENCIAS.has(nome);
|
|
152
|
+
const base = assinatura(nome, args);
|
|
153
|
+
// A mesma mudança logo em seguida, sem olhar nada no meio, conta na época
|
|
154
|
+
// em que a primeira rodou — senão ela mesma abriria a porta da segunda.
|
|
155
|
+
const epoca = (base === registro.ultimaMudanca && !registro.olhouDesde)
|
|
156
|
+
? registro.epocaDaMudanca : (registro.epoca || 0);
|
|
157
|
+
const chave = base + (foraDaEpoca ? '' : `@${epoca}`);
|
|
99
158
|
|
|
100
159
|
let vezes = (registro.vistos.get(chave) || 0) + 1;
|
|
101
160
|
let parecidaCom = null;
|
|
@@ -123,14 +182,15 @@ function ver(registro, nome, args) {
|
|
|
123
182
|
function aviso(nome, vezes, parecidaCom) {
|
|
124
183
|
const oQue = parecidaCom
|
|
125
184
|
? `Você já pesquisou isto: "${parecidaCom}". A busca de agora é a mesma coisa com outras palavras, e traria as mesmas fontes.`
|
|
126
|
-
: `Você já chamou ${nome} com estes mesmos argumentos.`;
|
|
185
|
+
: `Você já chamou ${nome} com estes mesmos argumentos, e nada mudou desde então.`;
|
|
127
186
|
|
|
128
187
|
if (vezes === 2) {
|
|
129
188
|
return `${oQue} O resultado está na conversa acima — role para cima e use o que já tem. `
|
|
130
189
|
+ 'Não chame esta tool de novo com os mesmos argumentos.';
|
|
131
190
|
}
|
|
132
191
|
return `${oQue} Esta é a ${vezes}ª vez, e a tool NÃO foi executada. PARE de repetir: `
|
|
133
|
-
+ 'com o que você já tem, produza AGORA o resultado que o usuário pediu (crie o arquivo, o documento, o vídeo)
|
|
192
|
+
+ 'com o que você já tem, produza AGORA o resultado que o usuário pediu (crie o arquivo, o documento, o vídeo), '
|
|
193
|
+
+ 'ou tente OUTRO caminho (outra ferramenta, outro arquivo, outro comando). '
|
|
134
194
|
+ 'Se algo realmente impede, chame finish e diga ao usuário o que faltou.';
|
|
135
195
|
}
|
|
136
196
|
|
|
@@ -187,5 +247,5 @@ function referenciasNaConversa(messages) {
|
|
|
187
247
|
module.exports = {
|
|
188
248
|
REFERENCIAS, LIMITE_SEMELHANCA,
|
|
189
249
|
normalizar, palavras, semelhanca, assinatura, consultaDe,
|
|
190
|
-
novo, ver, chamadasPorId, referenciasNaConversa,
|
|
250
|
+
novo, ver, executou, observa, muda, chamadasPorId, referenciasNaConversa,
|
|
191
251
|
};
|
package/lib/tools.js
CHANGED
|
@@ -22,16 +22,38 @@ const cursor = require('./cursor');
|
|
|
22
22
|
* ação acontece de verdade, e a pessoa VÊ a seta dele indo até lá, clicando,
|
|
23
23
|
* digitando. Sem o cursor (outro sistema, ou o Python não subiu), vale o
|
|
24
24
|
* caminho antigo do desktop.js. */
|
|
25
|
+
/* Sem o cursor, a ação vai pelo caminho antigo, que NÃO aparece na tela. Isso
|
|
26
|
+
era calado: a pessoa via o terminal dizer "ok" e nada se mexer. Agora sai
|
|
27
|
+
uma linha, uma vez por sessão, dizendo por quê — e o modelo recebe o
|
|
28
|
+
mesmo aviso junto do resultado, para contar em vez de fingir. */
|
|
29
|
+
let avisouSemCursor = false;
|
|
30
|
+
function semCursorVisivel(resultado, motivo) {
|
|
31
|
+
const aviso = `o cursor do Primo não abriu (${motivo || 'motivo desconhecido'}): a ação roda, mas não aparece na tela`;
|
|
32
|
+
if (!avisouSemCursor) {
|
|
33
|
+
avisouSemCursor = true;
|
|
34
|
+
try { console.log(require('./ui.js').c.warn(` ⎿ ${aviso}`)); } catch { /* sem tela */ }
|
|
35
|
+
}
|
|
36
|
+
return resultado && typeof resultado === 'object' ? { ...resultado, avisoCursor: aviso } : resultado;
|
|
37
|
+
}
|
|
38
|
+
|
|
25
39
|
async function peloCursor(cmd, args, reserva) {
|
|
26
40
|
if (!cursor.disponivel()) return reserva();
|
|
27
41
|
const r = await cursor.mandar(cmd, args);
|
|
28
|
-
if (r.semCursor) return reserva();
|
|
42
|
+
if (r.semCursor) return semCursorVisivel(await reserva(), r.error);
|
|
29
43
|
if (!r.ok) return { ok: false, error: r.error || 'o cursor não conseguiu', ...(r.permissao === false ? { permissao: false } : {}) };
|
|
30
44
|
return { ok: true };
|
|
31
45
|
}
|
|
32
46
|
|
|
33
47
|
function numero(v) { const n = Number(v); return Number.isFinite(n) ? n : null; }
|
|
34
48
|
|
|
49
|
+
/** Os textos visíveis na tela, pelo OCR do cursor, no formato dos elementos. */
|
|
50
|
+
async function textosDaTela() {
|
|
51
|
+
if (!cursor.disponivel()) return { ok: false, error: 'ler a tela é só no macOS' };
|
|
52
|
+
const r = await cursor.mandar('ler_tela', {}, 30000);
|
|
53
|
+
if (!r.ok) return { ok: false, error: r.error || 'não consegui ler a tela' };
|
|
54
|
+
return { ok: true, elementos: (r.textos || []).map((t) => ({ nome: t.texto, tipo: 'texto na tela', x: t.x, y: t.y })) };
|
|
55
|
+
}
|
|
56
|
+
|
|
35
57
|
async function clicarPeloCursor(x, y, { botao = 'left', duplo = false, rotulo } = {}) {
|
|
36
58
|
const [nx, ny] = [numero(x), numero(y)];
|
|
37
59
|
if (nx == null || ny == null) return { ok: false, error: `Coordenada inválida: x=${x}, y=${y}` };
|
|
@@ -643,8 +665,16 @@ async function despachar(name, args, ctx) {
|
|
|
643
665
|
return desktop.listWindows();
|
|
644
666
|
case 'desktop_focus':
|
|
645
667
|
return desktop.focusWindow(args.window || args.title || '');
|
|
646
|
-
case 'desktop_elements':
|
|
647
|
-
|
|
668
|
+
case 'desktop_elements': {
|
|
669
|
+
const lidos = desktop.uiElements(args.window);
|
|
670
|
+
if (!cursor.disponivel()) return lidos;
|
|
671
|
+
// No Mac, junto dos botões da acessibilidade vão os TEXTOS
|
|
672
|
+
// que aparecem na tela (OCR), com a posição de cada um.
|
|
673
|
+
const naTela = await textosDaTela();
|
|
674
|
+
if (!naTela.ok) return lidos.ok ? { ...lidos, telaIndisponivel: naTela.error } : lidos;
|
|
675
|
+
return { ...(lidos.ok ? lidos : { ok: true, elementos: [] }),
|
|
676
|
+
textosNaTela: naTela.elementos.slice(0, 120).map((e) => ({ texto: e.nome, x: e.x, y: e.y })) };
|
|
677
|
+
}
|
|
648
678
|
case 'desktop_click_element': {
|
|
649
679
|
if (!cursor.disponivel()) {
|
|
650
680
|
return desktop.clickElement(args.name || args.text || '', {
|
|
@@ -656,11 +686,21 @@ async function despachar(name, args, ctx) {
|
|
|
656
686
|
const pedido = String(args.name || args.text || '').trim();
|
|
657
687
|
if (!pedido) return { ok: false, error: 'Diga o nome do elemento (o texto do botão, ícone ou campo).' };
|
|
658
688
|
const lidos = desktop.uiElements(args.window);
|
|
659
|
-
|
|
660
|
-
|
|
689
|
+
let alvo = lidos.ok ? desktop.escolherElemento(lidos.elementos, pedido) : null;
|
|
690
|
+
/* A acessibilidade não achou (ou o app não expõe botões): procura
|
|
691
|
+
o TEXTO na tela, pelo OCR do cursor. É o que faz "clica em
|
|
692
|
+
Salvar" funcionar em qualquer app, como no Jarvis. */
|
|
693
|
+
let naTela = null;
|
|
694
|
+
if (!alvo) {
|
|
695
|
+
naTela = await textosDaTela();
|
|
696
|
+
if (naTela.ok) alvo = desktop.escolherElemento(naTela.elementos, pedido);
|
|
697
|
+
}
|
|
661
698
|
if (!alvo) {
|
|
662
|
-
return { ok: false,
|
|
663
|
-
|
|
699
|
+
return { ok: false,
|
|
700
|
+
error: `Nenhum elemento parecido com "${pedido}"${lidos.ok ? ` na janela "${lidos.janela}"` : ''}, nem como texto na tela.`,
|
|
701
|
+
elementosDisponiveis: [...(lidos.ok ? lidos.elementos : []), ...((naTela && naTela.ok) ? naTela.elementos : [])]
|
|
702
|
+
.slice(0, 50).map((e) => e.nome),
|
|
703
|
+
...(naTela && !naTela.ok ? { telaIndisponivel: naTela.error } : {}),
|
|
664
704
|
comoCorrigir: 'Chame de novo com um nome de "elementosDisponiveis", copiado literalmente.' };
|
|
665
705
|
}
|
|
666
706
|
const r = await clicarPeloCursor(alvo.x, alvo.y, {
|
package/lib/voz.js
CHANGED
|
@@ -62,6 +62,12 @@ ${DICA_COMPUTADOR}
|
|
|
62
62
|
Faça o trabalho inteiro normalmente, com as ferramentas de sempre. O que muda
|
|
63
63
|
é só o jeito de contar no fim.`;
|
|
64
64
|
|
|
65
|
+
/* A forma da resposta de CONVERSA por voz ("oi", "tudo bem?", "o que você
|
|
66
|
+
faz?"). Sem ferramenta e sem memória de tarefa: um "oi" ia para o agente
|
|
67
|
+
inteiro, com a lista das tarefas antigas e a ordem de mexer no cursor, e
|
|
68
|
+
ele respondia falando delas — "eu mandei oi e ele respondeu nada a ver". */
|
|
69
|
+
const COMO_CONVERSAR = `[MODO VOZ] A sua resposta vai ser LIDA EM VOZ ALTA. Responda ao que a pessoa acabou de dizer, e só a isso, em uma ou duas frases curtas, em português falado, sem markdown. Não puxe assunto de tarefa antiga. Se for só um cumprimento, cumprimente de volta e pergunte o que ela quer fazer.`;
|
|
70
|
+
|
|
65
71
|
/** Onde está o Python 3. Mesmo problema do Estúdio, mesma cura. */
|
|
66
72
|
function python() {
|
|
67
73
|
const nomes = process.platform === 'win32'
|
|
@@ -430,6 +436,7 @@ const OPCIONAIS = () => [
|
|
|
430
436
|
...(process.platform === 'darwin' ? [
|
|
431
437
|
['AppKit', 'pyobjc-framework-Cocoa', 'a orb transparente do macOS'],
|
|
432
438
|
['Quartz', 'pyobjc-framework-Quartz', 'o cursor do Primo na tela'],
|
|
439
|
+
['Vision', 'pyobjc-framework-Vision', 'ler o texto da tela (clicar pelo nome em qualquer app)'],
|
|
433
440
|
] : []),
|
|
434
441
|
];
|
|
435
442
|
|
|
@@ -574,4 +581,4 @@ async function preparar(aoFalar = () => {}) {
|
|
|
574
581
|
return { ok: true, python: py, esperando, avisos };
|
|
575
582
|
}
|
|
576
583
|
|
|
577
|
-
module.exports = { DICA_COMPUTADOR, conferir, instalar, preparar, porqueOPipFalhou, abrir, conferirEspera, baixarEspera, python, COMO_FALAR, PASTA };
|
|
584
|
+
module.exports = { DICA_COMPUTADOR, COMO_CONVERSAR, 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.
|
|
3
|
+
"version": "9.8.0-beta.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/voz-conversa.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.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/autonomia.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.
|
|
@@ -243,8 +243,10 @@ class Voz:
|
|
|
243
243
|
continue
|
|
244
244
|
|
|
245
245
|
self.orb.mudar(estado="pensando")
|
|
246
|
-
|
|
247
|
-
|
|
246
|
+
# O servidor também transcreve com Whisper: a mesma limpeza vale.
|
|
247
|
+
texto = transcrever.limpar(self._transcrever(wav))
|
|
248
|
+
if not texto:
|
|
249
|
+
self.orb.mudar(estado="esperando")
|
|
248
250
|
continue
|
|
249
251
|
|
|
250
252
|
falar_para_o_node({"tipo": "pedido", "texto": texto})
|
package/voz/primo_voz/cursor.py
CHANGED
|
@@ -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}
|
|
@@ -74,13 +74,49 @@ def aquecer() -> None:
|
|
|
74
74
|
threading.Thread(target=_ir, daemon=True).start()
|
|
75
75
|
|
|
76
76
|
|
|
77
|
+
# ── O QUE O WHISPER INVENTA ─────────────────────────────────────────────
|
|
78
|
+
# Treinado em legenda de vídeo, ele "ouve" no ruído o fim de um vídeo do
|
|
79
|
+
# YouTube. Essas frases chegavam ao agente como pedido, e ele respondia a
|
|
80
|
+
# elas: "eu mandei oi e ele respondeu nada a ver" (23/09/2026).
|
|
81
|
+
ALUCINACOES = (
|
|
82
|
+
"obrigado por assistir", "obrigada por assistir", "obrigado pela atenção",
|
|
83
|
+
"legendas pela comunidade", "amara.org", "inscreva-se", "se inscreva",
|
|
84
|
+
"deixe seu like", "ative o sininho", "até o próximo vídeo", "até a próxima",
|
|
85
|
+
"legendado por", "legenda por", "tradução e legendas", "transcrição por",
|
|
86
|
+
"www.", ".com.br",
|
|
87
|
+
)
|
|
88
|
+
# Acima disto o próprio Whisper acha que não havia fala no trecho.
|
|
89
|
+
SEM_FALA = 0.6
|
|
90
|
+
# Abaixo disto a confiança média é baixa demais para virar pedido.
|
|
91
|
+
CONFIANCA_MINIMA = -1.0
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def limpar(texto: str) -> str:
|
|
95
|
+
"""Tira a frase inventada. Devolve "" quando não sobra pedido."""
|
|
96
|
+
t = " ".join(str(texto or "").split())
|
|
97
|
+
baixo = t.lower()
|
|
98
|
+
if any(frase in baixo for frase in ALUCINACOES):
|
|
99
|
+
return ""
|
|
100
|
+
# Só pontuação ou uma letra solta não é pedido.
|
|
101
|
+
if len("".join(ch for ch in t if ch.isalnum())) < 2:
|
|
102
|
+
return ""
|
|
103
|
+
return t
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def aproveitavel(segmento) -> bool:
|
|
107
|
+
"""O segmento tem fala de verdade, pelo que o próprio Whisper mediu?"""
|
|
108
|
+
sem_fala = getattr(segmento, "no_speech_prob", 0.0) or 0.0
|
|
109
|
+
confianca = getattr(segmento, "avg_logprob", 0.0) or 0.0
|
|
110
|
+
return not (sem_fala > SEM_FALA and confianca < CONFIANCA_MINIMA)
|
|
111
|
+
|
|
112
|
+
|
|
77
113
|
def transcrever(wav: Path, idioma: str = "pt") -> str:
|
|
78
114
|
modelo = carregar()
|
|
79
115
|
# vad_filter corta o silêncio das pontas: sem ele, o Whisper às vezes
|
|
80
116
|
# "ouve" uma frase inteira inventada no silêncio ("Obrigado por assistir").
|
|
81
117
|
segmentos, _ = modelo.transcribe(str(wav), language=idioma, beam_size=5,
|
|
82
118
|
vad_filter=True, condition_on_previous_text=False)
|
|
83
|
-
return " ".join(s.text.strip() for s in segmentos
|
|
119
|
+
return limpar(" ".join(s.text.strip() for s in segmentos if aproveitavel(s)))
|
|
84
120
|
|
|
85
121
|
|
|
86
122
|
def main() -> int:
|