primocode 7.8.0 → 7.9.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/lib/act.js +10 -1
- package/lib/browser.js +177 -13
- package/lib/tools.js +31 -3
- package/lib/ui.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v7.
|
|
1
|
+
# PrimoCode v7.9.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 v7.
|
|
12
|
+
▐▛███▜▌ Primo Code v7.9.0
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/lib/act.js
CHANGED
|
@@ -271,7 +271,16 @@ async function runAct(userPrompt, opts) {
|
|
|
271
271
|
}
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
-
|
|
274
|
+
// Nunca terminar em silêncio. Quando o modelo devolvia resposta vazia sem
|
|
275
|
+
// chamar tool, o usuário via a tela parar e mais nada — pior que erro, porque
|
|
276
|
+
// não dá nem para saber que acabou. Medido: um pedido de navegador morria assim
|
|
277
|
+
// depois de a primeira tentativa de seletor falhar.
|
|
278
|
+
if (!finalText) {
|
|
279
|
+
finalText = falhasPendentes.length
|
|
280
|
+
? `Parei sem concluir. A última falha foi: ${falhasPendentes[falhasPendentes.length - 1]}`
|
|
281
|
+
: 'Parei sem concluir e sem explicação do modelo. Tente pedir de novo, em partes menores.';
|
|
282
|
+
if (!subagent) console.log('\n' + c.warn(finalText));
|
|
283
|
+
}
|
|
275
284
|
|
|
276
285
|
// Nada de arquivo mexido = nada para lembrar. E grava em `pastaAtual`, não em
|
|
277
286
|
// `projectDir`: quando a pasta do projeto nasceu no meio do caminho, a memória
|
package/lib/browser.js
CHANGED
|
@@ -57,6 +57,71 @@ function findChrome() {
|
|
|
57
57
|
for (const p of candidates) {
|
|
58
58
|
try { if (p && fs.existsSync(p)) return p; } catch {}
|
|
59
59
|
}
|
|
60
|
+
|
|
61
|
+
// Último recurso: o navegador pode estar num disco montado ou numa pasta de
|
|
62
|
+
// aplicativos do usuário. Caso real medido nesta máquina: o Chrome 151 estava
|
|
63
|
+
// em `/Volumes/Google Chrome/Google Chrome.app` — a JANELA DO INSTALADOR,
|
|
64
|
+
// ainda montada. O usuário jurava (com razão) que tinha Chrome; a lista de
|
|
65
|
+
// caminhos padrão jurava que não. Os dois estavam certos.
|
|
66
|
+
const achado = procurarEmVolumes();
|
|
67
|
+
if (achado) return achado;
|
|
68
|
+
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Nomes de app e o binário dentro de cada um. Só Chromium: o Safari não fala o
|
|
73
|
+
// protocolo de depuração que permite controlar a página.
|
|
74
|
+
const APPS_MAC = [
|
|
75
|
+
['Google Chrome.app', 'Google Chrome'],
|
|
76
|
+
['Google Chrome Beta.app', 'Google Chrome Beta'],
|
|
77
|
+
['Google Chrome Canary.app', 'Google Chrome Canary'],
|
|
78
|
+
['Chromium.app', 'Chromium'],
|
|
79
|
+
['Microsoft Edge.app', 'Microsoft Edge'],
|
|
80
|
+
['Brave Browser.app', 'Brave Browser'],
|
|
81
|
+
['Arc.app', 'Arc'],
|
|
82
|
+
['Vivaldi.app', 'Vivaldi'],
|
|
83
|
+
['Opera.app', 'Opera'],
|
|
84
|
+
];
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Procura o navegador em `~/Applications` e nos discos montados em `/Volumes`.
|
|
88
|
+
*
|
|
89
|
+
* Um app rodando de dentro do `.dmg` funciona, mas é frágil: o disco é
|
|
90
|
+
* somente-leitura, o macOS o executa por AppTranslocation (caminho temporário
|
|
91
|
+
* aleatório) e ele desaparece na hora que o disco for ejetado. Achamos e usamos —
|
|
92
|
+
* e `avisoDeLocal()` diz ao usuário para arrastar o app para Aplicativos.
|
|
93
|
+
*/
|
|
94
|
+
function procurarEmVolumes() {
|
|
95
|
+
const bases = [];
|
|
96
|
+
try { bases.push(path.join(os.homedir(), 'Applications')); } catch {}
|
|
97
|
+
try {
|
|
98
|
+
for (const vol of fs.readdirSync('/Volumes')) bases.push(path.join('/Volumes', vol));
|
|
99
|
+
} catch {}
|
|
100
|
+
|
|
101
|
+
for (const base of bases) {
|
|
102
|
+
for (const [app, bin] of APPS_MAC) {
|
|
103
|
+
const alvo = path.join(base, app, 'Contents', 'MacOS', bin);
|
|
104
|
+
try { if (fs.existsSync(alvo)) return alvo; } catch {}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* O navegador achado está num lugar problemático? Devolve o aviso, ou null.
|
|
112
|
+
*
|
|
113
|
+
* Não bloqueia nada: o controle funciona de dentro do disco montado. Só avisa,
|
|
114
|
+
* porque quem ejetar o disco vai ver a ferramenta parar de funcionar sem entender
|
|
115
|
+
* o motivo.
|
|
116
|
+
*/
|
|
117
|
+
function avisoDeLocal(caminho) {
|
|
118
|
+
if (!caminho) return null;
|
|
119
|
+
if (caminho.startsWith('/Volumes/')) {
|
|
120
|
+
const app = (caminho.match(/\/Volumes\/[^/]+\/([^/]+\.app)/) || [, 'o navegador'])[1];
|
|
121
|
+
return `${app} está rodando do disco de instalação, não instalado. `
|
|
122
|
+
+ 'Funciona, mas para de funcionar quando você ejetar esse disco — '
|
|
123
|
+
+ 'arraste o app para a pasta Aplicativos.';
|
|
124
|
+
}
|
|
60
125
|
return null;
|
|
61
126
|
}
|
|
62
127
|
|
|
@@ -104,11 +169,25 @@ class MiniWebSocket {
|
|
|
104
169
|
this.onClose = () => {};
|
|
105
170
|
this.closed = false;
|
|
106
171
|
}
|
|
107
|
-
connect(url) {
|
|
172
|
+
connect(url, timeoutMs = 10000) {
|
|
108
173
|
return new Promise((resolve, reject) => {
|
|
174
|
+
let pronto = false;
|
|
175
|
+
const relogio = setTimeout(() => {
|
|
176
|
+
if (pronto) return;
|
|
177
|
+
pronto = true;
|
|
178
|
+
try { if (this.socket) this.socket.destroy(); } catch {}
|
|
179
|
+
reject(new Error(`o navegador aceitou a conexão mas não completou o aperto de mão em ${Math.round(timeoutMs / 1000)}s`));
|
|
180
|
+
}, timeoutMs);
|
|
181
|
+
const terminar = (err) => {
|
|
182
|
+
if (pronto) return;
|
|
183
|
+
pronto = true;
|
|
184
|
+
clearTimeout(relogio);
|
|
185
|
+
err ? reject(err) : resolve();
|
|
186
|
+
};
|
|
187
|
+
|
|
109
188
|
const u = new URL(url);
|
|
110
189
|
const isSecure = u.protocol === 'wss:';
|
|
111
|
-
if (isSecure) return
|
|
190
|
+
if (isSecure) return terminar(new Error('wss:// não suportado (CDP local usa ws://)'));
|
|
112
191
|
const key = crypto.randomBytes(16).toString('base64');
|
|
113
192
|
const port = u.port || 80;
|
|
114
193
|
const path = u.pathname + (u.search || '');
|
|
@@ -130,21 +209,26 @@ class MiniWebSocket {
|
|
|
130
209
|
const sep = headerBuf.indexOf('\r\n\r\n');
|
|
131
210
|
if (sep === -1) return;
|
|
132
211
|
const header = headerBuf.slice(0, sep).toString();
|
|
133
|
-
if (!/HTTP\/1\.1 101/.test(header)) return
|
|
212
|
+
if (!/HTTP\/1\.1 101/.test(header)) return terminar(new Error('handshake WS falhou: ' + header.split('\r\n')[0]));
|
|
134
213
|
handshaked = true;
|
|
135
214
|
this.socket = sock;
|
|
136
215
|
const rest = headerBuf.slice(sep + 4);
|
|
137
216
|
if (rest.length) this._onData(rest);
|
|
138
|
-
|
|
217
|
+
terminar();
|
|
139
218
|
} else {
|
|
140
219
|
this._onData(data);
|
|
141
220
|
}
|
|
142
221
|
});
|
|
143
222
|
sock.on('error', (err) => {
|
|
144
|
-
if (!handshaked)
|
|
223
|
+
if (!handshaked) terminar(err);
|
|
145
224
|
this.closed = true; this.onClose(err);
|
|
146
225
|
});
|
|
147
|
-
sock.on('close', () => {
|
|
226
|
+
sock.on('close', () => {
|
|
227
|
+
// Fechar ANTES do 101 também é falha: sem isto a Promise ficava
|
|
228
|
+
// pendente quando o Chrome derrubava a conexão calado.
|
|
229
|
+
if (!handshaked) terminar(new Error('o navegador fechou a conexão antes de aceitar o controle'));
|
|
230
|
+
this.closed = true; this.onClose();
|
|
231
|
+
});
|
|
148
232
|
});
|
|
149
233
|
}
|
|
150
234
|
_onData(chunk) {
|
|
@@ -322,14 +406,22 @@ async function findOrCreatePageTarget(url) {
|
|
|
322
406
|
return created;
|
|
323
407
|
}
|
|
324
408
|
|
|
409
|
+
// Nenhuma etapa do controle de navegador pode pendurar o CLI sem explicação.
|
|
410
|
+
function comTeto(promessa, ms, oQue) {
|
|
411
|
+
return new Promise((resolve, reject) => {
|
|
412
|
+
const t = setTimeout(() => reject(new Error(`${oQue} não respondeu em ${Math.round(ms / 1000)}s`)), ms);
|
|
413
|
+
promessa.then((v) => { clearTimeout(t); resolve(v); }, (e) => { clearTimeout(t); reject(e); });
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
|
|
325
417
|
async function connect(url) {
|
|
326
418
|
await ensureChromeRunning();
|
|
327
|
-
const target = await findOrCreatePageTarget(url);
|
|
419
|
+
const target = await comTeto(findOrCreatePageTarget(url), 15000, 'a lista de abas do navegador');
|
|
328
420
|
if (!target || !target.webSocketDebuggerUrl) throw new Error('Não foi possível obter alvo de página do Chrome.');
|
|
329
421
|
if (currentSession) { try { currentSession.close(); } catch {} }
|
|
330
422
|
currentSession = new CDPSession();
|
|
331
423
|
currentSession.targetId = target.id;
|
|
332
|
-
await currentSession.attach(target.webSocketDebuggerUrl);
|
|
424
|
+
await comTeto(currentSession.attach(target.webSocketDebuggerUrl), 12000, 'o canal de controle do navegador');
|
|
333
425
|
await currentSession.send('Page.enable');
|
|
334
426
|
await currentSession.send('DOM.enable');
|
|
335
427
|
await currentSession.send('Runtime.enable');
|
|
@@ -403,6 +495,74 @@ async function clickText(text) {
|
|
|
403
495
|
return { ok: true, ...found };
|
|
404
496
|
}
|
|
405
497
|
|
|
498
|
+
/**
|
|
499
|
+
* Espera o seletor aparecer por um instante antes de desistir.
|
|
500
|
+
*
|
|
501
|
+
* A página quase sempre ainda está montando quando a próxima ferramenta roda.
|
|
502
|
+
* Sem esta espera curta, "não encontrado" era muitas vezes "ainda não nasceu".
|
|
503
|
+
*/
|
|
504
|
+
async function esperarSeletor(selector, ms = 4000) {
|
|
505
|
+
const fim = Date.now() + ms;
|
|
506
|
+
while (Date.now() < fim) {
|
|
507
|
+
if (await evaluate(`!!document.querySelector(${JSON.stringify(selector)})`)) return true;
|
|
508
|
+
await sleep(200);
|
|
509
|
+
}
|
|
510
|
+
return false;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* O que EXISTE na página, para o erro ensinar em vez de só reclamar.
|
|
515
|
+
*
|
|
516
|
+
* Mesma ideia do `trechosParecidos` do edit_file: erro seco faz o modelo chutar de
|
|
517
|
+
* novo. Devolvendo o que está lá, ele acerta na segunda tentativa.
|
|
518
|
+
*
|
|
519
|
+
* Caso real: o modelo pediu `input[name="q"]` no Google. A caixa de busca do
|
|
520
|
+
* Google é `textarea[name="q"]` — o chute era razoável e estava errado, e o erro
|
|
521
|
+
* antigo não dava nenhuma pista de qual era o certo.
|
|
522
|
+
*/
|
|
523
|
+
async function candidatosNaPagina() {
|
|
524
|
+
const js = `(function(){
|
|
525
|
+
// Ordem importa: quem errou um seletor quase sempre queria um CAMPO, e a
|
|
526
|
+
// lista é cortada em 12. Link de navegação por último, senão o campo de
|
|
527
|
+
// busca fica de fora justamente no caso em que ele é a resposta.
|
|
528
|
+
const porPrioridade = [
|
|
529
|
+
'input:not([type=hidden]),textarea,[contenteditable],[role=textbox]',
|
|
530
|
+
'button,[role=button],select,input[type=submit]',
|
|
531
|
+
'a[href]',
|
|
532
|
+
];
|
|
533
|
+
const alvos = [];
|
|
534
|
+
for (const grupo of porPrioridade) {
|
|
535
|
+
for (const e of document.querySelectorAll(grupo)) if (!alvos.includes(e)) alvos.push(e);
|
|
536
|
+
}
|
|
537
|
+
const vistos = [];
|
|
538
|
+
for (const e of alvos) {
|
|
539
|
+
const r = e.getBoundingClientRect();
|
|
540
|
+
if (r.width < 2 || r.height < 2) continue; // invisível não serve
|
|
541
|
+
const tag = e.tagName.toLowerCase();
|
|
542
|
+
let sel = tag;
|
|
543
|
+
if (e.id) sel = tag + '#' + e.id;
|
|
544
|
+
else if (e.name) sel = tag + '[name="' + e.name + '"]';
|
|
545
|
+
else if (e.getAttribute('aria-label')) sel = tag + '[aria-label="' + e.getAttribute('aria-label') + '"]';
|
|
546
|
+
else if (e.type) sel = tag + '[type="' + e.type + '"]';
|
|
547
|
+
const rotulo = (e.getAttribute('aria-label') || e.placeholder || (e.innerText||'').trim().slice(0,30) || '').replace(/\\s+/g,' ');
|
|
548
|
+
vistos.push(rotulo ? sel + ' (' + rotulo + ')' : sel);
|
|
549
|
+
if (vistos.length >= 12) break;
|
|
550
|
+
}
|
|
551
|
+
return JSON.stringify(vistos);
|
|
552
|
+
})()`;
|
|
553
|
+
try { return JSON.parse(await evaluate(js)) || []; } catch { return []; }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
async function erroDeSeletor(selector) {
|
|
557
|
+
const candidatos = await candidatosNaPagina();
|
|
558
|
+
const err = new Error(
|
|
559
|
+
`seletor não encontrado: ${selector}`
|
|
560
|
+
+ (candidatos.length ? `. Existem nesta página: ${candidatos.join(' · ')}` : '. A página não tem nenhum elemento clicável visível — ela pode não ter carregado.')
|
|
561
|
+
);
|
|
562
|
+
err.candidatos = candidatos;
|
|
563
|
+
return err;
|
|
564
|
+
}
|
|
565
|
+
|
|
406
566
|
async function clickSelector(selector) {
|
|
407
567
|
await ensure();
|
|
408
568
|
const js = `(function(){
|
|
@@ -412,8 +572,9 @@ async function clickSelector(selector) {
|
|
|
412
572
|
const r = el.getBoundingClientRect();
|
|
413
573
|
return { x: r.left + r.width/2, y: r.top + r.height/2, tag: el.tagName };
|
|
414
574
|
})()`;
|
|
415
|
-
|
|
416
|
-
if (!found
|
|
575
|
+
let found = await evaluate(js);
|
|
576
|
+
if (!found && await esperarSeletor(selector)) found = await evaluate(js);
|
|
577
|
+
if (!found) throw await erroDeSeletor(selector);
|
|
417
578
|
await sleep(200);
|
|
418
579
|
await click(found.x, found.y);
|
|
419
580
|
return { ok: true, selector, ...found };
|
|
@@ -430,14 +591,16 @@ async function type(text) {
|
|
|
430
591
|
|
|
431
592
|
async function typeInSelector(selector, text, clear = true) {
|
|
432
593
|
await ensure();
|
|
433
|
-
const
|
|
594
|
+
const focar = () => evaluate(`(function(){
|
|
434
595
|
const el = document.querySelector(${JSON.stringify(selector)});
|
|
435
596
|
if (!el) return false;
|
|
436
597
|
el.focus();
|
|
437
598
|
${clear ? "if ('value' in el) el.value = '';" : ''}
|
|
438
599
|
return true;
|
|
439
600
|
})()`);
|
|
440
|
-
|
|
601
|
+
let focused = await focar();
|
|
602
|
+
if (!focused && await esperarSeletor(selector)) focused = await focar();
|
|
603
|
+
if (!focused) throw await erroDeSeletor(selector);
|
|
441
604
|
await type(text);
|
|
442
605
|
// dispara evento input para frameworks reativos
|
|
443
606
|
await evaluate(`(function(){
|
|
@@ -508,5 +671,6 @@ function close() {
|
|
|
508
671
|
module.exports = {
|
|
509
672
|
open, navigate, click, clickText, clickSelector, type, typeInSelector,
|
|
510
673
|
key, screenshot, evaluate, currentUrl, waitFor, wait, scroll, close,
|
|
511
|
-
|
|
674
|
+
avisoDeLocal,
|
|
675
|
+
_internal: { findChrome, procurarEmVolumes, PROFILE_DIR, DEBUG_PORT },
|
|
512
676
|
};
|
package/lib/tools.js
CHANGED
|
@@ -317,11 +317,41 @@ function writeTodos({ todos }) {
|
|
|
317
317
|
}
|
|
318
318
|
function getTodos() { return TODOS; }
|
|
319
319
|
|
|
320
|
+
/**
|
|
321
|
+
* Executa uma ferramenta e NUNCA lança.
|
|
322
|
+
*
|
|
323
|
+
* O `await` aqui não é enfeite. O despachante abaixo faz `return browser.x(...)`
|
|
324
|
+
* em vinte casos, e `try { return promessa } catch` **não** pega a rejeição dessa
|
|
325
|
+
* promessa — só `return await` pega. Sem isto, qualquer falha de navegador ou
|
|
326
|
+
* desktop subia por cima do loop do agente e derrubava o CLI inteiro com exit 1,
|
|
327
|
+
* em vez de virar uma ação falhada que o modelo pode corrigir.
|
|
328
|
+
*
|
|
329
|
+
* Foi exatamente o que acontecia ao pedir uma pesquisa no Google: o seletor não
|
|
330
|
+
* batia, a promessa rejeitava, e o programa morria calado no meio da tarefa.
|
|
331
|
+
*/
|
|
320
332
|
async function execute(name, args, ctx) {
|
|
333
|
+
try {
|
|
334
|
+
return await despachar(name, args, ctx);
|
|
335
|
+
} catch (e) {
|
|
336
|
+
// Erro que sabe o que EXISTE leva a informação junto — mesma ideia do
|
|
337
|
+
// `trechosParecidos` do edit_file. Erro seco faz o modelo chutar de novo.
|
|
338
|
+
if (e && Array.isArray(e.candidatos) && e.candidatos.length) {
|
|
339
|
+
return {
|
|
340
|
+
ok: false,
|
|
341
|
+
error: e.message,
|
|
342
|
+
seletoresDisponiveis: e.candidatos,
|
|
343
|
+
comoCorrigir: 'Chame a tool de novo com um seletor de "seletoresDisponiveis", copiado LITERALMENTE. O primeiro da lista é o mais provável.',
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
return { ok: false, error: e.message || String(e) };
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
async function despachar(name, args, ctx) {
|
|
321
351
|
const projectDir = (ctx && ctx.projectDir) || process.cwd();
|
|
322
352
|
args = args || {};
|
|
323
353
|
|
|
324
|
-
|
|
354
|
+
{
|
|
325
355
|
switch (name) {
|
|
326
356
|
case 'write_file': {
|
|
327
357
|
const filePath = resolveInProject(args.path, projectDir);
|
|
@@ -410,8 +440,6 @@ async function execute(name, args, ctx) {
|
|
|
410
440
|
default:
|
|
411
441
|
return { ok: false, error: `Tool não implementada: ${name}` };
|
|
412
442
|
}
|
|
413
|
-
} catch (e) {
|
|
414
|
-
return { ok: false, error: e.message };
|
|
415
443
|
}
|
|
416
444
|
}
|
|
417
445
|
|
package/lib/ui.js
CHANGED
|
@@ -163,7 +163,7 @@ const TIPS = [
|
|
|
163
163
|
const NEWS = [
|
|
164
164
|
'Modelos grátis, com reserva: um no limite não vira erro na sua tela',
|
|
165
165
|
'Ele trabalha em ~/primocode. Fale o nome de uma pasta e ele muda sozinho',
|
|
166
|
-
'
|
|
166
|
+
'Falha de navegador agora diz quais seletores EXISTEM na página',
|
|
167
167
|
];
|
|
168
168
|
|
|
169
169
|
// ── Cabeçalho ────────────────────────────────────────────────────────────
|
package/package.json
CHANGED