primocode 7.4.1 → 7.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,16 +1,13 @@
1
- # PrimoCode v7.4.0
1
+ # PrimoCode v7.7.0
2
2
 
3
3
  Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
4
4
  Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
5
5
  e lembra do que fez entre uma sessão e outra.
6
6
 
7
7
  ```
8
- ╭◠╮
9
- ╭─┴─┴─╮
10
- ◖┫ ● ● ┣◗ PrimoCode v7.4.0
11
- ┃ ‿ ┃ projeto: jogo-de-carro
12
- ╰──┬──╯
13
- ╰─┴─╯
8
+ ▐▛███▜▌ Primo Code v7.7.0
9
+ ▝▜█████▛▘ Bem-vindo de volta, Joel
10
+ ▘▘ ▝▝ ~/projetos/jogo-de-carro
14
11
  ```
15
12
 
16
13
  Personalize o mascote (`primo`, `fox`, `cat`) e o nome com que ele te chama —
@@ -26,20 +23,41 @@ veja `/mascot` e `/name`.
26
23
  | **Memória** | Cada projeto lembra o que foi feito, entre sessões | Esquece ao fechar |
27
24
  | **Instalação** | Node puro, **zero dependências** — segundos | Pacotes e dependências |
28
25
  | **Cara própria** | Mascote, nome e temas que você escolhe | Fixo |
29
- | **Custo** | Roda em modelos rápidos e baratos (Groq/Llama) | Cobrança por token da API |
26
+ | **Modelos** | Claude Opus 5, Sonnet 5 e Haiku 4.5 via OpenRouter — trocáveis com `/model` | Preso ao provedor do fabricante |
30
27
 
31
28
  Em uma frase: **é o poder de um agente de código no terminal, em português, controlando o seu computador de verdade — sem você precisar configurar chave de API nenhuma.**
32
29
 
33
30
  ## Instalação
34
31
 
32
+ **macOS / Linux / WSL** — um comando, e ele resolve tudo (inclusive instalar o Node, se faltar):
33
+
35
34
  ```bash
36
- git clone https://github.com/ConectaPrimoAI/primocode.git
37
- cd primocode
38
- npm link # registra 'primocode' globalmente
39
- primocode
35
+ curl -fsSL https://raw.githubusercontent.com/ConectaPrimoAI/primocode/main/install.sh | bash
36
+ ```
37
+
38
+ **Windows** — baixe o repositório e rode:
39
+
40
+ ```cmd
41
+ install.cmd
40
42
  ```
41
43
 
42
- Requer **Node.js 18.17+**. O CLI não tem dependências — só a stdlib do Node.
44
+ Já tem Node 18.17+? Então basta:
45
+
46
+ ```bash
47
+ npm install -g primocode
48
+ ```
49
+
50
+ Requer **Node.js 18.17+**. O CLI não tem dependências — só a stdlib do Node, por isso instala em segundos.
51
+
52
+ ### Se der erro
53
+
54
+ | Erro | O que fazer |
55
+ |---|---|
56
+ | `command not found: npm` | Você não tem Node. Rode o `install.sh` — ele detecta, pergunta e instala pra você. |
57
+ | `EACCES ... mkdir '/usr/local/lib/node_modules'` | O npm está apontando para uma pasta do sistema. **Não use `sudo`** — rode o `install.sh`, que move o npm para `~/.npm-global`. |
58
+ | `command not found: primocode` | A instalação não terminou, ou o PATH não foi recarregado. Abra um terminal novo; se persistir, rode o `install.sh` de novo. |
59
+
60
+ O `install.sh` é seguro de rodar quantas vezes quiser — ele conserta o que estiver errado e não duplica nada.
43
61
 
44
62
  ## Como funciona
45
63
 
@@ -75,10 +93,43 @@ fim. É por isso que a IA não esquece o projeto entre sessões.
75
93
 
76
94
  | Modo | O que faz |
77
95
  |---|---|
78
- | **Primo Code** (padrão) | Agente completo — cria arquivos, roda comandos, abre o navegador |
96
+ | **Auto** (padrão) | Escolhe por pedido: conversa vai pro chat, tarefa vai pro agente |
97
+ | **Primo Code** | Agente completo — cria arquivos, roda comandos, abre o navegador |
79
98
  | **Chat** | Conversa pura, sem tools |
80
99
 
81
- Alterne com `/mode primocode` ou `/mode chat`.
100
+ Alterne com `/mode auto`, `/mode primocode` ou `/mode chat`. A escolha fica
101
+ guardada entre sessões.
102
+
103
+ **Por que o auto é o padrão.** O modo Primo Code manda o catálogo das 30
104
+ ferramentas em todo pedido — cerca de 4.700 tokens antes de você digitar a
105
+ primeira letra. Um "oi" custava o mesmo que uma refatoração, e num plano com
106
+ teto diário isso acaba a cota no meio da tarde. O auto decide na sua máquina,
107
+ sem chamar o modelo para isso, e mostra o que escolheu:
108
+
109
+ ```
110
+ Auto ❯ oi
111
+ ⎿ Chat · cumprimento
112
+
113
+ Auto·chat ❯ conserta o bug do login em src/auth.js
114
+ ⎿ Primo Code · cita arquivo ou caminho
115
+ ```
116
+
117
+ Na dúvida ele vai para o chat: errar para chat custa repetir o pedido, errar
118
+ para o agente custa token e pode escrever arquivo que ninguém pediu.
119
+
120
+ ## Onde ele trabalha
121
+
122
+ | Situação | Pasta de trabalho |
123
+ |---|---|
124
+ | Abriu o PrimoCode dentro de uma pasta | é ali que ele trabalha |
125
+ | `/dir ~/meus-projetos/site` | a pasta que **você** escolher, onde ela estiver |
126
+ | `/project loja` | cria `~/primocode/loja`, com memória própria |
127
+
128
+ `/dir` sem argumento mostra a pasta ativa e de onde ela veio. Caminho relativo
129
+ resolve contra a pasta ativa, então depois de um `/dir` o `/dir src` entra na
130
+ subpasta. Se a pasta não existir, ele pergunta antes de criar; se for a sua home
131
+ ou a raiz do disco, pergunta de novo — é onde um arquivo escrito por engano dói
132
+ mais.
82
133
 
83
134
  ## Ferramentas do agente
84
135
 
