primocode 7.10.0 → 7.11.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 +30 -1
- package/lib/act.js +27 -0
- package/lib/catalogo.js +15 -9
- package/lib/desktop.js +578 -47
- package/lib/modo.js +5 -1
- package/lib/streaming.js +241 -0
- package/lib/tools.js +16 -0
- package/lib/ui.js +16 -3
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v7.10.
|
|
1
|
+
# PrimoCode v7.10.1
|
|
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 v7.10.
|
|
12
|
+
▐▛███▜▌ Primo Code v7.10.1
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/bin/primocode.js
CHANGED
|
@@ -22,6 +22,7 @@ const cfg = require('../lib/config');
|
|
|
22
22
|
const { runAct } = require('../lib/act');
|
|
23
23
|
const pasta = require('../lib/pasta');
|
|
24
24
|
const catalogo = require('../lib/catalogo');
|
|
25
|
+
const streaming = require('../lib/streaming');
|
|
25
26
|
|
|
26
27
|
// A pasta de onde o comando foi dado. Não é mais onde ele trabalha (o padrão é
|
|
27
28
|
// ~/primocode), mas continua servindo de ponto de partida para achar pasta que
|
|
@@ -55,6 +56,7 @@ ${c.bold('Comandos')}
|
|
|
55
56
|
${c.brand('/read')} <arquivo> mostra um arquivo
|
|
56
57
|
${c.brand('/run')} <comando> executa no shell do projeto
|
|
57
58
|
${c.brand('/open')} <arquivo|url> abre no navegador
|
|
59
|
+
${c.brand('/streaming')} painel ao vivo — veja o agente mexendo no computador
|
|
58
60
|
${c.brand('/status')} checa o servidor
|
|
59
61
|
${c.brand('/name')} <nome> como o Primo te chama
|
|
60
62
|
${c.brand('/mascot')} <estilo> mascote: primo|fox|cat
|
|
@@ -465,7 +467,7 @@ async function handleSlash(text) {
|
|
|
465
467
|
for (const g of catalogo.GRUPOS) {
|
|
466
468
|
console.log(' ' + c.bold(c.white(g.nome)) + ' ' + ui.truncate(c.dim('— ' + g.resumo), cols - 4 - ui.visibleWidth(g.nome)));
|
|
467
469
|
for (const i of g.itens) {
|
|
468
|
-
console.log(' ' + c.brand(ui.padRight(i.tool,
|
|
470
|
+
console.log(' ' + c.brand(ui.padRight(i.tool, 23)) + ui.truncate(c.muted(i.faz), cols - 28));
|
|
469
471
|
}
|
|
470
472
|
console.log('');
|
|
471
473
|
}
|
|
@@ -503,6 +505,33 @@ async function handleSlash(text) {
|
|
|
503
505
|
break;
|
|
504
506
|
}
|
|
505
507
|
|
|
508
|
+
// Painel ao vivo: um servidorzinho local mostra cada ação do agente na
|
|
509
|
+
// tela + a foto tirada logo depois dela. Liga/desliga; /streaming on|off
|
|
510
|
+
// também funciona.
|
|
511
|
+
case 'streaming': case 'stream': case 'live': {
|
|
512
|
+
const pedido = rest.toLowerCase();
|
|
513
|
+
const ligar = pedido === 'on' ? true : pedido === 'off' ? false : !streaming.isOn();
|
|
514
|
+
|
|
515
|
+
if (!ligar) {
|
|
516
|
+
if (streaming.isOn()) { streaming.stop(); console.log(c.ok('Streaming desligado.')); }
|
|
517
|
+
else console.log(c.muted('O streaming já estava desligado.'));
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
520
|
+
if (streaming.isOn()) {
|
|
521
|
+
console.log(c.muted(`Já está ligado: ${streaming.url()}`));
|
|
522
|
+
break;
|
|
523
|
+
}
|
|
524
|
+
try {
|
|
525
|
+
const url = await streaming.start();
|
|
526
|
+
console.log(c.ok('Streaming ligado: ') + c.white(url));
|
|
527
|
+
console.log(c.muted(' Dê uma instrução e veja cada passo dele aí, ao vivo. /streaming de novo desliga.'));
|
|
528
|
+
await tools.openInBrowser(url);
|
|
529
|
+
} catch (e) {
|
|
530
|
+
console.log(c.err(`Não consegui ligar o streaming: ${e.message}`));
|
|
531
|
+
}
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
|
|
506
535
|
case 'name': {
|
|
507
536
|
if (!rest) { console.log(c.muted(` Nome atual: ${state.config.userName || '(nenhum)'}`)); break; }
|
|
508
537
|
state.config.userName = rest;
|
package/lib/act.js
CHANGED
|
@@ -15,6 +15,7 @@ const tools = require('./tools');
|
|
|
15
15
|
const api = require('./api');
|
|
16
16
|
const cfg = require('./config');
|
|
17
17
|
const catalogo = require('./catalogo');
|
|
18
|
+
const streaming = require('./streaming');
|
|
18
19
|
|
|
19
20
|
const MAX_ITERATIONS = 15;
|
|
20
21
|
const MAX_PROFUNDIDADE = 2; // principal + 1 nível de subagente
|
|
@@ -93,6 +94,27 @@ function logResult(kind, result, profundidade) {
|
|
|
93
94
|
case 'browser_read':
|
|
94
95
|
console.log(p + c.muted(`${(result.text || '').length} caracteres lidos da página`));
|
|
95
96
|
return;
|
|
97
|
+
case 'desktop_windows': {
|
|
98
|
+
console.log(p + c.muted(`${result.total} janela${result.total === 1 ? '' : 's'}`));
|
|
99
|
+
for (const j of (result.janelas || []).slice(0, 5)) console.log(p + c.dim(`${j.app ? j.app + ' · ' : ''}${j.titulo}`.slice(0, 120)));
|
|
100
|
+
if ((result.janelas || []).length > 5) console.log(p + c.dim(`… +${result.janelas.length - 5}`));
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
case 'desktop_elements': {
|
|
104
|
+
console.log(p + c.muted(`${result.total} elemento${result.total === 1 ? '' : 's'} em “${result.janela || 'janela em foco'}”`));
|
|
105
|
+
for (const e of (result.elementos || []).slice(0, 5)) console.log(p + c.dim(`${e.nome} (${e.x},${e.y})`.slice(0, 120)));
|
|
106
|
+
if ((result.elementos || []).length > 5) console.log(p + c.dim(`… +${result.elementos.length - 5}`));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
case 'desktop_click_element':
|
|
110
|
+
console.log(p + c.ok('ok') + c.muted(` · ${result.duplo ? 'clique duplo' : 'clique'} em “${result.clicado}” (${result.x},${result.y})`));
|
|
111
|
+
return;
|
|
112
|
+
case 'desktop_open_app':
|
|
113
|
+
console.log(p + c.ok('ok') + c.muted(` · ${result.app}`));
|
|
114
|
+
return;
|
|
115
|
+
case 'desktop_focus':
|
|
116
|
+
console.log(p + c.ok('ok') + c.muted(` · ${result.janela || 'em foco'}`));
|
|
117
|
+
return;
|
|
96
118
|
case 'browser_screenshot':
|
|
97
119
|
case 'desktop_screenshot':
|
|
98
120
|
console.log(p + c.muted(`${result.path} · ${tools.fmtBytes(result.bytes || 0)}`));
|
|
@@ -104,6 +126,7 @@ function logResult(kind, result, profundidade) {
|
|
|
104
126
|
case 'browser_wait':
|
|
105
127
|
case 'browser_eval':
|
|
106
128
|
case 'desktop_click':
|
|
129
|
+
case 'desktop_double_click':
|
|
107
130
|
case 'desktop_move':
|
|
108
131
|
case 'desktop_drag':
|
|
109
132
|
case 'desktop_type':
|
|
@@ -257,6 +280,10 @@ async function runAct(userPrompt, opts) {
|
|
|
257
280
|
const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
|
|
258
281
|
logResult(tc.name, result, profundidade);
|
|
259
282
|
|
|
283
|
+
// Painel ao vivo (/streaming): registra o passo e fotografa a tela.
|
|
284
|
+
// Nunca pode derrubar o loop — falha de foto é problema do painel.
|
|
285
|
+
try { streaming.step(tc.name, tc.arguments, result); } catch {}
|
|
286
|
+
|
|
260
287
|
// Uma escrita bem-sucedida limpa a dívida: o agente se corrigiu.
|
|
261
288
|
if (result && result.ok === false) falhasPendentes.push(`${tc.name}(${tc.arguments?.path || ''}): ${result.error}`);
|
|
262
289
|
else if (['write_file', 'edit_file'].includes(tc.name)) falhasPendentes.length = 0;
|
package/lib/catalogo.js
CHANGED
|
@@ -55,16 +55,22 @@ const GRUPOS = [
|
|
|
55
55
|
},
|
|
56
56
|
{
|
|
57
57
|
nome: 'Computador',
|
|
58
|
-
resumo: '
|
|
58
|
+
resumo: 'janelas, botões, mouse e teclado — age em qualquer programa e na área de trabalho',
|
|
59
59
|
itens: [
|
|
60
|
-
{ tool: '
|
|
61
|
-
{ tool: '
|
|
62
|
-
{ tool: '
|
|
63
|
-
{ tool: '
|
|
64
|
-
{ tool: '
|
|
65
|
-
{ tool: '
|
|
66
|
-
{ tool: '
|
|
67
|
-
{ tool: '
|
|
60
|
+
{ tool: 'desktop_open_app', escreve: false, faz: 'abre um programa pelo nome (Chrome, Bloco de Notas…)' },
|
|
61
|
+
{ tool: 'desktop_windows', escreve: false, faz: 'lista as janelas abertas, com título' },
|
|
62
|
+
{ tool: 'desktop_focus', escreve: false, faz: 'traz uma janela para a frente, pelo título' },
|
|
63
|
+
{ tool: 'desktop_elements', escreve: false, faz: 'lê os botões, campos e ícones da janela, com posição' },
|
|
64
|
+
{ tool: 'desktop_click_element', escreve: false, faz: 'clica num botão ou ícone pelo NOME, sem coordenada' },
|
|
65
|
+
{ tool: 'desktop_click', escreve: false, faz: 'clica na tela por coordenada' },
|
|
66
|
+
{ tool: 'desktop_double_click', escreve: false, faz: 'clique duplo — abre ícone da área de trabalho' },
|
|
67
|
+
{ tool: 'desktop_move', escreve: false, faz: 'move o mouse' },
|
|
68
|
+
{ tool: 'desktop_drag', escreve: false, faz: 'arrasta de um ponto a outro' },
|
|
69
|
+
{ tool: 'desktop_type', escreve: false, faz: 'digita no programa que está na frente' },
|
|
70
|
+
{ tool: 'desktop_key', escreve: false, faz: 'aperta tecla ou atalho (Enter, ctrl+c, alt+tab)' },
|
|
71
|
+
{ tool: 'desktop_scroll', escreve: false, faz: 'rola a tela' },
|
|
72
|
+
{ tool: 'desktop_size', escreve: false, faz: 'mede a tela, para se orientar antes de clicar' },
|
|
73
|
+
{ tool: 'desktop_screenshot', escreve: true, faz: 'tira foto da sua tela' },
|
|
68
74
|
],
|
|
69
75
|
},
|
|
70
76
|
{
|
package/lib/desktop.js
CHANGED
|
@@ -1,9 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* desktop.js — Controle nativo do mouse
|
|
2
|
+
* desktop.js — Controle nativo do computador: mouse, teclado, janelas e tela.
|
|
3
3
|
* Zero dependências npm. Usa ferramentas nativas do SO:
|
|
4
|
-
* Linux:
|
|
5
|
-
* macOS:
|
|
6
|
-
* Windows: powershell +
|
|
4
|
+
* Linux: xdotool (mouse/teclado/janelas) + scrot/maim/… (tela)
|
|
5
|
+
* macOS: cliclick (brew install cliclick) e osascript/System Events
|
|
6
|
+
* Windows: powershell (user32 + UI Automation) — nada a instalar
|
|
7
|
+
*
|
|
8
|
+
* A regra do módulo: o agente NÃO enxerga pixels (os modelos são só-texto),
|
|
9
|
+
* então ele enxerga por NOMES — as janelas abertas (listWindows), os botões e
|
|
10
|
+
* ícones de cada janela (uiElements) e o clique pelo nome (clickElement).
|
|
11
|
+
* Coordenada continua existindo, mas é o último recurso, não o primeiro.
|
|
7
12
|
*/
|
|
8
13
|
|
|
9
14
|
'use strict';
|
|
@@ -24,19 +29,52 @@ const TOOLS = {
|
|
|
24
29
|
nircmd: PLATFORM === 'win32' && (() => { try { execSync('where nircmd', {stdio:'ignore'}); return true; } catch { return false; } })(),
|
|
25
30
|
};
|
|
26
31
|
|
|
27
|
-
function run(cmd) {
|
|
28
|
-
try {
|
|
29
|
-
|
|
32
|
+
function run(cmd, opts = {}) {
|
|
33
|
+
try {
|
|
34
|
+
execSync(cmd, {
|
|
35
|
+
stdio: 'ignore',
|
|
36
|
+
timeout: opts.timeout || 15000,
|
|
37
|
+
env: opts.env ? { ...process.env, ...opts.env } : undefined,
|
|
38
|
+
});
|
|
39
|
+
return { ok: true };
|
|
40
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
30
41
|
}
|
|
31
42
|
|
|
32
|
-
//
|
|
33
|
-
|
|
34
|
-
|
|
43
|
+
// Igual ao run(), mas devolve o stdout — para os comandos que PERGUNTAM algo
|
|
44
|
+
// (listar janelas, ler elementos) em vez de só agir.
|
|
45
|
+
function runOut(cmd, opts = {}) {
|
|
46
|
+
try {
|
|
47
|
+
const out = execSync(cmd, {
|
|
48
|
+
encoding: 'utf-8',
|
|
49
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
50
|
+
timeout: opts.timeout || 15000,
|
|
51
|
+
env: opts.env ? { ...process.env, ...opts.env } : undefined,
|
|
52
|
+
});
|
|
53
|
+
return { ok: true, out: String(out || '') };
|
|
54
|
+
} catch (e) { return { ok: false, error: e.message, out: String((e && e.stdout) || '') }; }
|
|
35
55
|
}
|
|
36
56
|
|
|
57
|
+
// Argumento de shell entre aspas simples, à prova de apóstrofo. O truque
|
|
58
|
+
// POSIX é fechar a aspa, escapar o apóstrofo e reabrir: 'don'\''t'.
|
|
59
|
+
function sh1(s) {
|
|
60
|
+
return `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// PowerShell via -EncodedCommand: o script vai em base64 (UTF-16LE), então
|
|
64
|
+
// aspas, cifrão e acento do texto do usuário NUNCA passam pelo cmd.exe cru.
|
|
65
|
+
// A versão antiga escapava aspas à mão dentro de -Command "..." e quebrava
|
|
66
|
+
// com qualquer texto que tivesse caractere especial — era um dos motivos de
|
|
67
|
+
// "clica mas não digita" no Windows.
|
|
68
|
+
function pwshCmd(script) {
|
|
69
|
+
const b64 = Buffer.from(script, 'utf16le').toString('base64');
|
|
70
|
+
return `powershell -NoProfile -NonInteractive -EncodedCommand ${b64}`;
|
|
71
|
+
}
|
|
72
|
+
function pwshRun(script, opts = {}) { return run(pwshCmd(script), opts); }
|
|
73
|
+
function pwshOut(script, opts = {}) { return runOut(pwshCmd(script), opts); }
|
|
74
|
+
|
|
37
75
|
// Clique/scroll de verdade no Windows precisam de mouse_event (user32).
|
|
38
76
|
// A abordagem antiga só posicionava o cursor — nunca clicava.
|
|
39
|
-
const WIN_USER32 =
|
|
77
|
+
const WIN_USER32 = `Add-Type -Name U -Namespace W -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetCursorPos(int X,int Y); [DllImport("user32.dll")] public static extern void mouse_event(uint f,uint dx,uint dy,uint d,int e);';`;
|
|
40
78
|
|
|
41
79
|
// Primeira ferramenta cujo comando existe no PATH.
|
|
42
80
|
function firstCmd(list) {
|
|
@@ -44,86 +82,574 @@ function firstCmd(list) {
|
|
|
44
82
|
return null;
|
|
45
83
|
}
|
|
46
84
|
|
|
85
|
+
// Coordenada tem de ser número. Validar aqui vale para as três plataformas —
|
|
86
|
+
// sem isso um x="abc" virava comando de shell quebrado, não erro legível.
|
|
87
|
+
function coord(v) {
|
|
88
|
+
const n = Math.round(Number(v));
|
|
89
|
+
return Number.isFinite(n) ? n : null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ── Teclas: um dialeto por SO, uma tradução só ───────────────────────────
|
|
93
|
+
// O modelo fala "ctrl+shift+t", "Enter", "alt+tab" — e cada SO tem a própria
|
|
94
|
+
// gramática. Estas funções são puras (testáveis sem SO nenhum).
|
|
95
|
+
|
|
96
|
+
/** "ctrl+shift+t" → { mods: ['ctrl','shift'], key: 't' } */
|
|
97
|
+
function parseCombo(name) {
|
|
98
|
+
const parts = String(name || '').trim().split('+').map((s) => s.trim()).filter(Boolean);
|
|
99
|
+
const key = parts.pop() || '';
|
|
100
|
+
const mods = [];
|
|
101
|
+
for (const p of parts) {
|
|
102
|
+
const m = p.toLowerCase();
|
|
103
|
+
if (m === 'ctrl' || m === 'control') mods.push('ctrl');
|
|
104
|
+
else if (m === 'shift') mods.push('shift');
|
|
105
|
+
else if (m === 'alt' || m === 'option' || m === 'opt') mods.push('alt');
|
|
106
|
+
else if (m === 'cmd' || m === 'command' || m === 'win' || m === 'windows' || m === 'super' || m === 'meta') mods.push('super');
|
|
107
|
+
}
|
|
108
|
+
return { mods, key };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const F_RE = /^f([1-9]|1[0-2])$/i;
|
|
112
|
+
|
|
113
|
+
// xdotool: nomes de keysym.
|
|
114
|
+
const XKEYS = {
|
|
115
|
+
enter: 'Return', return: 'Return', tab: 'Tab', escape: 'Escape', esc: 'Escape',
|
|
116
|
+
backspace: 'BackSpace', delete: 'Delete', del: 'Delete', space: 'space',
|
|
117
|
+
arrowup: 'Up', up: 'Up', arrowdown: 'Down', down: 'Down',
|
|
118
|
+
arrowleft: 'Left', left: 'Left', arrowright: 'Right', right: 'Right',
|
|
119
|
+
home: 'Home', end: 'End', pageup: 'Prior', pagedown: 'Next',
|
|
120
|
+
insert: 'Insert', printscreen: 'Print',
|
|
121
|
+
win: 'Super_L', windows: 'Super_L', super: 'Super_L', cmd: 'Super_L', meta: 'Super_L',
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** "ctrl+shift+ArrowLeft" → "ctrl+shift+Left" (gramática do xdotool). */
|
|
125
|
+
function xdoKey(name) {
|
|
126
|
+
const { mods, key } = parseCombo(name);
|
|
127
|
+
const k = key.toLowerCase();
|
|
128
|
+
const traduzida = XKEYS[k] || (F_RE.test(k) ? k.toUpperCase() : key);
|
|
129
|
+
return [...mods, traduzida].join('+');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Windows SendKeys: ^=ctrl +=shift %=alt, especiais entre chaves.
|
|
133
|
+
const WINKEYS = {
|
|
134
|
+
enter: '{ENTER}', return: '{ENTER}', tab: '{TAB}', escape: '{ESC}', esc: '{ESC}',
|
|
135
|
+
backspace: '{BACKSPACE}', delete: '{DEL}', del: '{DEL}', space: ' ',
|
|
136
|
+
arrowup: '{UP}', up: '{UP}', arrowdown: '{DOWN}', down: '{DOWN}',
|
|
137
|
+
arrowleft: '{LEFT}', left: '{LEFT}', arrowright: '{RIGHT}', right: '{RIGHT}',
|
|
138
|
+
home: '{HOME}', end: '{END}', pageup: '{PGUP}', pagedown: '{PGDN}',
|
|
139
|
+
insert: '{INS}', printscreen: '{PRTSC}',
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
/** Texto literal para SendKeys: +^%~(){}[] têm significado — vão entre chaves. */
|
|
143
|
+
function escapeSendKeys(text) {
|
|
144
|
+
return String(text).replace(/[+^%~(){}[\]]/g, '{$&}');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* "ctrl+shift+t" → "^+t" (SendKeys). Devolve null quando o atalho usa a tecla
|
|
149
|
+
* Windows, que o SendKeys não alcança — o chamador explica a alternativa.
|
|
150
|
+
* "win" sozinho vira ^{ESC}, que abre o menu Iniciar desde sempre.
|
|
151
|
+
*/
|
|
152
|
+
function winSendKeys(name) {
|
|
153
|
+
const { mods, key } = parseCombo(name);
|
|
154
|
+
const k = key.toLowerCase();
|
|
155
|
+
if (!mods.length && (k === 'win' || k === 'windows' || k === 'super' || k === 'meta' || k === 'cmd')) return '^{ESC}';
|
|
156
|
+
if (mods.includes('super')) return null;
|
|
157
|
+
const prefix = mods.map((m) => (m === 'ctrl' ? '^' : m === 'shift' ? '+' : '%')).join('');
|
|
158
|
+
let token = WINKEYS[k] || (F_RE.test(k) ? `{${k.toUpperCase()}}` : escapeSendKeys(key.length === 1 ? key.toLowerCase() : key));
|
|
159
|
+
// Modificador se aplica à PRÓXIMA tecla; um token de várias teclas precisa
|
|
160
|
+
// de parênteses para o modificador cobrir todas.
|
|
161
|
+
if (prefix && token.length > 1 && !/^\{[^}]+\}$/.test(token)) token = `(${token})`;
|
|
162
|
+
return prefix + token;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// macOS: tecla especial é key code NUMÉRICO; letra é keystroke.
|
|
166
|
+
const MACCODES = {
|
|
167
|
+
enter: 36, return: 36, tab: 48, escape: 53, esc: 53, backspace: 51, delete: 117, del: 117,
|
|
168
|
+
space: 49, arrowleft: 123, left: 123, arrowright: 124, right: 124,
|
|
169
|
+
arrowdown: 125, down: 125, arrowup: 126, up: 126,
|
|
170
|
+
home: 115, end: 119, pageup: 116, pagedown: 121,
|
|
171
|
+
f1: 122, f2: 120, f3: 99, f4: 118, f5: 96, f6: 97, f7: 98, f8: 100, f9: 101, f10: 109, f11: 103, f12: 111,
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** A linha de System Events para uma tecla/atalho: `key code 36 using {...}`. */
|
|
175
|
+
function macKeyScript(name) {
|
|
176
|
+
const { mods, key } = parseCombo(name);
|
|
177
|
+
const using = mods.map((m) => (m === 'ctrl' ? 'control down' : m === 'shift' ? 'shift down' : m === 'alt' ? 'option down' : 'command down')).join(', ');
|
|
178
|
+
const sufixo = using ? ` using {${using}}` : '';
|
|
179
|
+
const k = key.toLowerCase();
|
|
180
|
+
if (MACCODES[k] != null) return `key code ${MACCODES[k]}${sufixo}`;
|
|
181
|
+
const safe = String(key).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
182
|
+
return `keystroke "${safe}"${sufixo}`;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function osascript(linhas, opts = {}) {
|
|
186
|
+
const script = Array.isArray(linhas) ? linhas.join('\n') : String(linhas);
|
|
187
|
+
return run(`osascript -e ${sh1(script)}`, opts);
|
|
188
|
+
}
|
|
189
|
+
function osascriptOut(linhas, opts = {}) {
|
|
190
|
+
const script = Array.isArray(linhas) ? linhas.join('\n') : String(linhas);
|
|
191
|
+
return runOut(`osascript -e ${sh1(script)}`, opts);
|
|
192
|
+
}
|
|
193
|
+
|
|
47
194
|
// ── Mouse ─────────────────────────────────────────────
|
|
48
195
|
function click(x, y, button = 'left') {
|
|
49
|
-
|
|
50
|
-
if (
|
|
196
|
+
const [xi, yi] = [coord(x), coord(y)];
|
|
197
|
+
if (xi == null || yi == null) return { ok: false, error: `Coordenada inválida: x=${x}, y=${y}` };
|
|
198
|
+
if (TOOLS.xdotool) return run(`xdotool mousemove ${xi} ${yi} click ${button === 'right' ? 3 : 1}`);
|
|
199
|
+
if (TOOLS.cliclick) return run(`cliclick ${button === 'right' ? 'rc' : 'c'}:${xi},${yi}`);
|
|
51
200
|
if (PLATFORM === 'win32') {
|
|
52
201
|
const down = button === 'right' ? '0x0008' : '0x0002';
|
|
53
202
|
const up = button === 'right' ? '0x0010' : '0x0004';
|
|
54
|
-
return
|
|
203
|
+
return pwshRun(`${WIN_USER32} [W.U]::SetCursorPos(${xi},${yi}); Start-Sleep -Milliseconds 40; [W.U]::mouse_event(${down},0,0,0,0); [W.U]::mouse_event(${up},0,0,0,0)`);
|
|
55
204
|
}
|
|
56
205
|
return { ok: false, error: 'Nenhuma ferramenta de automação encontrada. Instale xdotool (Linux) ou cliclick (macOS), ou use as tools browser_*.' };
|
|
57
206
|
}
|
|
58
207
|
|
|
208
|
+
// Clique duplo é o que ABRE as coisas fora do navegador: ícone da área de
|
|
209
|
+
// trabalho, arquivo no Explorer, célula de planilha. Sem ele o agente "clicava
|
|
210
|
+
// mas nada abria".
|
|
211
|
+
function doubleClick(x, y) {
|
|
212
|
+
const [xi, yi] = [coord(x), coord(y)];
|
|
213
|
+
if (xi == null || yi == null) return { ok: false, error: `Coordenada inválida: x=${x}, y=${y}` };
|
|
214
|
+
if (TOOLS.xdotool) return run(`xdotool mousemove ${xi} ${yi} click --repeat 2 --delay 120 1`);
|
|
215
|
+
if (TOOLS.cliclick) return run(`cliclick dc:${xi},${yi}`);
|
|
216
|
+
if (PLATFORM === 'win32') {
|
|
217
|
+
const clique = '[W.U]::mouse_event(0x0002,0,0,0,0); [W.U]::mouse_event(0x0004,0,0,0,0)';
|
|
218
|
+
return pwshRun(`${WIN_USER32} [W.U]::SetCursorPos(${xi},${yi}); Start-Sleep -Milliseconds 40; ${clique}; Start-Sleep -Milliseconds 90; ${clique}`);
|
|
219
|
+
}
|
|
220
|
+
return { ok: false, error: 'Clique duplo não disponível: instale xdotool (Linux) ou cliclick (macOS).' };
|
|
221
|
+
}
|
|
222
|
+
|
|
59
223
|
function move(x, y) {
|
|
60
|
-
|
|
61
|
-
if (
|
|
224
|
+
const [xi, yi] = [coord(x), coord(y)];
|
|
225
|
+
if (xi == null || yi == null) return { ok: false, error: `Coordenada inválida: x=${x}, y=${y}` };
|
|
226
|
+
if (TOOLS.xdotool) return run(`xdotool mousemove ${xi} ${yi}`);
|
|
227
|
+
if (TOOLS.cliclick) return run(`cliclick m:${xi},${yi}`);
|
|
62
228
|
if (PLATFORM === 'win32') {
|
|
63
|
-
return
|
|
229
|
+
return pwshRun(`${WIN_USER32} [W.U]::SetCursorPos(${xi},${yi})`);
|
|
64
230
|
}
|
|
65
231
|
return { ok: false, error: 'Automação não disponível' };
|
|
66
232
|
}
|
|
67
233
|
|
|
68
234
|
function drag(fromX, fromY, toX, toY) {
|
|
235
|
+
const [fx, fy, tx, ty] = [coord(fromX), coord(fromY), coord(toX), coord(toY)];
|
|
236
|
+
if ([fx, fy, tx, ty].some((v) => v == null)) return { ok: false, error: 'Coordenada inválida no drag' };
|
|
69
237
|
if (TOOLS.xdotool) {
|
|
70
|
-
return run(`xdotool mousemove ${
|
|
238
|
+
return run(`xdotool mousemove ${fx} ${fy} mousedown 1 mousemove ${tx} ${ty} mouseup 1`);
|
|
71
239
|
}
|
|
72
240
|
if (TOOLS.cliclick) {
|
|
73
|
-
return run(`cliclick dd:${
|
|
241
|
+
return run(`cliclick dd:${fx},${fy} du:${tx},${ty}`);
|
|
242
|
+
}
|
|
243
|
+
if (PLATFORM === 'win32') {
|
|
244
|
+
// Arrastar de verdade exige passos intermediários: soltar o botão no
|
|
245
|
+
// destino sem passar pelo caminho não conta como drag para o Windows.
|
|
246
|
+
const passos = [];
|
|
247
|
+
const N = 8;
|
|
248
|
+
for (let i = 1; i <= N; i++) {
|
|
249
|
+
const px = Math.round(fx + ((tx - fx) * i) / N);
|
|
250
|
+
const py = Math.round(fy + ((ty - fy) * i) / N);
|
|
251
|
+
passos.push(`[W.U]::SetCursorPos(${px},${py}); Start-Sleep -Milliseconds 25`);
|
|
252
|
+
}
|
|
253
|
+
return pwshRun(`${WIN_USER32} [W.U]::SetCursorPos(${fx},${fy}); Start-Sleep -Milliseconds 60; [W.U]::mouse_event(0x0002,0,0,0,0); Start-Sleep -Milliseconds 120; ${passos.join('; ')}; [W.U]::mouse_event(0x0004,0,0,0,0)`);
|
|
74
254
|
}
|
|
75
255
|
return { ok: false, error: 'Drag não suportado nesta plataforma sem xdotool/cliclick' };
|
|
76
256
|
}
|
|
77
257
|
|
|
78
258
|
function scroll(dy) {
|
|
79
|
-
|
|
80
|
-
|
|
259
|
+
const d = Number(dy);
|
|
260
|
+
const passo = Number.isFinite(d) ? d : 300;
|
|
261
|
+
if (TOOLS.xdotool) return run(`xdotool click --repeat ${Math.max(1, Math.abs(Math.round(passo / 50)))} ${passo > 0 ? 5 : 4}`);
|
|
262
|
+
if (TOOLS.cliclick) return run(`cliclick scroll:${Math.round(passo / 50)}`);
|
|
81
263
|
if (PLATFORM === 'win32') {
|
|
82
264
|
// Roda do mouse: 120 = um "clique". dy>0 rola para baixo (delta negativo).
|
|
83
|
-
const delta = -Math.round(
|
|
84
|
-
return
|
|
265
|
+
const delta = -Math.round(passo / 50) * 120;
|
|
266
|
+
return pwshRun(`${WIN_USER32} [W.U]::mouse_event(0x0800,0,0,${delta},0)`);
|
|
85
267
|
}
|
|
86
268
|
return { ok: false, error: 'Scroll não suportado nesta plataforma' };
|
|
87
269
|
}
|
|
88
270
|
|
|
89
271
|
// ── Teclado ───────────────────────────────────────────
|
|
90
272
|
function type(text) {
|
|
91
|
-
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
|
|
273
|
+
const t = String(text ?? '');
|
|
274
|
+
if (!t) return { ok: true, digitado: 0 };
|
|
275
|
+
if (TOOLS.xdotool) return run(`xdotool type --delay 12 -- ${sh1(t)}`);
|
|
276
|
+
if (PLATFORM === 'darwin') {
|
|
277
|
+
// Uma keystroke por linha; entre elas, Return de verdade (key code 36) —
|
|
278
|
+
// keystroke com "\n" no meio não quebra linha em app nenhum.
|
|
279
|
+
const linhas = t.split(/\r?\n/);
|
|
280
|
+
const corpo = [];
|
|
281
|
+
linhas.forEach((l, i) => {
|
|
282
|
+
if (i > 0) corpo.push('key code 36');
|
|
283
|
+
if (l) corpo.push(`keystroke "${l.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`);
|
|
284
|
+
});
|
|
285
|
+
return osascript(['tell application "System Events"', ...corpo, 'end tell']);
|
|
95
286
|
}
|
|
96
287
|
if (PLATFORM === 'win32') {
|
|
97
|
-
|
|
98
|
-
|
|
288
|
+
// SendKeys trata +^%~(){}[] como comando — escapar TUDO, senão digitar
|
|
289
|
+
// "olá (teste)" dispara atalho em vez de texto. Depois do escape, o Enter
|
|
290
|
+
// literal vira {ENTER}.
|
|
291
|
+
const seq = escapeSendKeys(t).replace(/\r?\n/g, '{ENTER}');
|
|
292
|
+
return pwshRun(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait($env:PRIMO_KEYS)`, { env: { PRIMO_KEYS: seq } });
|
|
99
293
|
}
|
|
100
294
|
return { ok: false, error: 'Automação de teclado não disponível' };
|
|
101
295
|
}
|
|
102
296
|
|
|
103
297
|
function key(name) {
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
Enter: 'Return', Return: 'Return', Tab: 'Tab', Escape: 'Escape', Esc: 'Escape',
|
|
107
|
-
Backspace: 'BackSpace', ArrowUp: 'Up', ArrowDown: 'Down', ArrowLeft: 'Left',
|
|
108
|
-
ArrowRight: 'Right', Space: 'space', Delete: 'Delete',
|
|
109
|
-
};
|
|
110
|
-
if (TOOLS.xdotool) return run(`xdotool key ${xmap[name] || name}`);
|
|
298
|
+
if (!name) return { ok: false, error: 'Diga a tecla: "Enter", "ctrl+c", "alt+tab"…' };
|
|
299
|
+
if (TOOLS.xdotool) return run(`xdotool key --clearmodifiers ${xdoKey(name)}`);
|
|
111
300
|
|
|
112
301
|
if (PLATFORM === 'darwin') {
|
|
113
|
-
|
|
114
|
-
const mac = { Enter: 36, Return: 36, Tab: 48, Escape: 53, Esc: 53, Backspace: 51, Delete: 51, Space: 49, ArrowLeft: 123, ArrowRight: 124, ArrowDown: 125, ArrowUp: 126 };
|
|
115
|
-
if (mac[name] != null) return run(`osascript -e 'tell application "System Events" to key code ${mac[name]}'`);
|
|
116
|
-
// Atalho com modificadores, ex "cmd+c": usa keystroke.
|
|
117
|
-
return run(`osascript -e 'tell application "System Events" to keystroke "${String(name).replace(/"/g, '\\"')}"'`);
|
|
302
|
+
return osascript(`tell application "System Events" to ${macKeyScript(name)}`);
|
|
118
303
|
}
|
|
119
304
|
if (PLATFORM === 'win32') {
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
305
|
+
const seq = winSendKeys(name);
|
|
306
|
+
if (seq === null) {
|
|
307
|
+
return { ok: false, error: 'Atalho com a tecla Windows não é alcançável por SendKeys. Alternativas: desktop_key("win") abre o menu Iniciar; desktop_open_app abre programas direto.' };
|
|
308
|
+
}
|
|
309
|
+
return pwshRun(`Add-Type -AssemblyName System.Windows.Forms; [System.Windows.Forms.SendKeys]::SendWait($env:PRIMO_KEYS)`, { env: { PRIMO_KEYS: seq } });
|
|
123
310
|
}
|
|
124
311
|
return { ok: false, error: 'Key não suportado nesta plataforma' };
|
|
125
312
|
}
|
|
126
313
|
|
|
314
|
+
// ── Programas e janelas ───────────────────────────────
|
|
315
|
+
// Abrir app o agente já conseguia pelo shell; o que faltava era o resto da
|
|
316
|
+
// conversa: saber QUAIS janelas existem, trazer uma para a frente e enxergar
|
|
317
|
+
// o que há dentro dela. É isso que transforma "abre mas não interage" em
|
|
318
|
+
// interação de verdade.
|
|
319
|
+
|
|
320
|
+
// Nomes em português do dia a dia → executável/app real. Pequena de propósito:
|
|
321
|
+
// só o que tem tradução óbvia. O resto passa direto como o usuário falou.
|
|
322
|
+
const APP_ALIASES = {
|
|
323
|
+
win32: {
|
|
324
|
+
'bloco de notas': 'notepad', 'notepad': 'notepad', 'calculadora': 'calc',
|
|
325
|
+
'paint': 'mspaint', 'explorador de arquivos': 'explorer', 'arquivos': 'explorer',
|
|
326
|
+
'explorer': 'explorer', 'prompt de comando': 'cmd', 'terminal': 'wt',
|
|
327
|
+
'gerenciador de tarefas': 'taskmgr', 'configuracoes': 'ms-settings:',
|
|
328
|
+
'painel de controle': 'control', 'edge': 'msedge', 'word': 'winword',
|
|
329
|
+
'excel': 'excel', 'powerpoint': 'powerpnt',
|
|
330
|
+
},
|
|
331
|
+
darwin: {
|
|
332
|
+
'calculadora': 'Calculator', 'bloco de notas': 'TextEdit', 'notas': 'Notes',
|
|
333
|
+
'arquivos': 'Finder', 'finder': 'Finder', 'terminal': 'Terminal',
|
|
334
|
+
'chrome': 'Google Chrome', 'safari': 'Safari', 'musica': 'Music',
|
|
335
|
+
'fotos': 'Photos', 'configuracoes': 'System Settings', 'ajustes': 'System Settings',
|
|
336
|
+
},
|
|
337
|
+
linux: {
|
|
338
|
+
'calculadora': 'gnome-calculator', 'arquivos': 'nautilus',
|
|
339
|
+
'bloco de notas': 'gedit', 'editor de texto': 'gedit',
|
|
340
|
+
'terminal': 'gnome-terminal', 'chrome': 'google-chrome',
|
|
341
|
+
},
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
function normaliza(s) {
|
|
345
|
+
return String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().trim();
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
function aliasDoApp(nome, platform = PLATFORM) {
|
|
349
|
+
const mapa = APP_ALIASES[platform] || {};
|
|
350
|
+
return mapa[normaliza(nome)] || String(nome || '').trim();
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function openApp(nome) {
|
|
354
|
+
const pedido = String(nome || '').trim();
|
|
355
|
+
if (!pedido) return { ok: false, error: 'Diga o nome do programa (ex: "chrome", "bloco de notas").' };
|
|
356
|
+
const app = aliasDoApp(pedido);
|
|
357
|
+
|
|
358
|
+
let r;
|
|
359
|
+
if (PLATFORM === 'win32') {
|
|
360
|
+
// Start-Process resolve PATH e App Paths (chrome, winword…); o start do
|
|
361
|
+
// cmd cobre o resto (URIs tipo ms-settings:, associações).
|
|
362
|
+
r = pwshOut(`$ErrorActionPreference='Stop'; $a=$env:PRIMO_APP; try { Start-Process $a; Write-Output 'OK' } catch { try { Start-Process -FilePath 'cmd.exe' -ArgumentList @('/c','start','',$a) -WindowStyle Hidden; Write-Output 'OK' } catch { Write-Output ('ERRO ' + $_.Exception.Message) } }`, { env: { PRIMO_APP: app } });
|
|
363
|
+
if (r.ok && !/^ERRO/m.test(r.out)) r = { ok: true };
|
|
364
|
+
else r = { ok: false, error: (r.out || r.error || '').trim() || `não consegui abrir "${app}"` };
|
|
365
|
+
} else if (PLATFORM === 'darwin') {
|
|
366
|
+
r = run(`open -a ${sh1(app)}`);
|
|
367
|
+
if (!r.ok && app !== pedido) r = run(`open -a ${sh1(pedido)}`);
|
|
368
|
+
if (!r.ok) r = { ok: false, error: `Não achei o app "${pedido}". Use o nome exato como aparece em /Applications.` };
|
|
369
|
+
} else {
|
|
370
|
+
const bin = app.split(/\s+/)[0];
|
|
371
|
+
if (hasCmd('gtk-launch') && run(`gtk-launch ${sh1(bin)}`).ok) r = { ok: true };
|
|
372
|
+
else if (hasCmd(bin)) r = run(`bash -c ${sh1(`setsid ${app} >/dev/null 2>&1 < /dev/null &`)}`);
|
|
373
|
+
else r = { ok: false, error: `Não achei o programa "${pedido}" no PATH.` };
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (!r.ok) return r;
|
|
377
|
+
return { ok: true, app, dica: 'A janela pode levar 1–2s para abrir. Confirme com desktop_windows e traga para a frente com desktop_focus.' };
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function listWindows() {
|
|
381
|
+
let linhas = [];
|
|
382
|
+
if (PLATFORM === 'win32') {
|
|
383
|
+
const r = pwshOut(`Get-Process | Where-Object { $_.MainWindowTitle } | ForEach-Object { $t = $_.MainWindowTitle -replace "[\\r\\n|]", " "; Write-Output ("$($_.ProcessName)|$t") }`, { timeout: 20000 });
|
|
384
|
+
if (!r.ok && !r.out) return { ok: false, error: r.error || 'não consegui listar as janelas' };
|
|
385
|
+
linhas = r.out.split('\n');
|
|
386
|
+
} else if (PLATFORM === 'darwin') {
|
|
387
|
+
const r = osascriptOut([
|
|
388
|
+
'set saida to ""',
|
|
389
|
+
'tell application "System Events"',
|
|
390
|
+
' repeat with p in (application processes whose visible is true)',
|
|
391
|
+
' repeat with w in windows of p',
|
|
392
|
+
' try',
|
|
393
|
+
' set saida to saida & (name of p) & "|" & (name of w) & linefeed',
|
|
394
|
+
' end try',
|
|
395
|
+
' end repeat',
|
|
396
|
+
' end repeat',
|
|
397
|
+
'end tell',
|
|
398
|
+
'return saida',
|
|
399
|
+
], { timeout: 20000 });
|
|
400
|
+
if (!r.ok) return { ok: false, error: 'Não consegui listar as janelas. Conceda permissão de Acessibilidade ao Terminal em Ajustes → Privacidade e Segurança.' };
|
|
401
|
+
linhas = r.out.split('\n');
|
|
402
|
+
} else {
|
|
403
|
+
if (hasCmd('wmctrl')) {
|
|
404
|
+
const r = runOut('wmctrl -l');
|
|
405
|
+
if (r.ok) linhas = r.out.split('\n').map((l) => {
|
|
406
|
+
const m = l.match(/^\S+\s+-?\d+\s+\S+\s+(.*)$/);
|
|
407
|
+
return m ? `|${m[1]}` : '';
|
|
408
|
+
});
|
|
409
|
+
} else if (TOOLS.xdotool) {
|
|
410
|
+
const r = runOut(`xdotool search --onlyvisible --name '.*' getwindowname %@ 2>/dev/null`);
|
|
411
|
+
if (r.ok) linhas = r.out.split('\n').map((t) => (t.trim() ? `|${t.trim()}` : ''));
|
|
412
|
+
} else {
|
|
413
|
+
return { ok: false, error: 'Instale wmctrl ou xdotool para listar janelas no Linux.' };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
const janelas = [];
|
|
418
|
+
for (const l of linhas) {
|
|
419
|
+
const linha = l.replace(/\r/g, '').trim();
|
|
420
|
+
if (!linha) continue;
|
|
421
|
+
const sep = linha.indexOf('|');
|
|
422
|
+
const app = sep >= 0 ? linha.slice(0, sep).trim() : '';
|
|
423
|
+
const titulo = (sep >= 0 ? linha.slice(sep + 1) : linha).trim();
|
|
424
|
+
if (titulo) janelas.push({ app, titulo });
|
|
425
|
+
}
|
|
426
|
+
return { ok: true, total: janelas.length, janelas: janelas.slice(0, 60) };
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function focusWindow(titulo) {
|
|
430
|
+
const t = String(titulo || '').trim();
|
|
431
|
+
if (!t) return { ok: false, error: 'Diga o título (ou parte dele) da janela.' };
|
|
432
|
+
|
|
433
|
+
if (PLATFORM === 'win32') {
|
|
434
|
+
const r = pwshOut(
|
|
435
|
+
`$ErrorActionPreference='SilentlyContinue'
|
|
436
|
+
Add-Type -Name W -Namespace P -MemberDefinition '[DllImport("user32.dll")] public static extern bool SetForegroundWindow(IntPtr h); [DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr h, int n); [DllImport("user32.dll")] public static extern bool IsIconic(IntPtr h);'
|
|
437
|
+
$t = $env:PRIMO_TITLE
|
|
438
|
+
$p = Get-Process | Where-Object { $_.MainWindowTitle -and ($_.MainWindowTitle -match [regex]::Escape($t) -or $_.ProcessName -match [regex]::Escape($t)) } | Select-Object -First 1
|
|
439
|
+
if (-not $p) { Write-Output 'NOTFOUND'; exit 0 }
|
|
440
|
+
if ([P.W]::IsIconic($p.MainWindowHandle)) { [P.W]::ShowWindow($p.MainWindowHandle, 9) | Out-Null }
|
|
441
|
+
[P.W]::SetForegroundWindow($p.MainWindowHandle) | Out-Null
|
|
442
|
+
Write-Output $p.MainWindowTitle`,
|
|
443
|
+
{ env: { PRIMO_TITLE: t }, timeout: 20000 },
|
|
444
|
+
);
|
|
445
|
+
const saida = (r.out || '').trim();
|
|
446
|
+
if (!r.ok && !saida) return { ok: false, error: r.error || 'falhou ao focar' };
|
|
447
|
+
if (/^NOTFOUND/m.test(saida)) return naoAchouJanela(t);
|
|
448
|
+
return { ok: true, janela: saida.split('\n')[0] };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (PLATFORM === 'darwin') {
|
|
452
|
+
const safe = t.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
453
|
+
const r = osascript([
|
|
454
|
+
'tell application "System Events"',
|
|
455
|
+
` set alvo to first application process whose visible is true and (name contains "${safe}" or displayed name contains "${safe}")`,
|
|
456
|
+
' set frontmost of alvo to true',
|
|
457
|
+
'end tell',
|
|
458
|
+
]);
|
|
459
|
+
if (!r.ok) return naoAchouJanela(t);
|
|
460
|
+
return { ok: true, janela: t };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (TOOLS.xdotool) {
|
|
464
|
+
const busca = runOut(`xdotool search --onlyvisible --name ${sh1(t)} 2>/dev/null`);
|
|
465
|
+
const id = (busca.out || '').trim().split('\n')[0];
|
|
466
|
+
if (!id) return naoAchouJanela(t);
|
|
467
|
+
const r = run(`xdotool windowactivate --sync ${id}`);
|
|
468
|
+
return r.ok ? { ok: true, janela: t } : r;
|
|
469
|
+
}
|
|
470
|
+
return { ok: false, error: 'Focar janela não disponível: instale xdotool (Linux).' };
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
// Erro que sabe o que EXISTE leva a informação junto — mesma ideia dos
|
|
474
|
+
// "trechosParecidos" do edit_file. Erro seco faz o modelo chutar de novo.
|
|
475
|
+
function naoAchouJanela(titulo) {
|
|
476
|
+
const w = listWindows();
|
|
477
|
+
return {
|
|
478
|
+
ok: false,
|
|
479
|
+
error: `Nenhuma janela combinou com "${titulo}".`,
|
|
480
|
+
janelasAbertas: w.ok ? w.janelas.map((j) => j.titulo).slice(0, 30) : undefined,
|
|
481
|
+
comoCorrigir: 'Use um título de "janelasAbertas", copiado literalmente (ou um pedaço único dele). Se o programa nem abriu, chame desktop_open_app antes.',
|
|
482
|
+
};
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ── Elementos da janela — os "olhos" do agente ────────────────────────────
|
|
486
|
+
// Os modelos do servidor são só-texto: mandar um PNG não adianta nada. O que
|
|
487
|
+
// funciona é ler a árvore de acessibilidade — a mesma que o leitor de tela usa
|
|
488
|
+
// — e devolver NOME + POSIÇÃO de cada botão, campo e ícone. Aí "clique em
|
|
489
|
+
// Salvar" vira coordenada exata, sem chute.
|
|
490
|
+
|
|
491
|
+
const MAX_ELEMENTOS = 120;
|
|
492
|
+
|
|
493
|
+
const PS_ELEMENTOS = `$ErrorActionPreference='SilentlyContinue'
|
|
494
|
+
Add-Type -AssemblyName UIAutomationClient
|
|
495
|
+
Add-Type -AssemblyName UIAutomationTypes
|
|
496
|
+
Add-Type -Name W -Namespace P -MemberDefinition '[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();'
|
|
497
|
+
$titulo = $env:PRIMO_TITLE
|
|
498
|
+
$root = [System.Windows.Automation.AutomationElement]::RootElement
|
|
499
|
+
$win = $null
|
|
500
|
+
if ($titulo) {
|
|
501
|
+
foreach ($w in $root.FindAll([System.Windows.Automation.TreeScope]::Children, [System.Windows.Automation.Condition]::TrueCondition)) {
|
|
502
|
+
if ($w.Current.Name -like ('*' + $titulo + '*')) { $win = $w; break }
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
if (-not $win) {
|
|
506
|
+
$h = [P.W]::GetForegroundWindow()
|
|
507
|
+
if ($h -ne [IntPtr]::Zero) { $win = [System.Windows.Automation.AutomationElement]::FromHandle($h) }
|
|
508
|
+
}
|
|
509
|
+
if (-not $win) { Write-Output 'SEMJANELA'; exit 0 }
|
|
510
|
+
Write-Output ('JANELA|' + ($win.Current.Name -replace "[\\r\\n|]", ' '))
|
|
511
|
+
$todos = $win.FindAll([System.Windows.Automation.TreeScope]::Descendants, [System.Windows.Automation.Condition]::TrueCondition)
|
|
512
|
+
$n = 0
|
|
513
|
+
foreach ($e in $todos) {
|
|
514
|
+
if ($n -ge ${MAX_ELEMENTOS}) { break }
|
|
515
|
+
$c = $e.Current
|
|
516
|
+
$nome = $c.Name
|
|
517
|
+
if (-not $nome) { continue }
|
|
518
|
+
if ($c.IsOffscreen) { continue }
|
|
519
|
+
$r = $c.BoundingRectangle
|
|
520
|
+
if ($r.Width -le 0 -or $r.Height -le 0) { continue }
|
|
521
|
+
$tipo = $c.ControlType.ProgrammaticName -replace 'ControlType\\.', ''
|
|
522
|
+
$cx = [int]($r.X + $r.Width / 2); $cy = [int]($r.Y + $r.Height / 2)
|
|
523
|
+
$nome = $nome -replace "[\\r\\n|]", ' '
|
|
524
|
+
Write-Output ("$tipo|$cx|$cy|$nome")
|
|
525
|
+
$n++
|
|
526
|
+
}`;
|
|
527
|
+
|
|
528
|
+
function uiElements(janela) {
|
|
529
|
+
const titulo = String(janela || '').trim();
|
|
530
|
+
|
|
531
|
+
if (PLATFORM === 'win32') {
|
|
532
|
+
const r = pwshOut(PS_ELEMENTOS, { env: { PRIMO_TITLE: titulo }, timeout: 45000 });
|
|
533
|
+
if (!r.ok && !r.out) return { ok: false, error: r.error || 'não consegui ler os elementos' };
|
|
534
|
+
if (/^SEMJANELA/m.test(r.out)) return { ok: false, error: 'Nenhuma janela em foco. Abra o programa (desktop_open_app) ou foque a janela (desktop_focus) primeiro.' };
|
|
535
|
+
return parseElementos(r.out);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (PLATFORM === 'darwin') {
|
|
539
|
+
// `entire contents` lê a janela toda de uma vez; elemento sem nome ou
|
|
540
|
+
// sem posição só é pulado (o try engole).
|
|
541
|
+
const r = osascriptOut([
|
|
542
|
+
'set saida to ""',
|
|
543
|
+
'set n to 0',
|
|
544
|
+
'tell application "System Events"',
|
|
545
|
+
' set p to first application process whose frontmost is true',
|
|
546
|
+
' set saida to "JANELA|" & (name of p) & linefeed',
|
|
547
|
+
' try',
|
|
548
|
+
' set els to entire contents of front window of p',
|
|
549
|
+
' repeat with e in els',
|
|
550
|
+
` if n ≥ ${MAX_ELEMENTOS} then exit repeat`,
|
|
551
|
+
' try',
|
|
552
|
+
' set nm to name of e',
|
|
553
|
+
' if nm is not missing value and nm is not "" then',
|
|
554
|
+
' set {ex, ey} to position of e',
|
|
555
|
+
' set {ew, eh} to size of e',
|
|
556
|
+
' set saida to saida & (role of e) & "|" & (ex + ew div 2) & "|" & (ey + eh div 2) & "|" & nm & linefeed',
|
|
557
|
+
' set n to n + 1',
|
|
558
|
+
' end if',
|
|
559
|
+
' end try',
|
|
560
|
+
' end repeat',
|
|
561
|
+
' end try',
|
|
562
|
+
'end tell',
|
|
563
|
+
'return saida',
|
|
564
|
+
], { timeout: 45000 });
|
|
565
|
+
if (!r.ok) return { ok: false, error: 'Não consegui ler a janela. Conceda permissão de Acessibilidade ao Terminal em Ajustes → Privacidade e Segurança → Acessibilidade.' };
|
|
566
|
+
return parseElementos(r.out);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return { ok: false, error: 'Leitura de elementos não disponível no Linux. Caminho alternativo: desktop_screenshot para o usuário ver, e desktop_click com coordenadas (xdotool).' };
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function parseElementos(saida) {
|
|
573
|
+
const elementos = [];
|
|
574
|
+
let janela = '';
|
|
575
|
+
const vistos = new Set();
|
|
576
|
+
for (const linha of String(saida || '').split('\n')) {
|
|
577
|
+
const l = linha.replace(/\r/g, '').trim();
|
|
578
|
+
if (!l) continue;
|
|
579
|
+
if (l.startsWith('JANELA|')) { janela = l.slice(7).trim(); continue; }
|
|
580
|
+
const partes = l.split('|');
|
|
581
|
+
if (partes.length < 4) continue;
|
|
582
|
+
const [tipo, xs, ys] = partes;
|
|
583
|
+
const nome = partes.slice(3).join('|').trim();
|
|
584
|
+
const x = Math.round(Number(xs)), y = Math.round(Number(ys));
|
|
585
|
+
if (!nome || !Number.isFinite(x) || !Number.isFinite(y)) continue;
|
|
586
|
+
const chave = `${tipo}|${x}|${y}|${nome}`;
|
|
587
|
+
if (vistos.has(chave)) continue;
|
|
588
|
+
vistos.add(chave);
|
|
589
|
+
elementos.push({ tipo: tipo.trim(), x, y, nome: nome.slice(0, 120) });
|
|
590
|
+
if (elementos.length >= MAX_ELEMENTOS) break;
|
|
591
|
+
}
|
|
592
|
+
if (!elementos.length) {
|
|
593
|
+
return { ok: false, error: 'A janela não expôs elementos nomeados (alguns apps escondem a árvore de acessibilidade). Use desktop_screenshot + desktop_click por coordenada.' , janela };
|
|
594
|
+
}
|
|
595
|
+
return { ok: true, janela, total: elementos.length, elementos };
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/**
|
|
599
|
+
* Melhor elemento para um nome pedido — sem acento, sem caixa, aceitando
|
|
600
|
+
* pedaço ("salvar" acha "Salvar como…"). Pura, para o teste bater sem SO.
|
|
601
|
+
*/
|
|
602
|
+
function escolherElemento(elementos, consulta) {
|
|
603
|
+
const q = normaliza(consulta);
|
|
604
|
+
if (!q) return null;
|
|
605
|
+
let melhor = null;
|
|
606
|
+
let nota = 0;
|
|
607
|
+
for (const e of elementos || []) {
|
|
608
|
+
const n = normaliza(e.nome);
|
|
609
|
+
if (!n) continue;
|
|
610
|
+
let s = 0;
|
|
611
|
+
// O nome que ESTENDE a consulta ("salvar como" → "Salvar como…") vale
|
|
612
|
+
// mais que a consulta estender o nome: quem pediu a frase maior quis o
|
|
613
|
+
// elemento maior, não o irmão de nome curto.
|
|
614
|
+
if (n === q) s = 100;
|
|
615
|
+
else if (n.startsWith(q)) s = 85;
|
|
616
|
+
else if (q.startsWith(n)) s = 75;
|
|
617
|
+
else if (n.includes(q)) s = 65;
|
|
618
|
+
else if (q.includes(n)) s = 55;
|
|
619
|
+
else {
|
|
620
|
+
const termos = q.split(/\s+/).filter((t) => t.length > 1);
|
|
621
|
+
const acertos = termos.filter((t) => n.includes(t)).length;
|
|
622
|
+
if (acertos) s = Math.round((40 * acertos) / termos.length);
|
|
623
|
+
}
|
|
624
|
+
if (!s) continue;
|
|
625
|
+
// Empate: nome mais curto costuma ser o botão em si, não a frase que o contém.
|
|
626
|
+
if (s > nota || (s === nota && melhor && e.nome.length < melhor.nome.length)) { nota = s; melhor = e; }
|
|
627
|
+
}
|
|
628
|
+
return nota >= 40 ? melhor : null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function clickElement(nome, opts = {}) {
|
|
632
|
+
const pedido = String(nome || '').trim();
|
|
633
|
+
if (!pedido) return { ok: false, error: 'Diga o nome do elemento (o texto do botão, ícone ou campo).' };
|
|
634
|
+
|
|
635
|
+
const lidos = uiElements(opts.window);
|
|
636
|
+
if (!lidos.ok) return lidos;
|
|
637
|
+
|
|
638
|
+
const alvo = escolherElemento(lidos.elementos, pedido);
|
|
639
|
+
if (!alvo) {
|
|
640
|
+
return {
|
|
641
|
+
ok: false,
|
|
642
|
+
error: `Nenhum elemento parecido com "${pedido}" na janela "${lidos.janela}".`,
|
|
643
|
+
elementosDisponiveis: lidos.elementos.slice(0, 40).map((e) => e.nome),
|
|
644
|
+
comoCorrigir: 'Chame de novo com um nome de "elementosDisponiveis", copiado literalmente.',
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const r = opts.double ? doubleClick(alvo.x, alvo.y) : click(alvo.x, alvo.y, opts.button || 'left');
|
|
649
|
+
if (!r.ok) return r;
|
|
650
|
+
return { ok: true, clicado: alvo.nome, tipo: alvo.tipo, x: alvo.x, y: alvo.y, duplo: !!opts.double };
|
|
651
|
+
}
|
|
652
|
+
|
|
127
653
|
// ── Screenshot ────────────────────────────────────────
|
|
128
654
|
function screenshot(saveTo) {
|
|
129
655
|
const filename = saveTo || `desktop-${Date.now()}.png`;
|
|
@@ -133,8 +659,8 @@ function screenshot(saveTo) {
|
|
|
133
659
|
return done(run(`screencapture -x "${filename}"`));
|
|
134
660
|
}
|
|
135
661
|
if (PLATFORM === 'win32') {
|
|
136
|
-
const ps = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object System.Drawing.Bitmap($b.Width,$b.Height); $g=[System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$bmp.Size); $bmp.Save('${filename}'); $g.Dispose(); $bmp.Dispose()`;
|
|
137
|
-
return done(
|
|
662
|
+
const ps = `Add-Type -AssemblyName System.Drawing; Add-Type -AssemblyName System.Windows.Forms; $b=[System.Windows.Forms.Screen]::PrimaryScreen.Bounds; $bmp=New-Object System.Drawing.Bitmap($b.Width,$b.Height); $g=[System.Drawing.Graphics]::FromImage($bmp); $g.CopyFromScreen($b.Location,[System.Drawing.Point]::Empty,$bmp.Size); $bmp.Save('${filename.replace(/'/g, "''")}'); $g.Dispose(); $bmp.Dispose()`;
|
|
663
|
+
return done(pwshRun(ps));
|
|
138
664
|
}
|
|
139
665
|
// Linux: tenta as ferramentas comuns, na ordem.
|
|
140
666
|
const scrot = firstCmd(['scrot', 'gnome-screenshot', 'spectacle', 'import', 'maim']);
|
|
@@ -182,8 +708,13 @@ function getScreenSize() {
|
|
|
182
708
|
}
|
|
183
709
|
|
|
184
710
|
module.exports = {
|
|
185
|
-
click, move, drag, scroll,
|
|
711
|
+
click, doubleClick, move, drag, scroll,
|
|
186
712
|
type, key,
|
|
713
|
+
openApp, listWindows, focusWindow, uiElements, clickElement,
|
|
187
714
|
screenshot, getScreenSize,
|
|
188
715
|
TOOLS,
|
|
716
|
+
// Puras, exportadas para o test/desktop.test.js provar a tradução de teclas
|
|
717
|
+
// e a escolha de elemento sem depender do SO da máquina de teste.
|
|
718
|
+
parseCombo, xdoKey, winSendKeys, macKeyScript, escapeSendKeys,
|
|
719
|
+
escolherElemento, aliasDoApp,
|
|
189
720
|
};
|
package/lib/modo.js
CHANGED
|
@@ -30,12 +30,16 @@ const VERBOS = [
|
|
|
30
30
|
'implement[ae]r?', 'l[eê]i?a?r?', 'procur[ae]r?', 'busc[ae]r?', 'encontr[ae]r?',
|
|
31
31
|
'renome[ai]r?', 'copi[ae]r?', 'commit[ae]r?', 'deploy',
|
|
32
32
|
'sobe', 'suba', 'subir', 'empurr[ae]r?', 'mergea?r?',
|
|
33
|
+
// Interação com o computador: "clica no botão", "digita a senha",
|
|
34
|
+
// "pesquisa no google", "aperta enter", "arrasta o arquivo".
|
|
35
|
+
'clic[ao]r?', 'clique', 'digit[ae]r?', 'pesquis[ae]r?', 'apert[ae]r?',
|
|
36
|
+
'arrast[ae]r?', 'rol[ae]r?', 'minimiz[ae]r?', 'maximiz[ae]r?', 'fech[ae]r?',
|
|
33
37
|
];
|
|
34
38
|
const RE_VERBO = new RegExp(`(^|\\s)(${VERBOS.join('|')})\\b`, 'i');
|
|
35
39
|
|
|
36
40
|
// Coisas que só existem quando há trabalho de arquivo/máquina envolvido.
|
|
37
41
|
const RE_ARTEFATO = /\.(js|ts|tsx|jsx|html|css|json|md|py|sh|yml|yaml|txt|csv|env)\b|[\w-]+\/[\w-]/i;
|
|
38
|
-
const RE_FERRAMENTA = /\b(navegador|browser|desktop|terminal|shell|git|npm|node|deploy|servidor|localhost|porta \d+|screenshot|print da tela)\b/i;
|
|
42
|
+
const RE_FERRAMENTA = /\b(navegador|browser|desktop|terminal|shell|git|npm|node|deploy|servidor|localhost|porta \d+|screenshot|print da tela|mouse|teclado|janela|[áa]rea de trabalho|aplicativo|[íi]cone)\b/i;
|
|
39
43
|
const RE_PASTA = /\b(pasta|diret[óo]rio|arquivo|arquivos|reposit[óo]rio|repo|projeto)\b/i;
|
|
40
44
|
|
|
41
45
|
// Conversa: cumprimento, agradecimento, pergunta conceitual.
|
package/lib/streaming.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* streaming.js — o painel "ao vivo": ver o agente mexendo no computador.
|
|
3
|
+
*
|
|
4
|
+
* O agente age no desktop do usuário (clica, digita, abre janela), mas rápido
|
|
5
|
+
* demais para acompanhar a olho nu — e às vezes numa janela que não está na
|
|
6
|
+
* frente. O /streaming liga um servidorzinho HTTP local que mostra, no
|
|
7
|
+
* navegador, cada ação em ordem + uma foto da tela tirada logo depois dela.
|
|
8
|
+
* Você dá a instrução no terminal e VÊ o agente fazendo, passo a passo.
|
|
9
|
+
*
|
|
10
|
+
* Zero dependências: http nativo, uma página HTML embutida que consulta
|
|
11
|
+
* /state a cada 700ms. Só escuta em 127.0.0.1 — nada disso sai da máquina.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
'use strict';
|
|
15
|
+
|
|
16
|
+
const http = require('http');
|
|
17
|
+
const fs = require('fs');
|
|
18
|
+
const path = require('path');
|
|
19
|
+
const os = require('os');
|
|
20
|
+
const desktop = require('./desktop');
|
|
21
|
+
|
|
22
|
+
const DIR = path.join(os.homedir(), '.primocode', 'streaming');
|
|
23
|
+
const FRAME = path.join(DIR, 'frame.png');
|
|
24
|
+
const MAX_LOG = 200;
|
|
25
|
+
// Uma foto por ação no máximo a cada 600ms: digitar letra por letra não pode
|
|
26
|
+
// virar uma rajada de screenshots.
|
|
27
|
+
const INTERVALO_FOTO_MS = 600;
|
|
28
|
+
|
|
29
|
+
const state = {
|
|
30
|
+
on: false,
|
|
31
|
+
server: null,
|
|
32
|
+
url: '',
|
|
33
|
+
frame: 0, // contador: a página troca a imagem quando ele muda
|
|
34
|
+
log: [],
|
|
35
|
+
erroFoto: null,
|
|
36
|
+
ultimaFoto: 0,
|
|
37
|
+
passo: 0,
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// Ações cujo efeito aparece NA TELA — são as que merecem uma foto nova.
|
|
41
|
+
const ACOES_NA_TELA = new Set([
|
|
42
|
+
'desktop_click', 'desktop_double_click', 'desktop_click_element',
|
|
43
|
+
'desktop_move', 'desktop_drag', 'desktop_type', 'desktop_key',
|
|
44
|
+
'desktop_scroll', 'desktop_open_app', 'desktop_focus', 'desktop_screenshot',
|
|
45
|
+
'browser_open', 'browser_click', 'browser_type', 'browser_key',
|
|
46
|
+
'browser_scroll',
|
|
47
|
+
]);
|
|
48
|
+
|
|
49
|
+
function isOn() { return state.on; }
|
|
50
|
+
function url() { return state.url; }
|
|
51
|
+
|
|
52
|
+
const corta = (s, n) => { const t = String(s ?? ''); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
53
|
+
|
|
54
|
+
// A frase humana de cada ação — é o que aparece na linha do tempo.
|
|
55
|
+
function descrever(name, args = {}) {
|
|
56
|
+
switch (name) {
|
|
57
|
+
case 'desktop_click': return `clique em (${args.x}, ${args.y})`;
|
|
58
|
+
case 'desktop_double_click': return `clique duplo em (${args.x}, ${args.y})`;
|
|
59
|
+
case 'desktop_click_element': return `clique em “${corta(args.name, 40)}”${args.double ? ' (duplo)' : ''}`;
|
|
60
|
+
case 'desktop_move': return `mouse para (${args.x}, ${args.y})`;
|
|
61
|
+
case 'desktop_drag': return `arrastou (${args.from_x}, ${args.from_y}) → (${args.to_x}, ${args.to_y})`;
|
|
62
|
+
case 'desktop_type': return `digitou “${corta(args.text, 40)}”`;
|
|
63
|
+
case 'desktop_key': return `tecla ${args.key}`;
|
|
64
|
+
case 'desktop_scroll': return `rolou a tela (${args.dy ?? 300})`;
|
|
65
|
+
case 'desktop_open_app': return `abriu ${corta(args.app || args.name, 40)}`;
|
|
66
|
+
case 'desktop_focus': return `focou a janela “${corta(args.window || args.title, 40)}”`;
|
|
67
|
+
case 'desktop_windows': return 'listou as janelas abertas';
|
|
68
|
+
case 'desktop_elements': return `leu os elementos${args.window ? ` de “${corta(args.window, 30)}”` : ' da janela em foco'}`;
|
|
69
|
+
case 'desktop_screenshot': return 'tirou um print da tela';
|
|
70
|
+
case 'desktop_size': return 'mediu a tela';
|
|
71
|
+
case 'browser_open': return `abriu ${corta(args.url, 60)} no navegador`;
|
|
72
|
+
case 'browser_click': return `clicou em “${corta(args.text || args.selector || `${args.x},${args.y}`, 40)}” na página`;
|
|
73
|
+
case 'browser_type': return `digitou “${corta(args.text, 40)}” na página`;
|
|
74
|
+
case 'browser_key': return `tecla ${args.key} na página`;
|
|
75
|
+
case 'browser_scroll': return 'rolou a página';
|
|
76
|
+
case 'browser_read': return 'leu a página';
|
|
77
|
+
case 'browser_screenshot': return 'print da página';
|
|
78
|
+
case 'browser_wait': return `esperou ${corta(args.selector, 40)}`;
|
|
79
|
+
case 'browser_eval': return 'rodou JavaScript na página';
|
|
80
|
+
case 'write_file': return `criou/reescreveu ${corta(args.path, 50)}`;
|
|
81
|
+
case 'edit_file': return `editou ${corta(args.path, 50)}`;
|
|
82
|
+
case 'multi_edit': return `editou ${corta(args.path, 50)} (várias trocas)`;
|
|
83
|
+
case 'read_file': return `leu ${corta(args.path, 50)}`;
|
|
84
|
+
case 'run_shell': return `rodou: ${corta(args.command, 60)}`;
|
|
85
|
+
case 'run_in_new_terminal': return `terminal novo: ${corta(args.command, 50)}`;
|
|
86
|
+
case 'write_todos': return 'atualizou o plano';
|
|
87
|
+
default: return name.replace(/_/g, ' ');
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Chamada pelo loop do agente depois de CADA tool. Quando o streaming está
|
|
93
|
+
* desligado é um no-op — o caminho normal não paga nada por isto.
|
|
94
|
+
*/
|
|
95
|
+
function step(name, args, result) {
|
|
96
|
+
if (!state.on) return;
|
|
97
|
+
|
|
98
|
+
state.passo++;
|
|
99
|
+
state.log.push({
|
|
100
|
+
n: state.passo,
|
|
101
|
+
hora: new Date().toTimeString().slice(0, 8),
|
|
102
|
+
acao: descrever(name, args || {}),
|
|
103
|
+
ok: !(result && result.ok === false),
|
|
104
|
+
erro: result && result.ok === false ? corta(result.error, 120) : undefined,
|
|
105
|
+
});
|
|
106
|
+
if (state.log.length > MAX_LOG) state.log.splice(0, state.log.length - MAX_LOG);
|
|
107
|
+
|
|
108
|
+
if (ACOES_NA_TELA.has(name)) capturar();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function capturar() {
|
|
112
|
+
const agora = Date.now();
|
|
113
|
+
if (agora - state.ultimaFoto < INTERVALO_FOTO_MS) return;
|
|
114
|
+
state.ultimaFoto = agora;
|
|
115
|
+
const r = desktop.screenshot(FRAME);
|
|
116
|
+
if (r.ok) { state.frame++; state.erroFoto = null; }
|
|
117
|
+
else if (!state.erroFoto) state.erroFoto = r.error;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ── Página ────────────────────────────────────────────────────────────────
|
|
121
|
+
const PAGINA = `<!doctype html>
|
|
122
|
+
<html lang="pt-BR"><head><meta charset="utf-8">
|
|
123
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
124
|
+
<title>PrimoCode · Ao vivo</title>
|
|
125
|
+
<style>
|
|
126
|
+
* { box-sizing: border-box; margin: 0; }
|
|
127
|
+
body { background: #0b1220; color: #e2e8f0; font: 14px/1.5 ui-monospace, 'Cascadia Code', Consolas, monospace; height: 100vh; display: flex; flex-direction: column; }
|
|
128
|
+
header { display: flex; align-items: center; gap: 10px; padding: 12px 18px; border-bottom: 1px solid #1e293b; }
|
|
129
|
+
.dot { width: 9px; height: 9px; border-radius: 50%; background: #22d3ee; animation: pulsa 1.4s infinite; }
|
|
130
|
+
@keyframes pulsa { 50% { opacity: .25 } }
|
|
131
|
+
header b { color: #22d3ee; font-weight: 600; }
|
|
132
|
+
header span { color: #64748b; font-size: 12px; margin-left: auto; }
|
|
133
|
+
main { flex: 1; display: flex; min-height: 0; }
|
|
134
|
+
#tela { flex: 1; display: flex; align-items: center; justify-content: center; padding: 14px; min-width: 0; background: #0f172a; }
|
|
135
|
+
#tela img { max-width: 100%; max-height: 100%; border: 1px solid #1e293b; border-radius: 8px; display: none; }
|
|
136
|
+
#aviso { color: #64748b; text-align: center; max-width: 46ch; }
|
|
137
|
+
aside { width: 360px; max-width: 45%; border-left: 1px solid #1e293b; overflow-y: auto; padding: 10px 0; }
|
|
138
|
+
.item { padding: 6px 14px; display: flex; gap: 8px; border-left: 2px solid transparent; }
|
|
139
|
+
.item.novo { border-left-color: #22d3ee; background: #0f172a; }
|
|
140
|
+
.item .n { color: #475569; min-width: 3ch; text-align: right; }
|
|
141
|
+
.item .ok { color: #22d3ee; }
|
|
142
|
+
.item .falha { color: #fb7185; }
|
|
143
|
+
.item .txt { flex: 1; word-break: break-word; }
|
|
144
|
+
.item .hora { color: #475569; font-size: 11px; }
|
|
145
|
+
.item .erro { color: #fb7185; font-size: 12px; display: block; }
|
|
146
|
+
</style></head>
|
|
147
|
+
<body>
|
|
148
|
+
<header><span class="dot"></span><b>PrimoCode</b> · Ao vivo <span id="st">conectando…</span></header>
|
|
149
|
+
<main>
|
|
150
|
+
<section id="tela">
|
|
151
|
+
<img id="frame" alt="tela do computador">
|
|
152
|
+
<div id="aviso">Aguardando a primeira ação na tela…<br><br>Dê uma instrução no terminal do PrimoCode e acompanhe cada passo aqui.</div>
|
|
153
|
+
</section>
|
|
154
|
+
<aside id="log"></aside>
|
|
155
|
+
</main>
|
|
156
|
+
<script>
|
|
157
|
+
let ultimoFrame = -1, ultimoPasso = -1;
|
|
158
|
+
async function tick() {
|
|
159
|
+
try {
|
|
160
|
+
const s = await (await fetch('/state')).json();
|
|
161
|
+
document.getElementById('st').textContent = 'ao vivo · porta ' + location.port;
|
|
162
|
+
if (s.frame !== ultimoFrame && s.frame > 0) {
|
|
163
|
+
ultimoFrame = s.frame;
|
|
164
|
+
const img = document.getElementById('frame');
|
|
165
|
+
img.src = '/frame?' + s.frame;
|
|
166
|
+
img.style.display = 'block';
|
|
167
|
+
document.getElementById('aviso').style.display = 'none';
|
|
168
|
+
}
|
|
169
|
+
if (s.erroFoto) {
|
|
170
|
+
document.getElementById('aviso').textContent = 'Sem foto da tela: ' + s.erroFoto;
|
|
171
|
+
}
|
|
172
|
+
const fim = s.log.length ? s.log[s.log.length - 1].n : -1;
|
|
173
|
+
if (fim !== ultimoPasso) {
|
|
174
|
+
ultimoPasso = fim;
|
|
175
|
+
const el = document.getElementById('log');
|
|
176
|
+
el.innerHTML = s.log.slice().reverse().map((e, i) =>
|
|
177
|
+
'<div class="item' + (i === 0 ? ' novo' : '') + '">'
|
|
178
|
+
+ '<span class="n">' + e.n + '</span>'
|
|
179
|
+
+ '<span class="' + (e.ok ? 'ok' : 'falha') + '">' + (e.ok ? '✔' : '✗') + '</span>'
|
|
180
|
+
+ '<span class="txt">' + esc(e.acao)
|
|
181
|
+
+ (e.erro ? '<span class="erro">' + esc(e.erro) + '</span>' : '')
|
|
182
|
+
+ '</span><span class="hora">' + e.hora + '</span></div>'
|
|
183
|
+
).join('');
|
|
184
|
+
}
|
|
185
|
+
} catch (e) {
|
|
186
|
+
document.getElementById('st').textContent = 'reconectando…';
|
|
187
|
+
}
|
|
188
|
+
setTimeout(tick, 700);
|
|
189
|
+
}
|
|
190
|
+
function esc(s) { return String(s ?? '').replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); }
|
|
191
|
+
tick();
|
|
192
|
+
</script>
|
|
193
|
+
</body></html>`;
|
|
194
|
+
|
|
195
|
+
function start() {
|
|
196
|
+
if (state.on) return Promise.resolve(state.url);
|
|
197
|
+
try { fs.mkdirSync(DIR, { recursive: true }); } catch {}
|
|
198
|
+
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
const server = http.createServer((req, res) => {
|
|
201
|
+
const rota = (req.url || '/').split('?')[0];
|
|
202
|
+
if (rota === '/state') {
|
|
203
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
|
|
204
|
+
res.end(JSON.stringify({ frame: state.frame, erroFoto: state.erroFoto, log: state.log.slice(-60) }));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
if (rota === '/frame') {
|
|
208
|
+
fs.readFile(FRAME, (err, buf) => {
|
|
209
|
+
if (err) { res.writeHead(404); res.end(); return; }
|
|
210
|
+
res.writeHead(200, { 'Content-Type': 'image/png', 'Cache-Control': 'no-store' });
|
|
211
|
+
res.end(buf);
|
|
212
|
+
});
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
216
|
+
res.end(PAGINA);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
server.on('error', reject);
|
|
220
|
+
// unref: o painel não pode segurar o processo vivo — /exit tem de sair
|
|
221
|
+
// mesmo com o streaming ligado.
|
|
222
|
+
server.on('connection', (s) => s.unref());
|
|
223
|
+
// Só na própria máquina: isto é a tela do usuário, não um serviço.
|
|
224
|
+
server.listen(0, '127.0.0.1', () => {
|
|
225
|
+
server.unref();
|
|
226
|
+
state.server = server;
|
|
227
|
+
state.on = true;
|
|
228
|
+
state.url = `http://127.0.0.1:${server.address().port}`;
|
|
229
|
+
resolve(state.url);
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function stop() {
|
|
235
|
+
if (state.server) { try { state.server.close(); } catch {} }
|
|
236
|
+
state.server = null;
|
|
237
|
+
state.on = false;
|
|
238
|
+
state.url = '';
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = { start, stop, step, isOn, url, descrever };
|
package/lib/tools.js
CHANGED
|
@@ -436,6 +436,22 @@ async function despachar(name, args, ctx) {
|
|
|
436
436
|
return desktop.scroll(args.dy != null ? args.dy : 300);
|
|
437
437
|
case 'desktop_size':
|
|
438
438
|
return desktop.getScreenSize();
|
|
439
|
+
case 'desktop_double_click':
|
|
440
|
+
return desktop.doubleClick(args.x, args.y);
|
|
441
|
+
case 'desktop_open_app':
|
|
442
|
+
return desktop.openApp(args.app || args.name || '');
|
|
443
|
+
case 'desktop_windows':
|
|
444
|
+
return desktop.listWindows();
|
|
445
|
+
case 'desktop_focus':
|
|
446
|
+
return desktop.focusWindow(args.window || args.title || '');
|
|
447
|
+
case 'desktop_elements':
|
|
448
|
+
return desktop.uiElements(args.window);
|
|
449
|
+
case 'desktop_click_element':
|
|
450
|
+
return desktop.clickElement(args.name || args.text || '', {
|
|
451
|
+
window: args.window,
|
|
452
|
+
double: !!args.double,
|
|
453
|
+
button: args.button,
|
|
454
|
+
});
|
|
439
455
|
|
|
440
456
|
default:
|
|
441
457
|
return { ok: false, error: `Tool não implementada: ${name}` };
|
package/lib/ui.js
CHANGED
|
@@ -161,9 +161,9 @@ const TIPS = [
|
|
|
161
161
|
];
|
|
162
162
|
|
|
163
163
|
const NEWS = [
|
|
164
|
+
'Agora ele INTERAGE com o computador: janelas, botões e ícones pelo nome',
|
|
165
|
+
'/streaming abre um painel ao vivo — veja cada passo dele na tela',
|
|
164
166
|
'Dois provedores em corrente: acabou a cota de um, o outro atende',
|
|
165
|
-
'Ele trabalha em ~/primocode. Fale o nome de uma pasta e ele muda sozinho',
|
|
166
|
-
'Falha de navegador agora diz quais seletores EXISTEM na página',
|
|
167
167
|
];
|
|
168
168
|
|
|
169
169
|
// ── Cabeçalho ────────────────────────────────────────────────────────────
|
|
@@ -309,9 +309,12 @@ function actionHeader(name, args) {
|
|
|
309
309
|
desktop_screenshot:'Desktop·shot', desktop_click:'Desktop·click', desktop_move:'Desktop·move',
|
|
310
310
|
desktop_drag:'Desktop·drag', desktop_type:'Desktop·type', desktop_key:'Desktop·key',
|
|
311
311
|
desktop_scroll:'Desktop·scroll', desktop_size:'Desktop·size',
|
|
312
|
+
desktop_double_click:'Desktop·2click', desktop_open_app:'Desktop·app',
|
|
313
|
+
desktop_windows:'Desktop·janelas', desktop_focus:'Desktop·foco',
|
|
314
|
+
desktop_elements:'Desktop·elementos', desktop_click_element:'Desktop·click',
|
|
312
315
|
};
|
|
313
316
|
const label = nice[name] || name;
|
|
314
|
-
const argStr = args ? (args.path || args.pattern || args.command || args.target || args.url || args.selector || args.text || args.description || (args.todos ? `${args.todos.length} tarefas` : '') || (args.x != null ? `${args.x},${args.y}` : '') || args.key || JSON.stringify(args).slice(0,60)) : '';
|
|
317
|
+
const argStr = args ? (args.path || args.pattern || args.command || args.target || args.url || args.selector || args.text || args.app || args.window || args.name || args.description || (args.todos ? `${args.todos.length} tarefas` : '') || (args.x != null ? `${args.x},${args.y}` : '') || args.key || JSON.stringify(args).slice(0,60)) : '';
|
|
315
318
|
return ` ${c.dim('⎿')} ${c.brand(label)}${argStr ? ' '+c.muted(argStr) : ''}`;
|
|
316
319
|
}
|
|
317
320
|
|
|
@@ -381,6 +384,16 @@ function friendlyError(raw) {
|
|
|
381
384
|
// corrente de reserva também não — o limite é da CONTA, não do modelo, então
|
|
382
385
|
// todos os elos recusam junto. Medido: 429 idêntico nos três modelos testados.
|
|
383
386
|
if (/free-models-per-day|free model requests per day/i.test(low)) {
|
|
387
|
+
// O servidor avisa quando existe provedor de reserva DESLIGADO por falta de
|
|
388
|
+
// chave. Esse detalhe é a ação que resolve — descartá-lo para mostrar um
|
|
389
|
+
// texto genérico faria o usuário achar que não havia nada a fazer.
|
|
390
|
+
const semChave = s.match(/provedor de reserva \(([^)]+)\) está sem chave/i);
|
|
391
|
+
if (semChave) {
|
|
392
|
+
return `📅 A cota diária de grátis do OpenRouter acabou (são 50 por dia na conta sem crédito),`
|
|
393
|
+
+ ` e o provedor de reserva (${semChave[1]}) está sem chave no servidor — não houve para onde cair.`
|
|
394
|
+
+ ` Configure a chave dele no Render, ou adicione 10 dólares de crédito no OpenRouter`
|
|
395
|
+
+ ` para subir de 50 para 1.000 pedidos por dia (esse crédito não é gasto pelos modelos grátis).`;
|
|
396
|
+
}
|
|
384
397
|
return '📅 A cota diária de grátis acabou nos DOIS provedores (OpenRouter e Groq).'
|
|
385
398
|
+ ' Se chegou até aqui, o servidor já tentou os dez modelos da corrente.'
|
|
386
399
|
+ ' Trocar de modelo não resolve: o limite é da conta, não do modelo.'
|
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "7.
|
|
4
|
-
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop.",
|
|
3
|
+
"version": "7.11.0",
|
|
4
|
+
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. 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": {
|
|
7
7
|
"primocode": "bin/primocode.js"
|
|
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/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js"
|
|
11
|
+
"test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|