primocode 7.15.0 → 7.16.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/bin/primocode.js CHANGED
@@ -23,6 +23,7 @@ const { runAct, interromper } = require('../lib/act');
23
23
  const pasta = require('../lib/pasta');
24
24
  const catalogo = require('../lib/catalogo');
25
25
  const streaming = require('../lib/streaming');
26
+ const effort = require('../lib/effort');
26
27
 
27
28
  // A pasta de onde o comando foi dado. Não é mais onde ele trabalha (o padrão é
28
29
  // ~/primocode), mas continua servindo de ponto de partida para achar pasta que
@@ -48,7 +49,7 @@ ${c.bold('Comandos')}
48
49
  ${c.brand('/dir')} <caminho> trabalha numa pasta que já existe (onde você quiser)
49
50
  ${c.brand('/project')} lista projetos ou cria em ~/primocode: /project <nome>
50
51
  ${c.brand('/mode')} /mode primocode|chat|auto
51
- ${c.brand('/model')} /model top|main|fast — ou um ID do OpenRouter
52
+ ${c.brand('/effort')} esforço do agente — do rápido ao mais esperto
52
53
  ${c.brand('/attach')} <arquivo> anexa arquivo ao próximo pedido
53
54
  ${c.brand('/files')} lista anexos ${c.brand('/detach')} remove
54
55
  ${c.brand('/tudo')} lista tudo que ele sabe fazer