@@ -187,9 +238,10 @@ primocode -f app.js "onde está o bug?" # com arquivo anexado
187
238
 
188
239
  | Comando | Descrição |
189
240
  |---|---|
190
- | `/project` | Lista projetos. `/project <nome>` cria ou entra em um |
191
- | `/mode primocode\|chat` | Alterna o modo |
192
- | `/model main\|fast` | main = llama-3.3-70b, fast = llama-3.1-8b |
241
+ | `/dir <caminho>` | Trabalha numa pasta que já existe, onde você quiser |
242
+ | `/project` | Lista projetos. `/project <nome>` cria em `~/primocode` |
243
+ | `/mode primocode\|chat\|auto` | Alterna o modo (fica guardado) |
244
+ | `/model top\|main\|fast` | top = Opus 5, main = Sonnet 5 (padrão), fast = Haiku 4.5. Aceita qualquer ID do OpenRouter |
193
245
  | `/attach <arquivo>` | Anexa arquivo ao próximo pedido |
194
246
  | `/detach` · `/files` | Remove / lista anexos |
195
247
  | `/tree [n]` | Árvore do projeto atual |
@@ -234,7 +286,7 @@ Caminho absoluto ou com `../` é recusado — o agente não escreve fora da sua
234
286
 
235
287
  ```
236
288
  ┌────────────────┐ HTTPS/SSE ┌───────────────────┐ HTTPS ┌──────────┐
237
- │ primocode CLI │ ─────────────► │ primocode-server │ ────────► │ Groq │
289
+ │ primocode CLI │ ─────────────► │ primocode-server │ ────────► │OpenRouter│
238
290
  │ (seu terminal)│ ◄───────────── │ (Render) │ ◄──────── │ API │
239
291
  └────────────────┘ └───────────────────┘ └──────────┘
240
292
  executa as tools monta o prompt
package/bin/primocode.js CHANGED
@@ -15,6 +15,7 @@ const os = require('os');
15
15
  const pkg = require('../package.json');
16
16
  const ui = require('../lib/ui');
17
17
  const { c, welcomeBox, Spinner } = ui;
18
+ const { escolherModo } = require('../lib/modo.js');
18
19
  const api = require('../lib/api');
19
20
  const tools = require('../lib/tools');
20
21
  const cfg = require('../lib/config');
@@ -25,18 +26,21 @@ const VERSION = pkg.version;
25
26
  const state = {
26
27
  config: cfg.loadConfig(),
27
28
  history: [],
28
- mode: 'primocode',
29
+ mode: null, // 'primocode' | 'chat' | 'auto' — vem da config em main()
30
+ modoEfetivo: null, // no auto, o modo que o último pedido usou de fato
29
31
  attachedFiles: [],
30
32
  lastResponse: '',
31
33
  currentProject: null,
32
- workDir: null, // pasta onde o agente trabalha nesta sessão
34
+ workDir: null, // pasta onde o agente trabalha nesta sessão
35
+ workDirOrigem: null, // 'cwd' | '/dir' | 'projeto' — para o /dir explicar
33
36
  };
34
37
 
35
38
  const HELP = `
36
39
  ${c.bold('Comandos')}
37
- ${c.brand('/project')} lista projetos ou muda: /project <nome>
38
- ${c.brand('/mode')} alterna: /mode primocode|chat
39
- ${c.brand('/model')} alterna: /model main|fast
40
+ ${c.brand('/dir')} <caminho> trabalha numa pasta que já existe (onde você quiser)
41
+ ${c.brand('/project')} lista projetos ou cria em ~/primocode: /project <nome>
42
+ ${c.brand('/mode')} /mode primocode|chat|auto
43
+ ${c.brand('/model')} /model top|main|fast — ou um ID do OpenRouter
40
44
  ${c.brand('/attach')} <arquivo> anexa arquivo ao próximo pedido
41
45
  ${c.brand('/files')} lista anexos ${c.brand('/detach')} remove
42
46
  ${c.brand('/tree')} [n] árvore do projeto
@@ -52,14 +56,27 @@ ${c.bold('Comandos')}
52
56
  ${c.brand('/exit')} sair
53
57
 
54
58
  ${c.bold('Modos')}
55
- ${c.magenta('Primo Code')} ${c.muted('Agente completo — cria arquivos, roda comandos, abre o navegador')}
56
- ${c.brand('Chat')} ${c.muted('Conversa pura — sem tools, só diálogo')}
59
+ ${c.warn('Primo Code')} ${c.muted('Agente completo — cria arquivos, roda comandos, abre o navegador')}
60
+ ${c.brandDim('Chat')} ${c.muted('Conversa pura — sem tools, só diálogo. Gasta bem menos token')}
61
+ ${c.brand('Auto')} ${c.muted('Escolhe por pedido: conversa vai pro chat, tarefa vai pro agente')}
62
+
63
+ ${c.bold('Onde ele trabalha')}
64
+ ${c.muted('Abriu dentro de uma pasta? É ali. Senão, /dir aponta para qualquer')}
65
+ ${c.muted('pasta sua, e /project cria uma nova em ~/primocode.')}
57
66
  `;
58
67
 
59
68
  let rl = null;
60
69
 
61
70
  function makePrompt() {
62
- const modeTag = state.mode === 'primocode' ? c.magenta('Primo Code') : c.brand('Chat');
71
+ // No auto o rótulo mostra "Auto" e, depois do primeiro pedido, qual modo
72
+ // ele acabou usando — senão você nunca sabe quem respondeu.
73
+ let modeTag;
74
+ if (state.mode === 'auto') {
75
+ const ultimo = state.modoEfetivo === 'primocode' ? c.warn('primo') : state.modoEfetivo === 'chat' ? c.brandDim('chat') : '';
76
+ modeTag = c.brand('Auto') + (ultimo ? c.dim('·') + ultimo : '');
77
+ } else {
78
+ modeTag = state.mode === 'primocode' ? c.warn('Primo Code') : c.brandDim('Chat');
79
+ }
63
80
  const proj = state.currentProject ? c.dim(` ${state.currentProject}`) : '';
64
81
  const att = state.attachedFiles.length ? c.muted(` +${state.attachedFiles.length}`) : '';
65
82
  return `\n${c.muted(modeTag)}${proj}${att} ${c.brand('❯')} `;
@@ -129,6 +146,7 @@ function createPendingProject(plan) {
129
146
  const dir = cfg.createProjectDir(plan.name);
130
147
  state.workDir = dir;
131
148
  state.currentProject = path.basename(dir);
149
+ state.workDirOrigem = 'projeto';
132
150
  const rotulo = plan.similar ? 'Continuando em' : 'Projeto novo:';
133
151
  console.log(c.ok(` ${rotulo} ${state.currentProject}`) + c.muted(` (${dir})`));
134
152
  refreshPrompt();
@@ -148,7 +166,19 @@ async function sendPrompt(userText) {
148
166
  prompt = parts.join('\n\n') + '\n\n' + userText;
149
167
  }
150
168
 
151
- if (state.mode === 'primocode') {
169
+ // O modo primocode manda o schema das 30 tools em todo pedido (~4.700
170
+ // tokens). No auto, quem não vai usar ferramenta não paga por ela.
171
+ let modo = state.mode;
172
+ if (modo === 'auto') {
173
+ const escolha = escolherModo(userText);
174
+ modo = escolha.modo;
175
+ state.modoEfetivo = modo;
176
+ const rotulo = modo === 'primocode' ? c.warn('Primo Code') : c.brandDim('Chat');
177
+ console.log(c.dim(` ⎿ ${ui.stripAnsi(rotulo)} · ${escolha.motivo}`));
178
+ refreshPrompt();
179
+ }
180
+
181
+ if (modo === 'primocode') {
152
182
  const plan = planWorkDir(userText);
153
183
  const r = await runAct(prompt, {
154
184
  server: state.config.server,
@@ -232,22 +262,93 @@ async function handleSlash(text) {
232
262
  const dir = cfg.createProjectDir(rest);
233
263
  state.workDir = dir;
234
264
  state.currentProject = path.basename(dir);
265
+ state.workDirOrigem = 'projeto';
235
266
  console.log(c.ok(`Projeto: ${state.currentProject}`) + c.muted(` (${dir})`));
236
267
  refreshPrompt();
237
268
  break;
238
269
  }
239
270
 
271
+ // Antes só existia /project, que SEMPRE cria em ~/primocode/<nome>. Se a
272
+ // sua pasta já existe em outro lugar, não havia como apontar para ela.
273
+ case 'dir':
274
+ case 'cd':
275
+ case 'pasta': {
276
+ if (!rest) {
277
+ const de = { cwd: 'a pasta onde você abriu o PrimoCode', '/dir': 'você escolheu com /dir', projeto: 'projeto criado pelo PrimoCode' };
278
+ console.log(` ${c.white(shortPath(projectDir()))}`);
279
+ console.log(c.muted(` ${de[state.workDirOrigem] || 'pasta atual do terminal'}`));
280
+ console.log(c.dim(' /dir <caminho> muda · /project <nome> cria uma nova em ~/primocode'));
281
+ break;
282
+ }
283
+
284
+ // ~ e caminho relativo resolvem contra a pasta ativa, não contra o
285
+ // cwd do processo: depois de um /dir, "src" é dentro dela.
286
+ const bruto = rest.replace(/^~(?=$|[/\\])/, os.homedir());
287
+ const alvo = path.resolve(projectDir(), bruto);
288
+
289
+ if (!fs.existsSync(alvo)) {
290
+ if (!(await confirm(`A pasta ${shortPath(alvo)} não existe. Criar?`))) {
291
+ console.log(c.muted(' Nada mudou.'));
292
+ break;
293
+ }
294
+ try { fs.mkdirSync(alvo, { recursive: true }); }
295
+ catch (e) { console.log(c.err(` Não consegui criar: ${e.message}`)); break; }
296
+ } else if (!fs.statSync(alvo).isDirectory()) {
297
+ console.log(c.err(` ${shortPath(alvo)} é um arquivo, não uma pasta.`));
298
+ break;
299
+ }
300
+
301
+ // A home e a raiz do disco são recusadas por padrão em qualquer
302
+ // lugar do código — aqui o pedido é explícito, então pergunta em vez
303
+ // de recusar, mas pergunta mesmo: é onde um write_file erra feio.
304
+ if (!isUsableCwd(alvo)) {
305
+ console.log(c.warn(` ${shortPath(alvo)} é a sua home ou a raiz do disco.`));
306
+ if (!(await confirm('O agente pode criar e editar arquivos aí. Confirma?'))) {
307
+ console.log(c.muted(' Nada mudou.'));
308
+ break;
309
+ }
310
+ }
311
+
312
+ state.workDir = alvo;
313
+ state.currentProject = path.basename(alvo);
314
+ state.workDirOrigem = '/dir';
315
+ const quantos = (() => { try { return fs.readdirSync(alvo).length; } catch { return 0; } })();
316
+ console.log(c.ok(` Trabalhando em ${shortPath(alvo)}`) + c.muted(` (${quantos} ${quantos === 1 ? 'item' : 'itens'})`));
317
+ refreshPrompt();
318
+ break;
319
+ }
320
+
240
321
  case 'mode': {
241
322
  const m = rest.toLowerCase();
242
- if (['primocode', 'chat'].includes(m)) { state.mode = m; console.log(c.ok(`Modo: ${m}`)); refreshPrompt(); }
243
- else console.log(c.err('Uso: /mode primocode|chat'));
323
+ if (['primocode', 'chat', 'auto'].includes(m)) {
324
+ state.mode = m;
325
+ state.modoEfetivo = null;
326
+ cfg.saveConfig({ mode: m });
327
+ console.log(c.ok(`Modo: ${m}`) + (m === 'auto'
328
+ ? c.muted(' — escolho por pedido: conversa vai pro chat, tarefa vai pro agente')
329
+ : ''));
330
+ refreshPrompt();
331
+ } else console.log(c.err('Uso: /mode primocode|chat|auto'));
244
332
  break;
245
333
  }
246
334
 
247
335
  case 'model': {
248
336
  const m = rest.toLowerCase();
249
- if (['main', 'fast'].includes(m)) { state.config.model = m; cfg.saveConfig({ model: m }); console.log(c.ok(`Modelo: ${m}`)); }
250
- else console.log(c.err('Uso: /model main|fast'));
337
+ const nomes = {
338
+ top: 'Opus 5 — o mais capaz, e o mais caro por pedido',
339
+ main: 'Sonnet 5 — o padrão: capacidade de topo a 2,5x menos',
340
+ fast: 'Haiku 4.5 — respostas curtas e baratas',
341
+ };
342
+ if (nomes[m]) {
343
+ state.config.model = m;
344
+ cfg.saveConfig({ model: m });
345
+ console.log(c.ok(`Modelo: ${m}`) + c.muted(` ${nomes[m]}`));
346
+ } else if (m.includes('/')) {
347
+ // ID literal do OpenRouter, para quem quer sair dos três.
348
+ state.config.model = m;
349
+ cfg.saveConfig({ model: m });
350
+ console.log(c.ok(`Modelo: ${m}`) + c.muted(' precisa aceitar tools para o modo primocode'));
351
+ } else console.log(c.err('Uso: /model top|main|fast (ou um ID do OpenRouter, ex: google/gemini-2.5-pro)'));
251
352
  break;
252
353
  }
253
354
 
@@ -388,8 +489,13 @@ async function main() {
388
489
  if (isUsableCwd(process.cwd())) {
389
490
  state.workDir = process.cwd();
390
491
  state.currentProject = path.basename(state.workDir);
492
+ state.workDirOrigem = 'cwd';
391
493
  }
392
494
 
495
+ // Modo guardado entre sessões. O padrão é auto: sem ele, um "oi" gasta o
496
+ // mesmo que uma refatoração.
497
+ state.mode = ['primocode', 'chat', 'auto'].includes(state.config.mode) ? state.config.mode : 'auto';
498
+
393
499
  const args = process.argv.slice(2);
394
500
 
395
501
  // Flags padrão de CLI — funcionam em qualquer máquina, úteis em scripts/CI.
@@ -398,7 +504,7 @@ async function main() {
398
504
  process.exit(0);
399
505
  }
400
506
  if (args[0] === '--help' || args[0] === '-h') {
401
- console.log(welcomeBox(VERSION, null, { mascot: state.config.mascot, userName: state.config.userName }));
507
+ console.log(welcomeBox(VERSION, null, { mascot: state.config.mascot, userName: state.config.userName, modo: state.mode }));
402
508
  console.log(HELP);
403
509
  process.exit(0);
404
510
  }
@@ -431,6 +537,7 @@ async function main() {
431
537
  }
432
538
 
433
539
  console.log(welcomeBox(VERSION, state.workDir ? shortPath(state.workDir) : null, {
540
+ modo: state.mode,
434
541
  mascot: state.config.mascot,
435
542
  userName: state.config.userName,
436
543
  }));
package/lib/browser.js CHANGED
@@ -3,7 +3,7 @@
3
3
  // mínimo escrito à mão em cima de 'net'/'tls'.
4
4
  //
5
5
  // Como funciona:
6
- // 1) launch() abre o Chrome do usuário com --remote-debugging-port=9222
6
+ // 1) launch() abre o Chrome do usuário com --remote-debugging-port=3199
7
7
  // e um --user-data-dir dedicado (~/.primocode/chrome-profile) — assim
8
8
  // o usuário logado nesse profile persiste (login GitHub, Vercel, etc).
9
9
  // 2) attach() descobre a aba ativa em /json/list e abre um WebSocket CDP.
@@ -23,7 +23,8 @@ const crypto = require('crypto');
23
23
  const { spawn, execSync } = require('child_process');
24
24
 
25
25
  const PROFILE_DIR = path.join(os.homedir(), '.primocode', 'chrome-profile');
26
- const DEBUG_PORT = 9222;
26
+ // Dentro da faixa 3100-3199 do ambiente local, para o start.sh cuidar dela.
27
+ const DEBUG_PORT = Number(process.env.PRIMOCODE_CDP_PORT || 3199);
27
28
 
28
29
  // ---- Helpers ---------------------------------------------------
29
30
  function findChrome() {
package/lib/config.js CHANGED
@@ -166,6 +166,8 @@ function loadConfig() {
166
166
  userName: file.userName || null,
167
167
  mascot: file.mascot || 'primo',
168
168
  theme: file.theme || 'primo',
169
+ // Modo escolhido dura entre sessões: quem liga o auto quer o auto amanhã.
170
+ mode: process.env.PRIMOCODE_MODE || file.mode || 'auto',
169
171
  rootDir: ROOT_DIR,
170
172
  _file: file,
171
173
  };
@@ -175,8 +177,8 @@ function saveConfig(partial) {
175
177
  ensureDir(CONFIG_DIR);
176
178
  const cur = (() => { try { return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8')); } catch { return {}; } })();
177
179
  // Só persiste o que faz sentido guardar — nada de campos derivados.
178
- const { server, model, token, autoApply, currentProject, userName, mascot, theme } = { ...cur, ...partial };
179
- const next = { server, model, token, autoApply, currentProject, userName, mascot, theme };
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 };
180
182
  fs.writeFileSync(CONFIG_FILE, JSON.stringify(next, null, 2), 'utf8');
181
183
  return next;
182
184
  }
package/lib/modo.js ADDED
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Escolha automática de modo.
3
+ *
4
+ * O modo primocode manda o schema das 30 ferramentas em todo pedido — ~4.700
5
+ * tokens antes de você digitar a primeira letra. Um "oi" gasta o mesmo que uma
6
+ * refatoração, e num plano com teto diário isso é dinheiro jogado fora (foi
7
+ * assim que a cota de 100 mil tokens/dia acabou às 15h).
8
+ *
9
+ * O modo chat não manda schema nenhum. Então a regra é: só paga o preço das
10
+ * ferramentas quem vai usar ferramenta.
11
+ *
12
+ * A decisão é local e sem custo — nenhuma chamada ao modelo para descobrir a
13
+ * intenção, senão o remédio custaria como a doença. É um classificador de
14
+ * pistas, deliberadamente conservador: na dúvida, chat, porque errar para chat
15
+ * custa uma repetição do pedido, e errar para primocode custa tokens e pode
16
+ * escrever arquivo que ninguém pediu.
17
+ */
18
+
19
+ 'use strict';
20
+
21
+ // Verbos de tarefa no imperativo e no infinitivo, como as pessoas falam de
22
+ // verdade: "cria um site", "criar um site", "crie um site", "faz pra mim".
23
+ const VERBOS = [
24
+ 'cri[ae]r?', 'faz(er)?', 'fa[çc]a', 'escrev[ae]r?', 'ger[ae]r?', 'monta?r?',
25
+ 'consert[ae]r?', 'corrig[ei]r?', 'arrum[ae]r?', 'ajust[ae]r?',
26
+ 'rod[ae]r?', 'execut[ae]r?', 'instal[ae]r?', 'test[ae]r?',
27
+ 'abr[ae]?i?r?', 'clon[ae]r?', 'baix[ae]r?', 'public[ao]r?',
28
+ 'refator[ae]r?', 'apag[ae]r?', 'delet[ae]r?', 'remov[ae]r?', 'mov[ae]r?',
29
+ 'adicion[ae]r?', 'acrescent[ae]r?', 'mud[ae]r?', 'alter[ae]r?', 'atualiz[ae]r?',
30
+ 'implement[ae]r?', 'l[eê]i?a?r?', 'procur[ae]r?', 'busc[ae]r?', 'encontr[ae]r?',
31
+ 'renome[ai]r?', 'copi[ae]r?', 'commit[ae]r?', 'deploy',
32
+ 'sobe', 'suba', 'subir', 'empurr[ae]r?', 'mergea?r?',
33
+ ];
34
+ const RE_VERBO = new RegExp(`(^|\\s)(${VERBOS.join('|')})\\b`, 'i');
35
+
36
+ // Coisas que só existem quando há trabalho de arquivo/máquina envolvido.
37
+ 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;
39
+ const RE_PASTA = /\b(pasta|diret[óo]rio|arquivo|arquivos|reposit[óo]rio|repo|projeto)\b/i;
40
+
41
+ // Conversa: cumprimento, agradecimento, pergunta conceitual.
42
+ const RE_SAUDACAO = /^\s*(oi+|ol[áa]|e?a[íi]|bom dia|boa tarde|boa noite|tudo bem|beleza|valeu|obrigad[oa]|brigad[oa]|tchau|até|ok|okay|entendi|legal|show|massa)\b[\s!.?]*$/i;
43
+ const RE_CONCEITUAL = /\b(o que [ée]|o q [ée]|que [ée]|qual (a|o|as|os)?\s*(diferen[çc]a|melhor|sentido)|por qu[êe]|pra que serve|para que serve|como funciona|como (fa[çc]o|posso|se faz|fazer)|explica|explique|me explica|significa|vale a pena|devo usar|qual usar|prefere)\b/i;
44
+
45
+ /**
46
+ * Decide o modo de um pedido.
47
+ * Devolve { modo: 'primocode'|'chat', motivo: string }.
48
+ * O motivo existe para o CLI mostrar por que escolheu — automode que decide em
49
+ * silêncio parece defeito na primeira vez que erra.
50
+ */
51
+ function escolherModo(texto) {
52
+ const t = String(texto || '').trim();
53
+ if (!t) return { modo: 'chat', motivo: 'nada a fazer' };
54
+
55
+ // Cumprimento é chat mesmo que traga um verbo depois ("oi, tudo bem?").
56
+ if (RE_SAUDACAO.test(t)) return { modo: 'chat', motivo: 'cumprimento' };
57
+
58
+ // Pergunta conceitual ganha do verbo: "como faço para criar um site?" é
59
+ // dúvida, não pedido de execução.
60
+ if (RE_CONCEITUAL.test(t)) return { modo: 'chat', motivo: 'pergunta conceitual' };
61
+
62
+ if (RE_ARTEFATO.test(t)) return { modo: 'primocode', motivo: 'cita arquivo ou caminho' };
63
+ if (RE_VERBO.test(t)) return { modo: 'primocode', motivo: 'pede uma ação' };
64
+ if (RE_FERRAMENTA.test(t)) return { modo: 'primocode', motivo: 'menciona ferramenta ou máquina' };
65
+ if (RE_PASTA.test(t)) return { modo: 'primocode', motivo: 'fala de arquivo ou projeto' };
66
+
67
+ return { modo: 'chat', motivo: 'sem pista de tarefa' };
68
+ }
69
+
70
+ module.exports = { escolherModo };
package/lib/ui.js CHANGED
@@ -6,17 +6,33 @@
6
6
 
7
7
  const readline = require('readline');
8
8
 
9
+ // ── Paleta ───────────────────────────────────────────────────────────────
10
+ // Vem do design system do produto (product-plan/design-system): primária cyan,
11
+ // secundária amber, neutra slate, e rose para falha de ação. Os números são o
12
+ // vizinho mais próximo de cada degrau do Tailwind no cubo de 256 cores do
13
+ // terminal, calculado com peso perceptual — não escolhido a olho.
14
+ //
15
+ // brand cyan-400 #22d3ee → 45 o que foi apurado, confirmado
16
+ // brandDim cyan-600 #0891b2 → 31 a mesma cor recuando
17
+ // white slate-200 #e2e8f0 → 254 texto
18
+ // muted slate-400 #94a3b8 → 247 texto secundário
19
+ // dim slate-500 #64748b → 243 borda, apoio
20
+ // warn amber-400 #fbbf24 → 214 aviso que não é falha
21
+ // err rose-400 #fb7185 → 204 falha de ação
22
+ //
23
+ // O terminal é fundo escuro, então os degraus usados são os da coluna `dark:`
24
+ // da tabela de contraste validada do design system.
9
25
  const PALETTE = {
10
- brand: '\x1b[38;5;183m',
11
- brandDim: '\x1b[38;5;146m',
12
- white: '\x1b[38;5;253m',
13
- muted: '\x1b[38;5;245m',
14
- dim: '\x1b[38;5;240m',
15
- ok: '\x1b[38;5;114m',
16
- warn: '\x1b[38;5;180m',
17
- err: '\x1b[38;5;174m',
18
- magenta: '\x1b[38;5;176m',
19
- cyan: '\x1b[38;5;152m',
26
+ brand: '\x1b[38;5;45m',
27
+ brandDim: '\x1b[38;5;31m',
28
+ white: '\x1b[38;5;254m',
29
+ muted: '\x1b[38;5;247m',
30
+ dim: '\x1b[38;5;243m',
31
+ ok: '\x1b[38;5;45m',
32
+ warn: '\x1b[38;5;214m',
33
+ err: '\x1b[38;5;204m',
34
+ magenta: '\x1b[38;5;214m',
35
+ cyan: '\x1b[38;5;45m',
20
36
  bold: '\x1b[1m',
21
37
  reset: '\x1b[0m',
22
38
  };
@@ -37,63 +53,185 @@ const c = {
37
53
  };
38
54
 
39
55
  // ── Mascotes ─────────────────────────────────────────────────────────────
40
- // Primo, o mascote — expressivo e personalizável. Fones de "coach", antena
41
- // que brilha, olhos acesos. Escolha o estilo com /mascot.
42
- // Sem emoji — só formas geométricas de largura 1, alinhadas em qualquer
43
- // terminal monospace (Windows, Linux e Mac). Cada linha tem 9 colunas.
56
+ // O mascote é um bloco fixo de 3 linhas × 9 colunas. Tamanho fixo porque ele
57
+ // não fica sozinho: as três linhas de identidade do cabeçalho (nome, saudação,
58
+ // pasta) sentam ao lado dele, uma por linha do desenho. Mudar a altura
59
+ // desalinha o cabeçalho inteiro.
60
+ //
61
+ // Só glifos de bloco (U+2580–U+259F): largura 1 garantida em qualquer terminal
62
+ // monospace, sem emoji e sem depender de fonte instalada.
63
+ //
64
+ // A família compartilha corpo e pés — o que muda é a linha de cima. É de
65
+ // propósito: são o mesmo bicho de orelhas diferentes, não três desenhos de
66
+ // autores diferentes. Troque com /mascot.
67
+
68
+ const ART_W = 9; // colunas de cada linha do desenho
69
+ const ART_H = 3; // linhas — igual ao número de linhas de identidade
70
+
71
+ const CORPO = '▝▜█████▛▘';
72
+ const PES = ' ▘▘ ▝▝ ';
73
+
44
74
  const MASCOTS = {
45
- // Padrão: robô-coach — antena que brilha, headset lateral, olhar vivo,
46
- // sorriso e pezinhos. Limpo e simétrico, 11 colunas por linha.
47
- primo: () => [
48
- ` ${c.magenta('╭')}${c.magenta('◠')}${c.magenta('╮')} `,
49
- ` ${c.brand('╭─┴─┴─╮')} `,
50
- ` ${c.cyan('◖')}${c.brand('┫')} ${c.cyan('●')} ${c.cyan('●')} ${c.brand('┣')}${c.cyan('◗')} `,
51
- ` ${c.brand('┃')} ${c.magenta('‿')} ${c.brand('┃')} `,
52
- ` ${c.brand('╰──┬──╯')} `,
53
- ` ${c.brandDim('╰─┴─╯')} `,
54
- ],
55
- // Raposa — carismática e simétrica.
56
- fox: () => [
57
- ` ${c.warn('╱╲')} ${c.warn('╱╲')} `,
58
- `${c.warn('╱')} ${c.warn('╲_╱')} ${c.warn('╲')}`,
59
- `${c.warn('▏')} ${c.cyan('◕')} ${c.cyan('◕')} ${c.warn('▕')}`,
60
- `${c.warn('╲')} ${c.magenta('▾')} ${c.warn('╱')}`,
61
- ` ${c.warn('╲__')}${c.magenta('ᵕ')}${c.warn('__╱')} `,
62
- ],
63
- // Gato — minimalista e fofo.
64
- cat: () => [
65
- ` ${c.brand('╱╲_╱╲')} `,
66
- `${c.brand('│')} ${c.cyan('◕')} ${c.magenta('◡')} ${c.cyan('◕')} ${c.brand('│')}`,
67
- ` ${c.brand('╲')} ${c.magenta('▿')} ${c.brand('╱')} `,
68
- ` ${c.brand('╲───╯')} `,
69
- ],
75
+ // Padrão: o Primo — cabeça cheia, ombros marcados, dois pezinhos.
76
+ primo: { topo: ' ▐▛███▜▌ ', full: 'brand', edge: 'brandDim' },
77
+ // Raposa — orelhas largas, abertas para fora. Na secundária do sistema.
78
+ fox: { topo: ' ▛▀ ▀▜ ', full: 'warn', edge: 'dim' },
79
+ // Gato — orelhas curtas e juntas. Primária recuada, para não repetir o Primo.
80
+ cat: { topo: ' ▛▘ ▝▜ ', full: 'brandDim', edge: 'dim' },
70
81
  };
71
82
 
83
+ // Bloco cheio na cor viva, contorno na apagada: dá volume ao desenho sem
84
+ // precisar de gradiente nem de cor de fundo.
85
+ function shade(line, full, edge) {
86
+ let out = '';
87
+ for (const ch of line) {
88
+ if (ch === ' ') out += ch;
89
+ else if (ch === '█' || ch === '▀') out += c[full](ch);
90
+ else out += c[edge](ch);
91
+ }
92
+ return out;
93
+ }
94
+
72
95
  function mascot(style) {
73
- const fn = MASCOTS[style] || MASCOTS.primo;
74
- return fn();
96
+ const m = MASCOTS[style] || MASCOTS.primo;
97
+ return [m.topo, CORPO, PES].map((l) => shade(l, m.full, m.edge));
75
98
  }
76
99
 
100
+ // ── Medição de largura ───────────────────────────────────────────────────
101
+ // Alinhar caixa em terminal exige contar colunas VISÍVEIS: os códigos ANSI
102
+ // não ocupam espaço, e emoji/CJK ocupam duas colunas. Usar .length desalinha.
103
+
104
+ const ANSI_RE = /\x1b\[[0-9;]*m/g;
105
+
106
+ const stripAnsi = (s) => String(s).replace(ANSI_RE, '');
107
+
108
+ function charWidth(cp) {
109
+ if (cp === 0x200d) return 0; // zero-width joiner
110
+ if (cp >= 0xfe00 && cp <= 0xfe0f) return 0; // seletores de variação
111
+ if (cp >= 0x0300 && cp <= 0x036f) return 0; // acentos combinantes
112
+ if (
113
+ (cp >= 0x1100 && cp <= 0x115f) ||
114
+ (cp >= 0x2e80 && cp <= 0xa4cf) ||
115
+ (cp >= 0xac00 && cp <= 0xd7a3) ||
116
+ (cp >= 0xf900 && cp <= 0xfaff) ||
117
+ (cp >= 0xfe30 && cp <= 0xfe6f) ||
118
+ (cp >= 0xff00 && cp <= 0xff60) ||
119
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
120
+ (cp >= 0x1f300 && cp <= 0x1f64f) || // emoji
121
+ (cp >= 0x1f900 && cp <= 0x1f9ff) ||
122
+ (cp >= 0x1f680 && cp <= 0x1f6ff)
123
+ ) return 2;
124
+ return 1;
125
+ }
126
+
127
+ function visibleWidth(s) {
128
+ let w = 0;
129
+ for (const ch of stripAnsi(s)) w += charWidth(ch.codePointAt(0));
130
+ return w;
131
+ }
132
+
133
+ // Corta preservando os códigos ANSI (senão a cor "vaza" para o resto da linha).
134
+ function truncate(s, max) {
135
+ if (visibleWidth(s) <= max) return s;
136
+ const str = String(s);
137
+ let out = '', w = 0, i = 0;
138
+ while (i < str.length) {
139
+ ANSI_RE.lastIndex = i;
140
+ const m = ANSI_RE.exec(str);
141
+ if (m && m.index === i) { out += m[0]; i += m[0].length; continue; }
142
+ const ch = String.fromCodePoint(str.codePointAt(i));
143
+ const cw = charWidth(ch.codePointAt(0));
144
+ if (w + cw > max - 1) break;
145
+ out += ch; w += cw; i += ch.length;
146
+ }
147
+ return out + PALETTE.reset + PALETTE.dim + '…' + PALETTE.reset;
148
+ }
149
+
150
+ const padRight = (s, w) => s + ' '.repeat(Math.max(0, w - visibleWidth(s)));
151
+
152
+ // ── Conteúdo do cabeçalho ────────────────────────────────────────────────
153
+ // Editar aqui é o suficiente para mudar o que aparece na abertura.
154
+
155
+ const NOME = 'Primo Code';
156
+
157
+ const TIPS = [
158
+ 'Peça um app novo, ou aponte para um repositório e mande clonar',
159
+ '/dir <pasta> para ele trabalhar numa pasta que já é sua',
160
+ '/help lista tudo · /mode troca o modo · /status confere a API',
161
+ ];
162
+
163
+ const NEWS = [
164
+ 'Modo auto: conversa vai pro chat, tarefa vai pro agente — gasta menos',
165
+ '/dir aponta para qualquer pasta sua, sem criar projeto novo',
166
+ 'Corrigido o 413 que fazia todo pedido do modo primocode falhar',
167
+ ];
168
+
169
+ // ── Cabeçalho ────────────────────────────────────────────────────────────
170
+ // Duas faixas: identidade (mascote + 3 linhas) e orientação (dicas,
171
+ // novidades, modos). Sem caixa — moldura em terminal estreito quebra, e o
172
+ // alinhamento do desenho já dá a borda esquerda de graça.
173
+
77
174
  function welcomeBox(version, contexto, opts = {}) {
78
- const { mascot: style, userName } = opts;
175
+ const { mascot: style, userName, modo } = opts;
79
176
  const art = mascot(style);
80
- const saudacao = userName ? `${c.white('Olá, ')}${c.bold(c.brand(userName))}` : c.muted('agente de engenharia');
81
- const side = [
82
- '',
83
- `${c.bold(c.brand('P R I M O C O D E'))}`,
84
- `${saudacao} ${c.dim('v' + version)}`,
85
- contexto ? c.brandDim('▸ ' + contexto) : c.dim('nenhuma pasta ativa'),
86
- c.dim('cria arquivos · roda comandos · controla navegador e desktop'),
87
- '',
177
+ const cols = Math.max(32, Math.min(process.stdout.columns || 100, 120));
178
+
179
+ const saudacao = userName
180
+ ? `${c.muted('Bem-vindo de volta, ')}${c.bold(c.white(userName))}`
181
+ : c.muted('Bem-vindo de volta');
182
+
183
+ // As três linhas de identidade — uma por linha do mascote, na ordem em que
184
+ // se lê: quem é, quem chegou, onde estamos.
185
+ const ident = [
186
+ `${c.bold(c.brand(NOME))} ${c.dim('v' + version)}`,
187
+ saudacao,
188
+ contexto ? c.brandDim(contexto) : c.dim('nenhuma pasta ativa'),
88
189
  ];
89
- const rows = art.map((linha, i) => ` ${linha} ${side[i] || ''}`);
90
- return [
91
- '',
92
- ...rows,
93
- '',
94
- ` ${c.magenta('●')} ${c.muted('Primo Code')} ${c.brand('●')} ${c.muted('Chat')} ${c.dim('/help')} ${c.dim('para tudo')}`,
95
- '',
96
- ].join('\n');
190
+
191
+ const out = [''];
192
+
193
+ // Terminal estreito: o texto ao lado não caberia, então empilha embaixo.
194
+ const largo = cols >= ART_W + 2 + 28;
195
+ if (largo) {
196
+ const textW = cols - ART_W - 2;
197
+ for (let i = 0; i < ART_H; i++) {
198
+ out.push(art[i] + ' ' + truncate(ident[i] || '', textW));
199
+ }
200
+ } else {
201
+ out.push(...art);
202
+ out.push('');
203
+ out.push(...ident.map((l) => truncate(l, cols)));
204
+ }
205
+
206
+ // Cada item ocupa 5 colunas de prefixo (" 1. "), então o texto tem cols-5.
207
+ // Estourar por uma coluna quebra a linha e o cabeçalho parece defeito.
208
+ const secao = (titulo, itens, marca) => {
209
+ out.push('');
210
+ out.push(' ' + c.bold(c.white(titulo)));
211
+ itens.forEach((t, i) => {
212
+ out.push(` ${c.dim(marca(i))} ${truncate(c.muted(t), cols - 5)}`);
213
+ });
214
+ };
215
+
216
+ secao('Dicas para começar', TIPS, (i) => `${i + 1}.`);
217
+ secao('Novidades', NEWS, () => ' ·');
218
+
219
+ // Rodapé: em que modo você está agora, não a lista dos que existem. Em
220
+ // terminal apertado a dica do /help sai antes de empurrar a linha para
221
+ // baixo — cabeçalho que quebra parece defeito.
222
+ // Primária para o modo ativo, secundária quando ele tem poder de escrever:
223
+ // é a mesma leitura de "apurado" × "aviso que não é falha" do sistema.
224
+ const rotulos = {
225
+ auto: `${c.brand('●')} ${c.muted('Auto')}${c.dim(' — conversa vai pro chat, tarefa vai pro agente')}`,
226
+ primocode: `${c.warn('●')} ${c.muted('Primo Code')}${c.dim(' — agente completo, com ferramentas')}`,
227
+ chat: `${c.brandDim('●')} ${c.muted('Chat')}${c.dim(' — conversa pura, sem ferramentas')}`,
228
+ };
229
+ const linhaModo = ' ' + (rotulos[modo] || rotulos.auto);
230
+ out.push('');
231
+ out.push(visibleWidth(linhaModo) <= cols ? linhaModo : ` ${c.ok('●')} ${c.muted(modo || 'auto')}`);
232
+ if (cols >= 44) out.push(` ${c.dim('/help para tudo · /mode troca o modo')}`);
233
+ out.push('');
234
+ return out.join('\n');
97
235
  }
98
236
 
99
237
  class Spinner {
@@ -167,7 +305,7 @@ function actionHeader(name, args) {
167
305
  return ` ${c.dim('⎿')} ${c.brand(label)}${argStr ? ' '+c.muted(argStr) : ''}`;
168
306
  }
169
307
 
170
- // Transforma erro cru (JSON do Groq, código HTTP, erro de rede) numa frase
308
+ // Transforma erro cru (JSON do provedor, código HTTP, erro de rede) numa frase
171
309
  // humana. O usuário nunca deveria ver um paredão de JSON.
172
310
  function friendlyError(raw) {
173
311
  const s = String(raw && raw.message ? raw.message : raw || '').trim();
@@ -183,10 +321,48 @@ function friendlyError(raw) {
183
321
  }
184
322
  } catch {}
185
323
 
186
- if (/rate.?limit|tokens per minute|tpm|request too large|too many requests|\b429\b|\b413\b/i.test(low)) {
187
- return '⏳ O servidor está no limite de uso agora. Espere alguns segundos e tente de novo.';
324
+ // Falta de saldo: nem bug, nem espera. O provedor diz quanto a conta paga,
325
+ // e o servidor já refaz o pedido com esse teto — se ainda assim chegou aqui,
326
+ // é porque nem o mínimo cabe.
327
+ if (/can only afford|requires more credits|\b402\b/i.test(low)) {
328
+ const q = s.match(/afford\s+(\d+)/i);
329
+ return '💳 A conta do OpenRouter não tem crédito para uma resposta deste tamanho'
330
+ + (q ? ` (dá para ${q[1]} tokens)` : '')
331
+ + '. Adicione crédito em openrouter.ai/settings/credits, ou use /model fast'
332
+ + ' para pedidos menores.';
333
+ }
334
+ // Ordem importa: "grande demais" e "muitos pedidos" chegam os dois como
335
+ // rate_limit_exceeded, e a saída é oposta. Tamanho não se resolve
336
+ // esperando — o pedido vai estourar igual daqui a uma hora.
337
+ const porTamanho = /request too large|tokens per minute|\btpm\b|reduce your message size|\b413\b/i.test(low)
338
+ || /conversa passou do teto/i.test(low);
339
+ if (porTamanho) {
340
+ const nums = s.match(/Limit\s+(\d+),\s*Requested\s+(\d+)/i);
341
+ if (/conversa passou do teto/i.test(low)) return `📏 ${inner.replace(/\s+/g, ' ').trim()}`;
342
+ return '📏 Este pedido é maior que o teto do plano'
343
+ + (nums ? ` (${nums[2]} tokens contra um limite de ${nums[1]} por minuto)` : '')
344
+ + '. Esperar não resolve: use /clear para encurtar a conversa, ou divida o pedido em partes menores.';
345
+ }
346
+ // Cota do dia estourada. Nada a fazer no código, mas o provedor diz quanto
347
+ // falta para liberar — jogar isso fora e escrever "alguns instantes" é
348
+ // mandar o usuário tentar de novo em vão por uma hora e meia.
349
+ if (/tokens per day|\btpd\b/i.test(low)) {
350
+ // A classe [0-9hms.] engole o ponto final da frase do provedor, então tira
351
+ // a pontuação antes de arredondar os milissegundos fora.
352
+ const bruto = (s.match(/try again in ([0-9hms.]+)/i) || [])[1];
353
+ const quando = bruto && bruto.replace(/\.+$/, '').replace(/\.\d+s$/, 's');
354
+ const uso = s.match(/Limit\s+(\d+),\s*Used\s+(\d+)/i);
355
+ return '📅 A cota de tokens do dia acabou'
356
+ + (uso ? ` (${uso[2]} de ${uso[1]} tokens)` : '')
357
+ + (quando ? `, libera em ${quando}` : '')
358
+ + '. Enquanto isso o /mode chat gasta bem menos por pergunta.';
359
+ }
360
+ if (/rate.?limit|too many requests|\b429\b/i.test(low)) {
361
+ return '⏳ Muitos pedidos em pouco tempo. Espere alguns instantes e tente de novo.';
188
362
  }
189
- if (/\b401\b|unauthorized|token/i.test(low)) {
363
+ // "token" solto não serve de pista: a mensagem de TPM tem a palavra e não
364
+ // tem nada a ver com autenticação. Exige o código ou a frase inteira.
365
+ if (/\b401\b|unauthorized|forbidden|token (inv[aá]lido|ausente|expirado)|invalid token|missing token/i.test(low)) {
190
366
  return '🔒 Este servidor pede um token. Configure PRIMOCODE_TOKEN ou "token" em ~/.primocode/config.json.';
191
367
  }
192
368
  if (/econnrefused|enotfound|etimedout|timeout|socket hang up|network|getaddrinfo|econnreset/i.test(low)) {
@@ -234,4 +410,4 @@ function askYesNo(question) {
234
410
  });
235
411
  }
236
412
 
237
- module.exports = { c, mascot, welcomeBox, Spinner, ProgressBar, actionHeader, toolLine, toolResult, fmtBytes, renderMarkdown, askYesNo, friendlyError };
413
+ module.exports = { c, mascot, welcomeBox, Spinner, ProgressBar, actionHeader, toolLine, toolResult, fmtBytes, renderMarkdown, askYesNo, friendlyError, visibleWidth, stripAnsi, truncate, padRight };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "7.4.1",
3
+ "version": "7.7.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modo primocode cria arquivos, controla o desktop e navegador. Modo chat é conversa pura.",
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"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"