@@ -87,7 +88,7 @@ const SLASH_COMANDOS = [
87
88
  ['/project', 'lista/cria projetos em ~/primocode'],
88
89
  ['/dir', 'trabalhar numa pasta sua'],
89
90
  ['/mode', 'primocode | chat | auto'],
90
- ['/model', 'top | main | fast'],
91
+ ['/effort', 'esforço: low → ultracode (←/→ para ajustar)'],
91
92
  ['/attach', 'anexa arquivo ao pedido'],
92
93
  ['/detach', 'remove os anexos'],
93
94
  ['/files', 'lista os anexos'],
@@ -117,6 +118,105 @@ function completarSlash(line) {
117
118
  return [acertos, line];
118
119
  }
119
120
 
121
+ // ── /effort: o slider de esforço (estilo Claude Code) ──────────────────
122
+ // Régua com os níveis, o marcador ▲ embaixo do escolhido, "Faster" à
123
+ // esquerda e "Smarter" à direita. ←/→ move, Enter confirma, Esc cancela.
124
+
125
+ function desenharEffort(idx) {
126
+ const cols = Math.max(52, Math.min(process.stdout.columns || 100, 104));
127
+ const regua = '▔'.repeat(cols);
128
+ const linhas = [];
129
+
130
+ // Cada nível ocupa uma coluna própria; o ultracode fica destacado no fim.
131
+ const rotulos = effort.NOMES;
132
+ const larguras = rotulos.map((n) => Math.max(n.length + 3, 9));
133
+ const posicoes = [];
134
+ let x = 23; // recuo da régua, igual ao do mockup
135
+ for (let i = 0; i < rotulos.length; i++) {
136
+ posicoes.push(x + Math.floor(larguras[i] / 2));
137
+ x += larguras[i];
138
+ }
139
+ const fimRegua = x;
140
+
141
+ linhas.push(c.dim(regua));
142
+ linhas.push(' ' + c.bold(c.white('Effort')));
143
+ linhas.push('');
144
+
145
+ // Faster ....▲.... Smarter — a trilha, com o marcador na posição atual.
146
+ const marca = posicoes[idx];
147
+ let trilha = '';
148
+ for (let k = 23; k < fimRegua; k++) {
149
+ if (k === marca) trilha += c.brand('▲');
150
+ else if (k === fimRegua - 12) trilha += c.dim('┆'); // ultracode é um degrau à parte
151
+ else trilha += c.dim(k < marca ? '─' : '─');
152
+ }
153
+ linhas.push(' ' + c.muted('Faster') + ' '.repeat(Math.max(1, fimRegua - 23 - 6 - 7)) + c.muted('Smarter'));
154
+ linhas.push(' ' + trilha);
155
+
156
+ // Os nomes, o escolhido aceso.
157
+ let linhaNomes = ' '.repeat(23);
158
+ for (let i = 0; i < rotulos.length; i++) {
159
+ const nome = rotulos[i];
160
+ const espaco = larguras[i];
161
+ const pad = Math.floor((espaco - nome.length) / 2);
162
+ const texto = i === idx ? c.bold(c.brand(nome)) : c.muted(nome);
163
+ linhaNomes += ' '.repeat(pad) + texto + ' '.repeat(espaco - nome.length - pad);
164
+ }
165
+ linhas.push(linhaNomes);
166
+
167
+ const nivel = effort.nivelDe(rotulos[idx]);
168
+ linhas.push(' ' + c.dim(nivel.resumo));
169
+ linhas.push('');
170
+ linhas.push(' ' + c.muted('←/→ ajusta · Enter confirma · Esc cancela'));
171
+ return linhas.join('\n');
172
+ }
173
+
174
+ // Desenha, escuta as setas, e devolve o nome escolhido (ou null se cancelou).
175
+ function sliderEffort(atual) {
176
+ return new Promise((resolve) => {
177
+ if (!process.stdin.isTTY) { // sem terminal (pipe, CI) não há slider
178
+ console.log(c.muted(` Esforço atual: ${effort.normalizar(atual)}. Use /effort <${effort.NOMES.join('|')}>.`));
179
+ return resolve(null);
180
+ }
181
+ let idx = effort.indiceDe(atual);
182
+ const ALTURA = 9; // linhas que o desenho ocupa — para redesenhar por cima
183
+
184
+ const pintar = (primeira) => {
185
+ if (!primeira) process.stdout.write(`\x1b[${ALTURA}A\r`);
186
+ process.stdout.write(desenharEffort(idx).split('\n').map((l) => '\x1b[2K' + l).join('\n') + '\n');
187
+ };
188
+ pintar(true);
189
+
190
+ const aoTeclar = (ch, key) => {
191
+ if (!key) return;
192
+ if (key.name === 'right' || key.name === 'l') { idx = Math.min(effort.NOMES.length - 1, idx + 1); pintar(false); return; }
193
+ if (key.name === 'left' || key.name === 'h') { idx = Math.max(0, idx - 1); pintar(false); return; }
194
+ if (key.name === 'return' || key.name === 'enter') { encerrar(effort.NOMES[idx]); return; }
195
+ if (key.name === 'escape' || (key.ctrl && key.name === 'c')) { encerrar(null); return; }
196
+ };
197
+ const encerrar = (valor) => {
198
+ process.stdin.removeListener('keypress', aoTeclar);
199
+ sliderAtivo = false;
200
+ resolve(valor);
201
+ };
202
+ sliderAtivo = true;
203
+ process.stdin.on('keypress', aoTeclar);
204
+ });
205
+ }
206
+
207
+ function aplicarEffort(nome) {
208
+ const nivel = effort.nivelDe(nome);
209
+ state.config.effort = nivel.nome;
210
+ state.config.model = nivel.model;
211
+ cfg.saveConfig({ effort: nivel.nome, model: nivel.model });
212
+ console.log(c.ok(`Esforço: ${nivel.nome}`) + c.muted(` ${nivel.resumo}`));
213
+ if (nivel.pesquisa) console.log(c.dim(' Pesquisa de referência ligada: ele consulta docs e exemplos antes de escrever.'));
214
+ refreshPrompt();
215
+ }
216
+
217
+ // Enquanto o slider está no ar, o fantasma do autocompletar não desenha.
218
+ let sliderAtivo = false;
219
+
120
220
  function mostrarMenuSlash() {
121
221
  console.log('');
122
222
  for (const [nome, desc] of SLASH_COMANDOS) {
@@ -309,6 +409,7 @@ async function sendPrompt(userText) {
309
409
  const r = await runAct(prompt, {
310
410
  server: state.config.server,
311
411
  model: state.config.model,
412
+ effort: state.config.effort,
312
413
  token: state.config.token,
313
414
  history: state.history,
314
415
  projectDir: plan.dir,
@@ -464,24 +565,38 @@ async function handleSlash(text) {
464
565
  break;
465
566
  }
466
567
 
568
+ // O /model virou /effort: você escolhe o ESFORÇO num slider (←/→,
569
+ // Enter confirma, Esc cancela) e o modelo sai daí. Com argumento
570
+ // ("/effort high") aplica direto, para quem já sabe o que quer.
571
+ case 'effort': case 'esforco': case 'esforço': {
572
+ const pedido = rest.toLowerCase().trim();
573
+ if (pedido) {
574
+ if (!effort.NOMES.includes(pedido)) {
575
+ console.log(c.err(`Esforço inválido. Use: ${effort.NOMES.join(' | ')}`));
576
+ break;
577
+ }
578
+ aplicarEffort(pedido);
579
+ break;
580
+ }
581
+ const escolhido = await sliderEffort(state.config.effort);
582
+ if (escolhido) aplicarEffort(escolhido);
583
+ else console.log(c.muted(' Nada mudou.'));
584
+ break;
585
+ }
586
+
587
+ // Continua existindo para quem fixa um ID do OpenRouter na unha; o
588
+ // caminho normal é o /effort.
467
589
  case 'model': {
468
590
  const m = rest.toLowerCase();
469
- // Todos grátis. A lista viva de cada corrente está no /status.
470
- const nomes = {
471
- top: 'o mais capaz — 550B, 1 milhão de tokens de contexto',
472
- main: 'o padrão — agente de código, o melhor em tarefa de terminal',
473
- fast: 'o mais rápido — resposta curta, alta vazão',
474
- };
475
- if (nomes[m]) {
591
+ if (m.includes('/')) {
476
592
  state.config.model = m;
477
593
  cfg.saveConfig({ model: m });
478
- console.log(c.ok(`Modelo: ${m}`) + c.muted(` ${nomes[m]}`) + c.dim(' · grátis'));
479
- } else if (m.includes('/')) {
480
- // ID literal do OpenRouter, para quem quer sair dos três.
481
- state.config.model = m;
482
- cfg.saveConfig({ model: m });
483
- console.log(c.ok(`Modelo: ${m}`) + c.muted(' precisa aceitar tools para o modo primocode'));
484
- } else console.log(c.err('Uso: /model top|main|fast (ou um ID do OpenRouter, ex: google/gemini-2.5-pro)'));
594
+ console.log(c.ok(`Modelo fixo: ${m}`) + c.muted(' precisa aceitar tools no modo primocode'));
595
+ console.log(c.dim(' Para voltar ao normal, use /effort.'));
596
+ } else {
597
+ console.log(c.muted(' O modelo agora vem do esforço. Use ') + c.brand('/effort') + c.muted('.'));
598
+ console.log(c.dim(` Agora: ${state.config.effort} → modelo ${effort.modeloDe(state.config.effort)}`));
599
+ }
485
600
  break;
486
601
  }
487
602
 
@@ -613,7 +728,8 @@ async function handleSlash(text) {
613
728
  console.log('');
614
729
  console.log(` ${c.muted('nome')} ${c.white(state.config.userName || '(nenhum)')}`);
615
730
  console.log(` ${c.muted('mascote')} ${c.white(state.config.mascot || 'primo')}`);
616
- console.log(` ${c.muted('modelo')} ${c.white(state.config.model)}`);
731
+ console.log(` ${c.muted('esforço')} ${c.white(state.config.effort)}${c.dim(' · /effort muda')}`);
732
+ console.log(` ${c.muted('modelo')} ${c.white(state.config.model)}${c.dim(' (vem do esforço)')}`);
617
733
  console.log(` ${c.muted('modo')} ${c.white(state.mode)}`);
618
734
  console.log(` ${c.muted('servidor')} ${c.dim(state.config.server)}`);
619
735
  console.log(c.muted(`\n ${cfg.CONFIG_FILE}`));
@@ -733,6 +849,7 @@ async function main() {
733
849
  await runAct(fullPrompt, {
734
850
  server: state.config.server,
735
851
  model: state.config.model,
852
+ effort: state.config.effort,
736
853
  token: state.config.token,
737
854
  history: [],
738
855
  projectDir: plan.dir,
@@ -766,7 +883,7 @@ async function main() {
766
883
  if (process.stdin.isTTY && process.stdout.isTTY) {
767
884
  let fantasma = '';
768
885
  const desenharFantasma = () => {
769
- if (busy || pendingAsk || !rl) return;
886
+ if (busy || pendingAsk || sliderAtivo || !rl) return;
770
887
  const linha = rl.line;
771
888
  const noFim = rl.cursor === linha.length;
772
889
  fantasma = '';
@@ -785,6 +902,7 @@ async function main() {
785
902
  // sequence "\x1b\x1b" — vale como os dois.
786
903
  let ultimoEsc = 0;
787
904
  process.stdin.on('keypress', (ch, key) => {
905
+ if (sliderAtivo) return; // o slider é dono do teclado enquanto está no ar
788
906
  if (key && (key.name === 'escape' || key.sequence === '\x1b\x1b')) {
789
907
  if (!busy) { ultimoEsc = 0; return; } // sem agente rodando, Esc é só Esc
790
908
  const agora = Date.now();
package/lib/act.js CHANGED
@@ -105,6 +105,14 @@ function logResult(kind, result, profundidade) {
105
105
  case 'browser_read':
106
106
  console.log(p + c.muted(`${(result.text || '').length} caracteres lidos da página`));
107
107
  return;
108
+ case 'pesquisar_referencia': {
109
+ console.log(p + c.muted(`${result.fontes} fonte${result.fontes === 1 ? '' : 's'} lida${result.fontes === 1 ? '' : 's'} sobre "${result.consulta}"`));
110
+ for (const r of (result.referencias || []).slice(0, 4)) {
111
+ console.log(p + c.dim(`${r.titulo}`.slice(0, 100)));
112
+ console.log(p + c.dim(` ${r.url}`.slice(0, 110)));
113
+ }
114
+ return;
115
+ }
108
116
  case 'desktop_windows': {
109
117
  console.log(p + c.muted(`${result.total} janela${result.total === 1 ? '' : 's'}`));
110
118
  for (const j of (result.janelas || []).slice(0, 5)) console.log(p + c.dim(`${j.app ? j.app + ' · ' : ''}${j.titulo}`.slice(0, 120)));
@@ -168,7 +176,7 @@ function buildMemoryContext(projectDir, projectName) {
168
176
  async function runAct(userPrompt, opts) {
169
177
  const {
170
178
  server, model, token, history, projectDir, projectName,
171
- ensureWorkDir, profundidade = 0, subagent = false,
179
+ ensureWorkDir, profundidade = 0, subagent = false, effort,
172
180
  } = opts || {};
173
181
 
174
182
  // A pasta de projeto só nasce quando o agente for mesmo MEXER EM ARQUIVO.
@@ -210,7 +218,7 @@ async function runAct(userPrompt, opts) {
210
218
  let streaming = false;
211
219
 
212
220
  try {
213
- await api.requestAct(server, { messages, model, token, projectDir, projectName, subagent }, (evt) => {
221
+ await api.requestAct(server, { messages, model, effort, token, projectDir, projectName, subagent }, (evt) => {
214
222
  if (evt.erro) { streamError = evt.erro; return; }
215
223
  if (evt.tool_call) { toolCalls.push(evt.tool_call); return; }
216
224
  if (evt.delta) {
package/lib/catalogo.js CHANGED
@@ -78,6 +78,7 @@ const GRUPOS = [
78
78
  resumo: 'como ele se organiza numa tarefa grande',
79
79
  itens: [
80
80
  { tool: 'write_todos', escreve: false, faz: 'monta o plano e vai marcando o que terminou' },
81
+ { tool: 'pesquisar_referencia', escreve: false, faz: 'lê docs e exemplos antes de escrever — sobe a qualidade' },
81
82
  // Estas duas o loop do agente atende (lib/act.js), não o tools.execute.
82
83
  { tool: 'spawn_agent', escreve: false, faz: 'chama um subagente para uma parte da tarefa', noLoop: true },
83
84
  { tool: 'finish', escreve: false, faz: 'encerra dizendo o que fez', noLoop: true },
package/lib/config.js CHANGED
@@ -160,6 +160,9 @@ function loadConfig() {
160
160
  return {
161
161
  server: process.env.PRIMOCODE_SERVER || file.server || 'https://primocode-server.onrender.com',
162
162
  model: process.env.PRIMOCODE_MODEL || file.model || 'main',
163
+ // Esforço, estilo Claude Code (/effort). É o que o usuário escolhe; o
164
+ // modelo é derivado dele. Guardado para durar entre sessões.
165
+ effort: process.env.PRIMOCODE_EFFORT || file.effort || 'medium',
163
166
  token: process.env.PRIMOCODE_TOKEN || file.token || null,
164
167
  autoApply: file.autoApply === true,
165
168
  currentProject: file.currentProject || null,
@@ -177,8 +180,8 @@ function saveConfig(partial) {
177
180
  ensureDir(CONFIG_DIR);
178
181
  const cur = (() => { try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; } })();
179
182
  // Só persiste o que faz sentido guardar — nada de campos derivados.
180
- const { server, model, token, autoApply, currentProject, userName, mascot, theme, mode } = { ...cur, ...partial };
181
- const next = { server, model, token, autoApply, currentProject, userName, mascot, theme, mode };
183
+ const { server, model, effort, token, autoApply, currentProject, userName, mascot, theme, mode } = { ...cur, ...partial };
184
+ const next = { server, model, effort, token, autoApply, currentProject, userName, mascot, theme, mode };
182
185
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf8');
183
186
  return next;
184
187
  }
package/lib/effort.js ADDED
@@ -0,0 +1,51 @@
1
+ /**
2
+ * effort.js — o "esforço" do agente, no espírito do /effort do Claude Code.
3
+ *
4
+ * O usuário não escolhe mais um modelo por nome (/model): escolhe um ESFORÇO
5
+ * num controle deslizante, do mais rápido ao mais esperto. O modelo é derivado
6
+ * daqui. Fica tudo num lugar só, puro (sem I/O), para o teste bater sem rede.
7
+ *
8
+ * A pesquisa de referência (abrir docs/exemplos e aprender com eles antes de
9
+ * escrever) liga a partir do "high": esforço maior = ele pesquisa antes de
10
+ * codar, o que melhora a qualidade. É VISÍVEL no painel — nada escondido.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ // Ordem = ordem do slider, do mais rápido ao mais esperto.
16
+ const NIVEIS = [
17
+ { nome: 'low', model: 'fast', pesquisa: false, resumo: 'rápido — tarefa simples, resposta curta' },
18
+ { nome: 'medium', model: 'main', pesquisa: false, resumo: 'equilíbrio — o padrão do dia a dia' },
19
+ { nome: 'high', model: 'main', pesquisa: true, resumo: 'capricha no código e pesquisa referências' },
20
+ { nome: 'xhigh', model: 'top', pesquisa: true, resumo: 'o mais capaz — 1M de contexto, pesquisa' },
21
+ { nome: 'max', model: 'top', pesquisa: true, resumo: 'esforço máximo — pesquisa a fundo' },
22
+ { nome: 'ultracode', model: 'top', pesquisa: true, resumo: 'tudo no talo — pesquisa e cruza referências' },
23
+ ];
24
+
25
+ const NOMES = NIVEIS.map((n) => n.nome);
26
+
27
+ function normalizar(nome) {
28
+ const n = String(nome || '').toLowerCase().trim();
29
+ return NOMES.includes(n) ? n : 'medium';
30
+ }
31
+
32
+ function nivelDe(nome) {
33
+ return NIVEIS.find((n) => n.nome === normalizar(nome)) || NIVEIS[1];
34
+ }
35
+
36
+ function indiceDe(nome) {
37
+ const i = NOMES.indexOf(normalizar(nome));
38
+ return i < 0 ? 1 : i;
39
+ }
40
+
41
+ /** O apelido de modelo que este esforço usa. */
42
+ function modeloDe(nome) {
43
+ return nivelDe(nome).model;
44
+ }
45
+
46
+ /** Este esforço pesquisa referências antes de codar? */
47
+ function pesquisaDe(nome) {
48
+ return nivelDe(nome).pesquisa;
49
+ }
50
+
51
+ module.exports = { NIVEIS, NOMES, normalizar, nivelDe, indiceDe, modeloDe, pesquisaDe };
@@ -0,0 +1,288 @@
1
+ /**
2
+ * referencia.js — pesquisa de referência antes de escrever código.
3
+ *
4
+ * A ideia do dono: em vez de o agente inventar tudo da cabeça, ele consulta o
5
+ * que já existe sobre o pedido (documentação oficial, exemplos, respostas) e
6
+ * escreve com esse conhecimento na mão. A qualidade sobe porque ele para de
7
+ * chutar API que não existe e passa a seguir o padrão real da ferramenta.
8
+ *
9
+ * Duas decisões de projeto, deliberadas:
10
+ *
11
+ * 1. É VISÍVEL, não oculto. Cada pesquisa aparece no terminal e vira uma aba
12
+ * no /streaming, com as fontes. Um agente que abre coisas escondido do dono
13
+ * da máquina é um agente em que não dá para confiar — e o /streaming existe
14
+ * justamente para mostrar tudo.
15
+ *
16
+ * 2. Ele extrai CONHECIMENTO (como se faz, qual API, que cuidado tomar), não
17
+ * blocos de código para colar. Copiar trechos de terceiros para dentro do
18
+ * projeto do usuário sem olhar licença é problema jurídico dele, não nosso;
19
+ * e código colado de fontes que não conversam entre si é justamente o que
20
+ * produz o Frankenstein que não compila. O modelo lê, aprende e ESCREVE.
21
+ *
22
+ * Zero dependências: https nativo. Sem chave de API — usa as fontes abertas.
23
+ */
24
+
25
+ 'use strict';
26
+
27
+ const https = require('https');
28
+ const http = require('http');
29
+ const net = require('net');
30
+ const tls = require('tls');
31
+ const { URL } = require('url');
32
+
33
+ const TIMEOUT_MS = 12000;
34
+ const MAX_POR_FONTE = 4;
35
+ const MAX_TEXTO = 4500;
36
+
37
+ // Rede de empresa costuma exigir proxy. Sem isto, a pesquisa simplesmente não
38
+ // funciona em metade dos computadores corporativos — e o usuário nunca
39
+ // descobre por quê.
40
+ const PROXY = process.env.HTTPS_PROXY || process.env.https_proxy
41
+ || process.env.HTTP_PROXY || process.env.http_proxy || null;
42
+
43
+ /** Abre um túnel CONNECT no proxy e devolve o socket TLS pronto. */
44
+ function viaProxy(alvo, { timeout }) {
45
+ return new Promise((resolve, reject) => {
46
+ const p = new URL(PROXY);
47
+ const porta = alvo.port || 443;
48
+ const req = http.request({
49
+ host: p.hostname,
50
+ port: p.port || 80,
51
+ method: 'CONNECT',
52
+ path: `${alvo.hostname}:${porta}`,
53
+ timeout,
54
+ headers: p.username
55
+ ? { 'Proxy-Authorization': 'Basic ' + Buffer.from(`${p.username}:${p.password}`).toString('base64') }
56
+ : {},
57
+ });
58
+ req.on('connect', (res, socket) => {
59
+ if (res.statusCode !== 200) { socket.destroy(); return reject(new Error(`proxy ${res.statusCode}`)); }
60
+ resolve(tls.connect({ socket, servername: alvo.hostname }));
61
+ });
62
+ req.on('error', reject);
63
+ req.on('timeout', () => { req.destroy(); reject(new Error('timeout no proxy')); });
64
+ req.end();
65
+ });
66
+ }
67
+
68
+ function pegar(url, { timeout = TIMEOUT_MS } = {}) {
69
+ return new Promise((resolve) => {
70
+ let req;
71
+ const alvo = (() => { try { return new URL(url); } catch { return null; } })();
72
+ if (!alvo) return resolve(null);
73
+
74
+ const opcoes = {
75
+ headers: {
76
+ 'User-Agent': 'PrimoCode/1.0 (+https://github.com/ConectaPrimoAI/primocode)',
77
+ Accept: 'text/html,application/json;q=0.9,*/*;q=0.8',
78
+ },
79
+ timeout,
80
+ };
81
+
82
+ // Com proxy, a conexão vai por um túnel CONNECT; sem proxy, direto.
83
+ const abrir = PROXY && alvo.protocol === 'https:'
84
+ ? viaProxy(alvo, { timeout }).then((socket) => ({
85
+ ...opcoes, createConnection: () => socket, host: alvo.hostname, servername: alvo.hostname,
86
+ })).catch(() => null)
87
+ : Promise.resolve(opcoes);
88
+
89
+ abrir.then((cfg) => {
90
+ if (!cfg) return resolve(null);
91
+ try {
92
+ req = https.get(url, cfg, (res) => {
93
+ // Redirecionamento: segue uma vez, o suficiente para docs.
94
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
95
+ res.resume();
96
+ const destino = new URL(res.headers.location, url).href;
97
+ return pegar(destino, { timeout }).then(resolve);
98
+ }
99
+ if (res.statusCode !== 200) { res.resume(); return resolve(null); }
100
+ let corpo = '';
101
+ res.setEncoding('utf8');
102
+ res.on('data', (c) => {
103
+ corpo += c;
104
+ if (corpo.length > 400000) { req.destroy(); } // página gigante não serve
105
+ });
106
+ res.on('end', () => resolve(corpo));
107
+ res.on('error', () => resolve(null));
108
+ });
109
+ } catch { return resolve(null); }
110
+ req.on('timeout', () => { req.destroy(); resolve(null); });
111
+ req.on('error', () => resolve(null));
112
+ });
113
+ });
114
+ }
115
+
116
+ /** HTML → texto legível. Sem dependência: corta script/style e as tags. */
117
+ function textoDe(html) {
118
+ return String(html || '')
119
+ .replace(/<script[\s\S]*?<\/script>/gi, ' ')
120
+ .replace(/<style[\s\S]*?<\/style>/gi, ' ')
121
+ .replace(/<nav[\s\S]*?<\/nav>/gi, ' ')
122
+ .replace(/<footer[\s\S]*?<\/footer>/gi, ' ')
123
+ .replace(/<[^>]+>/g, ' ')
124
+ .replace(/&nbsp;/g, ' ').replace(/&amp;/g, '&').replace(/&lt;/g, '<')
125
+ .replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, "'")
126
+ .replace(/\s+/g, ' ')
127
+ .trim();
128
+ }
129
+
130
+ // Links que não são referência de nada — resultado de busca sujo.
131
+ const LIXO = /(facebook|twitter|x)\.com|instagram|pinterest|youtube\.com\/(?!watch)|\/search\?|duckduckgo|bing\.com\/(?!ck)|google\./i;
132
+
133
+ function limparLinks(pares, limite) {
134
+ const vistos = new Set();
135
+ const bons = [];
136
+ for (const { titulo, url } of pares) {
137
+ if (!/^https?:\/\//i.test(url) || LIXO.test(url)) continue;
138
+ const chave = url.replace(/[#?].*$/, '');
139
+ if (vistos.has(chave)) continue;
140
+ vistos.add(chave);
141
+ bons.push({ titulo: titulo.slice(0, 140), url });
142
+ if (bons.length >= limite) break;
143
+ }
144
+ return bons;
145
+ }
146
+
147
+ /** DuckDuckGo (HTML). O destino vem embrulhado em /l/?uddg=<url>. */
148
+ async function buscarDDG(consulta, limite) {
149
+ const html = await pegar(`https://duckduckgo.com/html/?q=${encodeURIComponent(consulta)}`);
150
+ if (!html) return [];
151
+ const pares = [];
152
+ const re = /<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
153
+ let m;
154
+ while ((m = re.exec(html))) {
155
+ let href = m[1];
156
+ const embrulho = href.match(/[?&]uddg=([^&]+)/);
157
+ if (embrulho) href = decodeURIComponent(embrulho[1]);
158
+ pares.push({ titulo: textoDe(m[2]), url: href });
159
+ }
160
+ return limparLinks(pares, limite);
161
+ }
162
+
163
+ /** Bing (HTML) — a reserva quando o DDG não responde. */
164
+ async function buscarBing(consulta, limite) {
165
+ const html = await pegar(`https://www.bing.com/search?q=${encodeURIComponent(consulta)}&setlang=en`);
166
+ if (!html) return [];
167
+ const pares = [];
168
+ const re = /<h2>\s*<a[^>]+href="(https?:\/\/[^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
169
+ let m;
170
+ while ((m = re.exec(html))) pares.push({ titulo: textoDe(m[2]), url: m[1] });
171
+ return limparLinks(pares, limite);
172
+ }
173
+
174
+ /** SearXNG público, em JSON — a terceira porta. */
175
+ async function buscarSearx(consulta, limite) {
176
+ const corpo = await pegar(`https://searx.be/search?q=${encodeURIComponent(consulta)}&format=json`);
177
+ if (!corpo) return [];
178
+ try {
179
+ const j = JSON.parse(corpo);
180
+ return limparLinks((j.results || []).map((r) => ({ titulo: String(r.title || ''), url: String(r.url || '') })), limite);
181
+ } catch { return []; }
182
+ }
183
+
184
+ /**
185
+ * Busca na web. Tenta as portas em ordem e para na primeira que responder —
186
+ * um buscador fora do ar (ou bloqueado pela rede) não pode desligar a
187
+ * pesquisa inteira. Sem chave de API em nenhuma delas.
188
+ */
189
+ async function buscar(consulta, limite = MAX_POR_FONTE) {
190
+ for (const porta of [buscarDDG, buscarBing, buscarSearx]) {
191
+ try {
192
+ const achados = await porta(consulta, limite);
193
+ if (achados.length) return achados;
194
+ } catch {}
195
+ }
196
+ return [];
197
+ }
198
+
199
+ /** Respostas do StackOverflow — a API pública, sem chave. */
200
+ async function stackoverflow(consulta, limite = 3) {
201
+ const url = 'https://api.stackexchange.com/2.3/search/advanced'
202
+ + `?order=desc&sort=relevance&q=${encodeURIComponent(consulta)}`
203
+ + '&site=stackoverflow&filter=default&pagesize=' + limite;
204
+ const corpo = await pegar(url);
205
+ if (!corpo) return [];
206
+ try {
207
+ const j = JSON.parse(corpo);
208
+ return (j.items || []).slice(0, limite).map((i) => ({
209
+ titulo: textoDe(i.title).slice(0, 140),
210
+ url: i.link,
211
+ nota: `${i.score} votos${i.is_answered ? ', respondida' : ''}`,
212
+ }));
213
+ } catch { return []; }
214
+ }
215
+
216
+ // Última porta: a documentação oficial dos assuntos mais comuns, direto. Não
217
+ // depende de buscador nenhum — é o que salva a pesquisa em rede corporativa
218
+ // que bloqueia mecanismo de busca mas libera site de documentação.
219
+ const DOCS = [
220
+ [/\b(js|javascript|array|string|promise|fetch|dom|css|html|regex)\b/i,
221
+ (q) => `https://developer.mozilla.org/en-US/search?q=${encodeURIComponent(q)}`],
222
+ [/\b(react|jsx|hook|usestate|useeffect)\b/i, () => 'https://react.dev/reference/react'],
223
+ [/\b(node|npm|express|fs|require)\b/i, () => 'https://nodejs.org/docs/latest/api/'],
224
+ [/\b(python|django|flask|pandas)\b/i,
225
+ (q) => `https://docs.python.org/3/search.html?q=${encodeURIComponent(q)}`],
226
+ ];
227
+
228
+ function docsConhecidas(consulta) {
229
+ const achados = [];
230
+ for (const [re, url] of DOCS) {
231
+ if (re.test(consulta)) achados.push({ titulo: 'documentação oficial', url: url(consulta) });
232
+ }
233
+ return achados;
234
+ }
235
+
236
+ /**
237
+ * A pesquisa completa: busca, abre as melhores páginas e devolve o texto
238
+ * delas para o modelo LER. O modelo escreve o código dele com isso na mão.
239
+ */
240
+ async function pesquisar({ consulta, paginas = 3 }) {
241
+ const termo = String(consulta || '').trim();
242
+ if (!termo) return { ok: false, error: 'diga o que pesquisar' };
243
+
244
+ const [web, so] = await Promise.all([
245
+ buscar(termo, MAX_POR_FONTE),
246
+ stackoverflow(termo, 2),
247
+ ]);
248
+
249
+ // A documentação oficial entra SEMPRE na fila, não só quando as outras
250
+ // portas vieram vazias: ela é a melhor fonte, e é o que salva a pesquisa
251
+ // quando os links do buscador não abrem (rede corporativa, site fora do
252
+ // ar, bloqueio). Foi assim que a pesquisa falhava mesmo achando links.
253
+ const candidatos = [...web, ...so, ...docsConhecidas(termo)]
254
+ .slice(0, Math.max(2, Math.min(8, paginas + 4)));
255
+ if (!candidatos.length) {
256
+ return { ok: false, error: `nada encontrado para "${termo}" (sem internet?)`, consulta: termo };
257
+ }
258
+
259
+ const lidas = [];
260
+ for (const cand of candidatos) {
261
+ if (lidas.length >= paginas) break;
262
+ const html = await pegar(cand.url);
263
+ if (!html) continue;
264
+ const texto = textoDe(html);
265
+ if (texto.length < 200) continue;
266
+ lidas.push({
267
+ titulo: cand.titulo,
268
+ url: cand.url,
269
+ nota: cand.nota,
270
+ trecho: texto.slice(0, MAX_TEXTO),
271
+ });
272
+ }
273
+
274
+ if (!lidas.length) {
275
+ return { ok: false, error: 'achei os links mas não consegui abrir nenhum', consulta: termo, links: candidatos.map((c) => c.url) };
276
+ }
277
+
278
+ return {
279
+ ok: true,
280
+ consulta: termo,
281
+ fontes: lidas.length,
282
+ referencias: lidas,
283
+ comoUsar: 'Leia, entenda o padrão e ESCREVA o seu próprio código. Não copie trechos literais: '
284
+ + 'cite a fonte se aproveitar uma ideia, e confira se a API citada existe na versão que o projeto usa.',
285
+ };
286
+ }
287
+
288
+ module.exports = { pesquisar, buscar, stackoverflow, textoDe };
package/lib/streaming.js CHANGED
@@ -26,8 +26,13 @@ const DIR = path.join(os.homedir(), '.primocode', 'streaming');
26
26
  const MAX_LOG = 300;
27
27
  // Fotos guardadas em disco para o "voltar no tempo" das abas. Além disso a
28
28
  // pasta viraria um álbum infinito — as mais velhas saem.
29
- const MAX_FRAMES = 80;
29
+ const MAX_FRAMES = 60;
30
30
  const MAX_ABAS = 12;
31
+ // Teto de disco: mesmo com poucos quadros, tela 4K dá PNG gordo. Passou
32
+ // disso, as fotos mais velhas saem até caber. 120 MB de screenshots numa
33
+ // pasta escondida é exatamente o tipo de coisa que ninguém vai limpar.
34
+ const MAX_BYTES = 120 * 1024 * 1024;
35
+ let bytesEmDisco = 0;
31
36
  // Uma foto por ação no máximo a cada 600ms: digitar letra por letra não pode
32
37
  // virar uma rajada de screenshots.
33
38
  const INTERVALO_FOTO_MS = 600;
@@ -154,6 +159,7 @@ function descrever(name, args = {}) {
154
159
  case 'run_shell': return `rodou: ${corta(args.command, 60)}`;
155
160
  case 'run_in_new_terminal': return `terminal novo: ${corta(args.command, 50)}`;
156
161
  case 'write_todos': return 'atualizou o plano';
162
+ case 'pesquisar_referencia': return `pesquisou referências: “${corta(args.consulta || args.query, 50)}”`;
157
163
  default: return name.replace(/_/g, ' ');
158
164
  }
159
165
  }
@@ -170,21 +176,69 @@ function nomeDeAba(name, args = {}) {
170
176
  try { return new URL(String(args.url)).hostname || 'navegador'; } catch { return 'navegador'; }
171
177
  }
172
178
  if (args && args.window) return String(args.window).trim();
179
+
180
+ // Trabalho que não é de janela também merece aba — senão o painel fica
181
+ // vazio de "o que ela fez" justamente no caso mais comum (mexer em
182
+ // arquivo, rodar comando, pesquisar referência).
183
+ if (['write_file', 'edit_file', 'multi_edit', 'read_file'].includes(name)) {
184
+ const arq = String(args.path || '').split(/[\\/]/).pop();
185
+ return arq ? `📄 ${arq}` : '📄 Arquivos';
186
+ }
187
+ if (['run_shell', 'run_in_new_terminal'].includes(name)) return '⌨ Terminal';
188
+ if (name === 'pesquisar_referencia') return '🔎 Pesquisa';
189
+ if (['glob', 'grep', 'list_dir'].includes(name)) return '🔍 Busca';
190
+ if (name.startsWith('browser_')) return '🌐 Navegador';
191
+ if (name.startsWith('desktop_')) return '🖥 Tela';
192
+ if (name === 'spawn_agent') return '🤖 Subagente';
173
193
  return '';
174
194
  }
175
195
 
196
+ let proximaAba = 1;
176
197
  function entrarNaAba(nome) {
177
198
  const alvo = corta(nome, 24);
178
199
  if (!alvo) return;
179
200
  let aba = state.abas.find((a) => a.nome.toLowerCase() === alvo.toLowerCase());
180
201
  if (!aba) {
181
- aba = { id: state.abas.length + 1, nome: alvo, passos: 0 };
202
+ // id de contador próprio: com `abas.length + 1`, uma aba descartada
203
+ // fazia a próxima nascer com id repetido — e os passos da nova
204
+ // apareciam dentro da antiga.
205
+ aba = { id: proximaAba++, nome: alvo, passos: 0 };
182
206
  state.abas.push(aba);
183
207
  if (state.abas.length > MAX_ABAS) state.abas.shift();
184
208
  }
185
209
  state.abaAtual = aba;
186
210
  }
187
211
 
212
+ // Guarda o quadro e cobra o aluguel: acima de MAX_FRAMES ou de MAX_BYTES, as
213
+ // fotos mais velhas são APAGADAS do disco. É o que impede a pasta de virar um
214
+ // álbum infinito na máquina do usuário.
215
+ function registrarFrame(meta, arquivo) {
216
+ let tamanho = 0;
217
+ try { tamanho = fs.statSync(arquivo).size; } catch {}
218
+ state.frames.push({ ...meta, bytes: tamanho });
219
+ bytesEmDisco += tamanho;
220
+
221
+ while (state.frames.length > MAX_FRAMES || (bytesEmDisco > MAX_BYTES && state.frames.length > 1)) {
222
+ const velho = state.frames.shift();
223
+ bytesEmDisco -= velho.bytes || 0;
224
+ try { fs.unlinkSync(path.join(DIR, `frame-${velho.id}.png`)); } catch {}
225
+ }
226
+ if (bytesEmDisco < 0) bytesEmDisco = 0;
227
+ }
228
+
229
+ /** Apaga TUDO que o painel deixou no disco. Chamado ao desligar e ao sair. */
230
+ function limparDisco() {
231
+ try {
232
+ for (const f of fs.readdirSync(DIR)) {
233
+ if (/^(frame-\d+|live|live-tmp)\.png$/.test(f)) {
234
+ try { fs.unlinkSync(path.join(DIR, f)); } catch {}
235
+ }
236
+ }
237
+ } catch {}
238
+ state.frames = [];
239
+ bytesEmDisco = 0;
240
+ }
241
+
188
242
  /**
189
243
  * A instrução que o usuário acabou de dar. A página autopreenche na barra de
190
244
  * comando, letra por letra, e marca o começo dela na linha do tempo.
@@ -210,7 +264,7 @@ function step(name, args, result) {
210
264
 
211
265
  const troca = nomeDeAba(name, args || {});
212
266
  if (troca) entrarNaAba(troca);
213
- if (!state.abaAtual && ACOES_NA_TELA.has(name)) entrarNaAba('Tela');
267
+ if (!state.abaAtual) entrarNaAba('🖥 Tela');
214
268
 
215
269
  state.passo++;
216
270
  if (state.abaAtual) state.abaAtual.passos++;
@@ -246,11 +300,7 @@ function capturar(entrada) {
246
300
  if (agora - state.liveAt < 900) {
247
301
  try { fs.copyFileSync(LIVE(), arquivo); } catch { return null; }
248
302
  state.proximoFrame++;
249
- state.frames.push({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba });
250
- while (state.frames.length > MAX_FRAMES) {
251
- const velho = state.frames.shift();
252
- try { fs.unlinkSync(path.join(DIR, `frame-${velho.id}.png`)); } catch {}
253
- }
303
+ registrarFrame({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba }, arquivo);
254
304
  return id;
255
305
  }
256
306
 
@@ -259,11 +309,7 @@ function capturar(entrada) {
259
309
 
260
310
  state.erroFoto = null;
261
311
  state.proximoFrame++;
262
- state.frames.push({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba });
263
- while (state.frames.length > MAX_FRAMES) {
264
- const velho = state.frames.shift();
265
- try { fs.unlinkSync(path.join(DIR, `frame-${velho.id}.png`)); } catch {}
266
- }
312
+ registrarFrame({ id, hora: entrada.hora, acao: entrada.acao, aba: entrada.aba }, arquivo);
267
313
  return id;
268
314
  }
269
315
 
@@ -274,11 +320,38 @@ function capturar(entrada) {
274
320
 
275
321
  // O mascote do CLI (▐▛███▜▌ …) redesenhado em SVG, célula a célula, na mesma
276
322
  // paleta: bloco cheio na cor viva, contorno na apagada.
277
- // Olhos e pés têm classe própria: a página anima (piscada e passinhos) por CSS.
278
- const MASCOTE_SVG = `<svg viewBox="0 0 9 3.6" aria-hidden="true">
279
- <g fill="#0891b2"><rect x="1" y="0" width="1" height="1"/><rect x="7" y="0" width="1" height="1"/><rect x="0" y="1" width="1" height="1" opacity=".7"/><rect x="8" y="1" width="1" height="1" opacity=".7"/><rect x="1" y="1" width="1" height="1"/><rect x="7" y="1" width="1" height="1"/></g>
280
- <g fill="#22d3ee"><rect x="2" y="0" width="5" height="1" rx=".12"/><rect x="2" y="1" width="5" height="1"/><rect class="pe pe1" x="1.9" y="2.2" width=".9" height=".5" rx=".1"/><rect class="pe pe2" x="3.4" y="2.2" width=".9" height=".5" rx=".1"/><rect class="pe pe1" x="4.9" y="2.2" width=".9" height=".5" rx=".1"/><rect class="pe pe2" x="6.4" y="2.2" width=".9" height=".5" rx=".1"/></g>
281
- <g fill="#0b1220"><rect class="olho" x="3" y=".35" width=".8" height=".5" rx=".1"/><rect class="olho" x="5.2" y=".35" width=".8" height=".5" rx=".1"/><rect x="3.9" y="1.25" width="1.2" height=".28" rx=".14"/></g>
323
+ // O mascote: um robozinho PROGRAMADOR. A cabeça é um monitor com o prompt
324
+ // "❯_" na tela (é isso que ele faz: linha de comando), antena com luz de
325
+ // atividade e dois bracinhos que digitam num tecladinho. O anterior era um
326
+ // bloco de pixels sem leitura nenhuma — não dizia "programação", não dizia
327
+ // nada. Olhos, antena, mão e cursor têm classe própria: a animação é CSS.
328
+ const MASCOTE_SVG = `<svg viewBox="0 0 44 40" aria-hidden="true">
329
+ <!-- antena com a luz de atividade -->
330
+ <line x1="22" y1="1.5" x2="22" y2="6" stroke="#0891b2" stroke-width="1.6" stroke-linecap="round"/>
331
+ <circle class="antena" cx="22" cy="2" r="2.3" fill="#22d3ee"/>
332
+ <!-- corpo/monitor -->
333
+ <rect x="4" y="6" width="36" height="24" rx="5" fill="#0e2233" stroke="#22d3ee" stroke-width="1.8"/>
334
+ <rect x="7.5" y="9.5" width="29" height="17" rx="3" fill="#071522"/>
335
+ <!-- olhos -->
336
+ <g fill="#22d3ee">
337
+ <rect class="olho" x="12" y="14" width="5" height="6" rx="2.2"/>
338
+ <rect class="olho" x="27" y="14" width="5" height="6" rx="2.2"/>
339
+ </g>
340
+ <!-- o prompt: ❯ e o cursor piscando (é um programador, afinal) -->
341
+ <path d="M12.5 23.2 l2.4 1.5 -2.4 1.5" fill="none" stroke="#5eead4" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
342
+ <rect class="cursor" x="17" y="23" width="4.5" height="3.2" rx=".6" fill="#5eead4"/>
343
+ <!-- braços + teclado -->
344
+ <g stroke="#0891b2" stroke-width="1.8" stroke-linecap="round">
345
+ <path class="braco braco1" d="M6 27 L3.5 33"/>
346
+ <path class="braco braco2" d="M38 27 L40.5 33"/>
347
+ </g>
348
+ <rect x="8" y="33" width="28" height="5" rx="2" fill="#12384d" stroke="#0891b2" stroke-width="1.2"/>
349
+ <g fill="#22d3ee" opacity=".85">
350
+ <rect class="tecla t1" x="11" y="35" width="4" height="1.6" rx=".6"/>
351
+ <rect class="tecla t2" x="17" y="35" width="4" height="1.6" rx=".6"/>
352
+ <rect class="tecla t3" x="23" y="35" width="4" height="1.6" rx=".6"/>
353
+ <rect class="tecla t1" x="29" y="35" width="4" height="1.6" rx=".6"/>
354
+ </g>
282
355
  </svg>`;
283
356
 
284
357
  // A tela do painel, na identidade do app — a MESMA paleta do design system do
@@ -288,8 +361,10 @@ const MASCOTE_SVG = `<svg viewBox="0 0 9 3.6" aria-hidden="true">
288
361
  // embaixo. Dentro da tela roda o VÍDEO ao vivo (o loop de captura acima); as
289
362
  // abas em cima são tudo que o agente abriu — clicáveis, cada uma com seus
290
363
  // quadros e passos; e a barra de comando autopreenche a instrução digitando.
364
+ // O favicon é o próprio mascote — o viewBox vem dele, não fixo (já ficou
365
+ // errado uma vez quando o desenho mudou de tamanho e o número aqui não).
291
366
  const FAVICON = 'data:image/svg+xml,' + encodeURIComponent(
292
- `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 9 3.6">${MASCOTE_SVG.replace(/^<svg[^>]*>|<\/svg>$/g, '')}</svg>`,
367
+ MASCOTE_SVG.replace('<svg ', '<svg xmlns="http://www.w3.org/2000/svg" '),
293
368
  );
294
369
 
295
370
  const PAGINA = `<!doctype html>
@@ -308,16 +383,24 @@ const PAGINA = `<!doctype html>
308
383
 
309
384
  /* ── Cabeçalho: mascote inclinado + nome, como no desenho ── */
310
385
  #topo { position: relative; width: min(1120px, 100%); padding: 44px 0 26px; text-align: center; }
311
- #topo .mascote { position: absolute; left: 4px; top: 24px; width: 104px; height: 44px; filter: drop-shadow(0 8px 20px rgba(34,211,238,.4)); animation: flutua 2.8s ease-in-out infinite; }
312
- @keyframes flutua { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(6px) } }
386
+ #topo .mascote { position: absolute; left: 8px; top: 18px; width: 62px; height: 56px; filter: drop-shadow(0 8px 20px rgba(34,211,238,.35)); animation: flutua 3s ease-in-out infinite; }
387
+ @keyframes flutua { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(5px) } }
313
388
  #topo .mascote svg { width: 100%; height: 100%; display: block; overflow: visible; }
314
- /* vivo: pisca os olhos de vez em quando e dá passinhos com os pés */
315
- .mascote .olho { transform-box: fill-box; transform-origin: center; animation: piscaOlho 3.6s infinite; }
316
- @keyframes piscaOlho { 0%, 89%, 100% { transform: scaleY(1) } 93%, 95% { transform: scaleY(.12) } }
317
- .mascote .pe { transform-box: fill-box; transform-origin: center; }
318
- .mascote .pe1 { animation: passinho 1.4s ease-in-out infinite; }
319
- .mascote .pe2 { animation: passinho 1.4s ease-in-out infinite; animation-delay: .7s; }
320
- @keyframes passinho { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(-.14px) } }
389
+ /* vivo: pisca os olhos, o cursor do prompt pisca, a antena respira e as
390
+ mãozinhas digitam no teclado */
391
+ .mascote .olho { transform-box: fill-box; transform-origin: center; animation: piscaOlho 4s infinite; }
392
+ @keyframes piscaOlho { 0%, 91%, 100% { transform: scaleY(1) } 94%, 96% { transform: scaleY(.1) } }
393
+ .mascote .cursor { animation: piscaCursor 1.05s steps(1) infinite; }
394
+ @keyframes piscaCursor { 50% { opacity: 0 } }
395
+ .mascote .antena { animation: respira 2.2s ease-in-out infinite; }
396
+ @keyframes respira { 0%, 100% { opacity: 1; r: 2.3 } 50% { opacity: .55; r: 1.9 } }
397
+ .mascote .braco { transform-box: fill-box; transform-origin: top center; }
398
+ .mascote .braco1 { animation: digita .62s ease-in-out infinite; }
399
+ .mascote .braco2 { animation: digita .62s ease-in-out infinite; animation-delay: .31s; }
400
+ @keyframes digita { 0%, 100% { transform: translateY(0) } 50% { transform: translateY(1.6px) } }
401
+ .mascote .tecla { animation: teclaAcende 1.24s steps(1) infinite; }
402
+ .mascote .t2 { animation-delay: .31s } .mascote .t3 { animation-delay: .62s }
403
+ @keyframes teclaAcende { 0%, 74% { opacity: .35 } 75%, 99% { opacity: 1 } }
321
404
  #topo h1 { font-size: clamp(34px, 5.6vw, 54px); font-weight: 650; letter-spacing: .4px; color: #f1f5f9; text-shadow: 0 4px 28px rgba(34,211,238,.22); }
322
405
  #topo h1 span { color: #22d3ee; }
323
406
  #topo .vivo { position: absolute; right: 2px; top: 48px; display: inline-flex; align-items: center; gap: 8px; color: #94a3b8; font-size: 12.5px; background: rgba(15,23,42,.85); border: 1px solid #1e293b; border-radius: 999px; padding: 6px 14px; }
@@ -645,6 +728,7 @@ function start() {
645
728
  // primeiro quadro está no ar — nada de tela vazia esperando ação.
646
729
  state.viewerAt = Date.now();
647
730
  agendarVideo(50);
731
+ armarFaxina();
648
732
  resolve(state.url);
649
733
  });
650
734
  });
@@ -656,6 +740,20 @@ function stop() {
656
740
  state.server = null;
657
741
  state.on = false;
658
742
  state.url = '';
743
+ limparDisco(); // desligou o painel, as fotos vão junto
744
+ }
745
+
746
+ // Fechou o PrimoCode (/exit, Ctrl+C, Ctrl+D): nada de print esquecido no
747
+ // disco. `exit` cobre a saída normal; os sinais cobrem o resto.
748
+ let faxinaArmada = false;
749
+ function armarFaxina() {
750
+ if (faxinaArmada) return;
751
+ faxinaArmada = true;
752
+ const faxina = () => { try { limparDisco(); } catch {} };
753
+ process.once('exit', faxina);
754
+ for (const sinal of ['SIGINT', 'SIGTERM', 'SIGHUP']) {
755
+ process.once(sinal, () => { faxina(); process.exit(0); });
756
+ }
659
757
  }
660
758
 
661
- module.exports = { start, stop, step, instrucao, isOn, url, descrever };
759
+ module.exports = { start, stop, step, instrucao, isOn, url, descrever, limparDisco };
package/lib/tools.js CHANGED
@@ -13,6 +13,7 @@ const os = require('os');
13
13
  const { exec } = require('child_process');
14
14
  const browser = require('./browser');
15
15
  const desktop = require('./desktop');
16
+ const referencia = require('./referencia');
16
17
 
17
18
  const IGNORAR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.cache', 'vendor']);
18
19
  const MAX_ARQUIVOS_VARREDURA = 5000;
@@ -388,6 +389,15 @@ async function despachar(name, args, ctx) {
388
389
  case 'write_todos':
389
390
  return writeTodos(args);
390
391
 
392
+ // Pesquisa de referência: abre documentação e exemplos sobre o
393
+ // pedido e devolve o TEXTO para o modelo aprender antes de
394
+ // escrever. Aparece no terminal e no /streaming — nada oculto.
395
+ case 'pesquisar_referencia':
396
+ return referencia.pesquisar({
397
+ consulta: args.consulta || args.query || '',
398
+ paginas: Math.max(1, Math.min(5, Number(args.paginas) || 3)),
399
+ });
400
+
391
401
  // ── Navegador controlado (CDP) — o agente age numa página real ──
392
402
  case 'browser_open': {
393
403
  const alvo = /^https?:\/\//i.test(args.url || '')
package/lib/ui.js CHANGED
@@ -157,13 +157,13 @@ const NOME = 'Primo Code';
157
157
  const TIPS = [
158
158
  'Peça um app novo, ou aponte para um repositório e mande clonar',
159
159
  '/dir <pasta> para ele trabalhar numa pasta que já é sua',
160
- '/help lista tudo · /mode troca o modo · /status confere a API',
160
+ '/help lista tudo · /effort ajusta o esforço · /streaming mostra ao vivo',
161
161
  ];
162
162
 
163
163
  const NEWS = [
164
- '/streaming agora é VÍDEO ao vivo — assista ele trabalhando, aba por aba',
165
- 'Comandos se autopreenchem: /str + Tab (ou Enter) vira /streaming',
166
- 'Ele INTERAGE com o computador: janelas, botões e ícones pelo nome',
164
+ '/effort escolhe o esforço num slider — do rápido ao mais esperto',
165
+ 'Do "high" para cima ele PESQUISA referências antes de escrever',
166
+ 'Esc Esc para o agente · /streaming mostra tudo ao vivo, e limpa os prints',
167
167
  ];
168
168
 
169
169
  // ── Cabeçalho ────────────────────────────────────────────────────────────
@@ -302,7 +302,7 @@ function actionHeader(name, args) {
302
302
  read_file:'Read', write_file:'Write', edit_file:'Edit', multi_edit:'MultiEdit',
303
303
  list_dir:'List', glob:'Glob', grep:'Grep', run_shell:'Bash',
304
304
  run_in_new_terminal:'Terminal', open_in_browser:'Open', spawn_agent:'Agent',
305
- write_todos:'Plano', finish:'Finish',
305
+ write_todos:'Plano', finish:'Finish', pesquisar_referencia:'Pesquisa',
306
306
  browser_open:'Browser', browser_click:'Browser·click', browser_type:'Browser·type',
307
307
  browser_key:'Browser·key', browser_read:'Browser·read', browser_screenshot:'Browser·shot',
308
308
  browser_scroll:'Browser·scroll', browser_wait:'Browser·wait', browser_eval:'Browser·eval',
@@ -314,7 +314,7 @@ function actionHeader(name, args) {
314
314
  desktop_elements:'Desktop·elementos', desktop_click_element:'Desktop·click',
315
315
  };
316
316
  const label = nice[name] || name;
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)) : '';
317
+ const argStr = args ? (args.consulta || args.path || args.pattern || args.command || args.target || args.url || args.selector || args.text || args.app || args.window || args.name || args.description || (args.todos ? `${args.todos.length} tarefas` : '') || (args.x != null ? `${args.x},${args.y}` : '') || args.key || JSON.stringify(args).slice(0,60)) : '';
318
318
  return ` ${c.dim('⎿')} ${c.brand(label)}${argStr ? ' '+c.muted(argStr) : ''}`;
319
319
  }
320
320
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "7.15.0",
3
+ "version": "7.16.0",
4
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": {
@@ -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/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.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 && node test/effort.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"