primocode 7.2.0 → 7.3.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
@@ -61,6 +61,66 @@ fim. É por isso que a IA não esquece o projeto entre sessões.
61
61
 
62
62
  Alterne com `/mode primocode` ou `/mode chat`.
63
63
 
64
+ ## Ferramentas do agente
65
+
66
+ | Tool | Para quê |
67
+ |---|---|
68
+ | `write_file` | Cria arquivo novo ou reescreve inteiro |
69
+ | `edit_file` | **Troca um trecho exato.** É o que evita reescrever 800 linhas pra mudar uma |
70
+ | `read_file` | Lê o arquivo, com `offset`/`limit` para os grandes |
71
+ | `glob` | Acha arquivos por padrão: `**/*.js`, `src/**/*.{ts,tsx}` |
72
+ | `grep` | Procura texto/regex **dentro** dos arquivos, com arquivo e linha |
73
+ | `list_dir` | Árvore de arquivos |
74
+ | `run_shell` | Roda comando na pasta do projeto |
75
+ | `open_in_browser` | Abre HTML ou URL |
76
+ | `spawn_agent` | Delega uma sub-tarefa a outro agente |
77
+
78
+ O `edit_file` é rigoroso de propósito: se o trecho não existe, ou aparece mais
79
+ de uma vez sem `replace_all`, ele **recusa** e devolve as linhas reais do
80
+ arquivo — em vez de editar o lugar errado.
81
+
82
+ ## Subagentes
83
+
84
+ Para tarefas com partes independentes, o agente delega:
85
+
86
+ ```
87
+ Primo Code site ❯ crie o visual do site
88
+
89
+ ◆ Agente criar o CSS
90
+ ⎿ Write style.css
91
+ ⎿ criado · 40 B
92
+ ◆ Criei style.css com fundo #111
93
+
94
+ Pronto: CSS delegado e criado
95
+ ```
96
+
97
+ O subagente tem conversa própria — **não vê o seu diálogo** — e devolve só o
98
+ resumo final. Ele não pode criar outro subagente, então a árvore nunca cresce
99
+ sem controle.
100
+
101
+ ## Nunca "pronto!" falso
102
+
103
+ Se uma ferramenta falha e o agente tenta encerrar dizendo que deu certo, o
104
+ PrimoCode barra e devolve a tarefa para ele:
105
+
106
+ ```
107
+ ⎿ Edit app.js
108
+ ⎿ Trecho não encontrado
109
+ → agente tenta finish("Porta alterada")
110
+ → BARRADO: "NÃO terminou. Estas tools falharam..."
111
+ ⎿ Edit app.js
112
+ ⎿ 1 troca · ±0 linhas ← agora foi de verdade
113
+ ```
114
+
115
+ ## Testes
116
+
117
+ ```bash
118
+ npm test
119
+ ```
120
+
121
+ 25 verificações sobre glob, grep, edit_file, leitura por partes e bloqueio de
122
+ caminho fora do projeto.
123
+
64
124
  ## Uso
65
125
 
66
126
  ```bash
package/lib/act.js CHANGED
@@ -1,9 +1,12 @@
1
1
  // Loop do agente autônomo — streaming via /api/act/stream.
2
2
  //
3
3
  // O contrato com o servidor é uma conversa completa (`messages`, estilo OpenAI).
4
- // Cada volta do loop: o servidor devolve tool_calls -> executamos AQUI, com await,
5
- // e devolvemos o resultado na próxima volta. Sem isso o modelo fica cego e repete
6
- // a mesma tool até estourar o limite.
4
+ // Cada volta: o servidor devolve tool_calls -> executamos AQUI, com await, e
5
+ // devolvemos o resultado na volta seguinte. Sem isso o modelo fica cego e
6
+ // repete a mesma tool até estourar o limite.
7
+ //
8
+ // spawn_agent roda este mesmo loop recursivamente, com conversa própria: o
9
+ // subagente não enxerga o diálogo do usuário e devolve só o resumo final.
7
10
 
8
11
  'use strict';
9
12
 
@@ -13,40 +16,69 @@ const api = require('./api');
13
16
  const cfg = require('./config');
14
17
 
15
18
  const MAX_ITERATIONS = 15;
19
+ const MAX_PROFUNDIDADE = 2; // principal + 1 nível de subagente
20
+
21
+ function ident(profundidade) {
22
+ return ' '.repeat(profundidade);
23
+ }
16
24
 
17
- function logResult(kind, result) {
18
- const prefix = c.muted(' ⎿ ');
25
+ function logResult(kind, result, profundidade) {
26
+ const p = ident(profundidade) + c.muted(' ⎿ ');
19
27
  if (!result) return;
20
- if (result.ok === false) { console.log(prefix + c.err(result.error || `exit ${result.code ?? 'erro'}`)); return; }
28
+ if (result.ok === false) { console.log(p + c.err(result.error || `exit ${result.code ?? 'erro'}`)); return; }
21
29
 
22
30
  switch (kind) {
23
31
  case 'write_file':
24
- console.log(prefix + c.muted(`${result.created ? 'created' : 'updated'} · ${tools.fmtBytes(result.bytes)}`));
32
+ console.log(p + c.muted(`${result.created ? 'criado' : 'atualizado'} · ${tools.fmtBytes(result.bytes)}`));
33
+ return;
34
+ case 'edit_file': {
35
+ const delta = result.linhasDepois - result.linhasAntes;
36
+ const sinal = delta > 0 ? c.ok(`+${delta}`) : delta < 0 ? c.err(`${delta}`) : c.muted('±0');
37
+ console.log(p + c.muted(`${result.substituicoes} troca${result.substituicoes > 1 ? 's' : ''} · `) + sinal + c.muted(' linhas'));
25
38
  return;
39
+ }
26
40
  case 'read_file':
27
- console.log(prefix + c.muted(`${tools.fmtBytes(result.bytes || 0)}${result.truncated ? ' (truncated)' : ''}`));
41
+ console.log(p + c.muted(result.de
42
+ ? `linhas ${result.de}–${result.ate} de ${result.linhas}`
43
+ : `${result.linhas} linhas · ${tools.fmtBytes(result.bytes || 0)}${result.truncado ? ' (cortado)' : ''}`));
28
44
  return;
29
45
  case 'list_dir': {
30
- const lines = (result.tree || '').split('\n').filter(Boolean);
31
- for (const l of lines.slice(0, 8)) console.log(prefix + c.muted(l));
32
- if (lines.length > 8) console.log(prefix + c.muted(`… +${lines.length - 8}`));
46
+ const linhas = (result.tree || '').split('\n').filter(Boolean);
47
+ for (const l of linhas.slice(0, 8)) console.log(p + c.muted(l));
48
+ if (linhas.length > 8) console.log(p + c.muted(`… +${linhas.length - 8}`));
49
+ return;
50
+ }
51
+ case 'glob':
52
+ console.log(p + c.muted(`${result.total} arquivo${result.total === 1 ? '' : 's'}`));
53
+ for (const f of (result.arquivos || []).slice(0, 5)) console.log(p + c.dim(f));
54
+ if ((result.arquivos || []).length > 5) console.log(p + c.dim(`… +${result.arquivos.length - 5}`));
55
+ return;
56
+ case 'grep': {
57
+ console.log(p + c.muted(`${result.total} ocorrência${result.total === 1 ? '' : 's'} em ${result.arquivosVistos} arquivos`));
58
+ for (const r of (result.resultados || []).slice(0, 5)) {
59
+ console.log(p + c.dim(`${r.arquivo}:${r.linha}`));
60
+ }
61
+ if ((result.resultados || []).length > 5) console.log(p + c.dim(`… +${result.resultados.length - 5}`));
33
62
  return;
34
63
  }
35
- case 'run_shell':
36
- console.log(prefix + c.ok('exit 0'));
64
+ case 'run_shell': {
65
+ const saida = (result.stdout || '').trim().split('\n').filter(Boolean);
66
+ for (const l of saida.slice(0, 6)) console.log(p + c.muted(l.slice(0, 160)));
67
+ if (saida.length > 6) console.log(p + c.muted(`… +${saida.length - 6} linhas`));
68
+ if (!saida.length) console.log(p + c.ok('exit 0'));
37
69
  return;
70
+ }
38
71
  case 'open_in_browser':
39
- case 'run_in_new_terminal':
40
- console.log(prefix + c.muted(result.opener || 'ok'));
72
+ console.log(p + c.muted(result.opener || 'aberto'));
41
73
  return;
42
74
  }
43
75
  }
44
76
 
45
- // O que mandamos de volta ao modelo. Cortamos saídas gigantes para não
46
- // estourar a janela de contexto na iteração seguinte.
77
+ // O que volta para o modelo. Cortamos saídas gigantes para não estourar a
78
+ // janela de contexto na iteração seguinte.
47
79
  function summarizeForModel(result) {
48
80
  const json = JSON.stringify(result);
49
- return json.length > 4000 ? json.slice(0, 4000) + '…(truncado)' : json;
81
+ return json.length > 6000 ? json.slice(0, 6000) + '…(truncado)' : json;
50
82
  }
51
83
 
52
84
  function buildMemoryContext(projectDir, projectName) {
@@ -59,24 +91,31 @@ function buildMemoryContext(projectDir, projectName) {
59
91
  }
60
92
 
61
93
  async function runAct(userPrompt, opts) {
62
- const { server, model, token, history, projectDir, projectName, ensureWorkDir } = opts || {};
94
+ const {
95
+ server, model, token, history, projectDir, projectName,
96
+ ensureWorkDir, profundidade = 0, subagent = false,
97
+ } = opts || {};
98
+
63
99
  // A pasta só nasce quando o agente for mesmo mexer em arquivo. Um "oi"
64
100
  // não deve deixar diretório vazio espalhado pelo disco.
65
101
  let workDirReady = !ensureWorkDir;
102
+ const falhasPendentes = [];
103
+ let tentativasDeEncerrar = 0;
66
104
 
67
- // Conversa completa desta tarefa: histórico recente + memória + pedido.
68
105
  const messages = [];
69
- const memory = buildMemoryContext(projectDir, projectName);
70
- if (memory) messages.push({ role: 'system', content: `Memória persistente:\n\n${memory}` });
71
- for (const m of (history || []).slice(-20)) {
72
- if (m && m.role && m.content) messages.push({ role: m.role, content: m.content });
106
+ if (!subagent) {
107
+ const memory = buildMemoryContext(projectDir, projectName);
108
+ if (memory) messages.push({ role: 'system', content: `Memória persistente:\n\n${memory}` });
109
+ for (const m of (history || []).slice(-20)) {
110
+ if (m && m.role && m.content) messages.push({ role: m.role, content: m.content });
111
+ }
73
112
  }
74
113
  messages.push({ role: 'user', content: userPrompt });
75
114
 
76
115
  let finalText = '';
77
116
 
78
117
  for (let iteration = 0; iteration < MAX_ITERATIONS; iteration++) {
79
- const sp = new Spinner('Primo Code pensando').start();
118
+ const sp = new Spinner(subagent ? 'subagente trabalhando' : 'Primo Code pensando').start();
80
119
 
81
120
  const toolCalls = [];
82
121
  let assistantText = '';
@@ -84,24 +123,26 @@ async function runAct(userPrompt, opts) {
84
123
  let streaming = false;
85
124
 
86
125
  try {
87
- await api.requestAct(server, { messages, model, token, projectDir, projectName }, (evt) => {
126
+ await api.requestAct(server, { messages, model, token, projectDir, projectName, subagent }, (evt) => {
88
127
  if (evt.erro) { streamError = evt.erro; return; }
89
128
  if (evt.tool_call) { toolCalls.push(evt.tool_call); return; }
90
129
  if (evt.delta) {
91
130
  if (!streaming) { sp.stop(); streaming = true; }
92
131
  assistantText += evt.delta;
93
- process.stdout.write(c.white(evt.delta));
132
+ if (!subagent) process.stdout.write(c.white(evt.delta));
94
133
  }
95
134
  if (evt.assistant_message) assistantText = evt.assistant_message.content || assistantText;
96
135
  });
97
136
  } catch (e) {
98
137
  sp.stop();
138
+ if (subagent) return { assistantMessage: { role: 'assistant', content: `Subagente falhou: ${e.message || e}` } };
99
139
  console.log(c.err(`\nErro: ${e.message || e}`));
100
140
  throw e;
101
141
  }
102
142
  sp.stop();
103
143
 
104
144
  if (streamError) {
145
+ if (subagent) return { assistantMessage: { role: 'assistant', content: `Subagente falhou: ${streamError}` } };
105
146
  console.log(c.err(`\nErro: ${streamError}`));
106
147
  return { assistantMessage: { role: 'assistant', content: `Erro: ${streamError}` } };
107
148
  }
@@ -109,15 +150,17 @@ async function runAct(userPrompt, opts) {
109
150
  // Sem tool_calls: o modelo respondeu e acabou.
110
151
  if (!toolCalls.length) {
111
152
  finalText = assistantText.trim();
112
- if (finalText && !streaming) console.log('\n' + c.white(finalText));
113
- else if (streaming) process.stdout.write('\n');
153
+ if (!subagent) {
154
+ if (finalText && !streaming) console.log('\n' + c.white(finalText));
155
+ else if (streaming) process.stdout.write('\n');
156
+ }
114
157
  break;
115
158
  }
116
159
 
117
- if (streaming) process.stdout.write('\n');
160
+ if (streaming && !subagent) process.stdout.write('\n');
118
161
 
119
- // Registra na conversa o turno do assistente COM os tool_calls.
120
- // A API exige que cada mensagem role:'tool' venha depois deste turno.
162
+ // Registra o turno do assistente COM os tool_calls. A API exige que
163
+ // cada mensagem role:'tool' venha logo depois deste turno.
121
164
  messages.push({
122
165
  role: 'assistant',
123
166
  content: assistantText || null,
@@ -131,26 +174,55 @@ async function runAct(userPrompt, opts) {
131
174
  let finished = null;
132
175
  for (const tc of toolCalls) {
133
176
  if (tc.name === 'finish') {
177
+ // Encerrar com falha pendente é como o agente dizia "pronto!"
178
+ // sem ter mudado nada. Devolvemos a tarefa para ele.
179
+ if (falhasPendentes.length && tentativasDeEncerrar < 2) {
180
+ tentativasDeEncerrar++;
181
+ messages.push({
182
+ role: 'tool',
183
+ tool_call_id: tc.id,
184
+ content: `NÃO terminou. Estas tools falharam e você não corrigiu: ${falhasPendentes.join('; ')}. `
185
+ + 'Conserte agora (leia o arquivo antes de editar) ou, se for impossível, diga claramente ao usuário o que falhou.',
186
+ });
187
+ continue;
188
+ }
134
189
  finished = (tc.arguments && tc.arguments.summary) || 'Concluído.';
135
190
  messages.push({ role: 'tool', tool_call_id: tc.id, content: 'ok' });
136
191
  continue;
137
192
  }
138
193
 
194
+ if (tc.name === 'spawn_agent') {
195
+ const r = await runSubagent(tc.arguments || {}, {
196
+ server, model, token, projectDir, projectName, profundidade,
197
+ });
198
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: r });
199
+ continue;
200
+ }
201
+
139
202
  if (!workDirReady) { ensureWorkDir(); workDirReady = true; }
140
203
 
141
- console.log(actionHeader(tc.name, tc.arguments));
204
+ console.log(ident(profundidade) + actionHeader(tc.name, tc.arguments));
142
205
  const result = await tools.execute(tc.name, tc.arguments, { projectDir });
143
- logResult(tc.name, result);
206
+ logResult(tc.name, result, profundidade);
207
+
208
+ // Uma escrita bem-sucedida limpa a dívida: o agente se corrigiu.
209
+ if (result && result.ok === false) falhasPendentes.push(`${tc.name}(${tc.arguments?.path || ''}): ${result.error}`);
210
+ else if (['write_file', 'edit_file'].includes(tc.name)) falhasPendentes.length = 0;
211
+
144
212
  messages.push({ role: 'tool', tool_call_id: tc.id, content: summarizeForModel(result) });
145
213
  }
146
214
 
147
- if (finished) { finalText = finished; console.log('\n' + c.white(finished)); break; }
215
+ if (finished) {
216
+ finalText = finished;
217
+ if (!subagent) console.log('\n' + c.white(finished));
218
+ break;
219
+ }
148
220
  }
149
221
 
150
222
  if (!finalText) finalText = 'Cheguei ao limite de passos sem concluir. Refaça o pedido em partes menores.';
151
223
 
152
224
  // Nada de arquivo mexido = nada para lembrar.
153
- if (projectName && workDirReady) {
225
+ if (!subagent && projectName && workDirReady) {
154
226
  cfg.appendProjectMemory(projectDir, `**Pedido:** ${userPrompt.slice(0, 300)}\n**Resultado:** ${finalText.slice(0, 500)}`);
155
227
  }
156
228
 
@@ -160,4 +232,27 @@ async function runAct(userPrompt, opts) {
160
232
  };
161
233
  }
162
234
 
235
+ async function runSubagent(args, ctx) {
236
+ const { server, model, token, projectDir, projectName, profundidade } = ctx;
237
+ const descricao = args.description || 'sub-tarefa';
238
+
239
+ if (profundidade + 1 >= MAX_PROFUNDIDADE) {
240
+ return 'Recusado: subagente não pode criar outro subagente. Faça você mesmo.';
241
+ }
242
+ if (!args.prompt) return 'Recusado: prompt do subagente vazio.';
243
+
244
+ console.log(ident(profundidade) + ` ${c.magenta('◆')} ${c.bold(c.magenta('Agente'))} ${c.muted(descricao)}`);
245
+
246
+ const r = await runAct(args.prompt, {
247
+ server, model, token, projectDir, projectName,
248
+ history: [],
249
+ profundidade: profundidade + 1,
250
+ subagent: true,
251
+ });
252
+
253
+ const resumo = r.assistantMessage.content || 'sem resultado';
254
+ console.log(ident(profundidade) + ` ${c.magenta('◆')} ${c.muted(resumo.slice(0, 120))}`);
255
+ return resumo;
256
+ }
257
+
163
258
  module.exports = { runAct };
package/lib/tools.js CHANGED
@@ -1,147 +1,343 @@
1
- /**
2
- * Ferramentas de arquivo, shell e browser para o modo Act.
3
- */
4
-
5
- 'use strict';
6
-
7
- const fs = require('fs');
8
- const path = require('path');
9
- const { exec } = require('child_process');
10
- const os = require('os');
11
-
12
- function fmtBytes(n) { if (n<1024) return `${n} B`; if (n<1024*1024) return `${(n/1024).toFixed(1)} KB`; return `${(n/(1024*1024)).toFixed(1)} MB`; }
13
-
14
- function treeDir(dir, maxDepth=2, currentDepth=0, prefix='') {
15
- if (currentDepth>maxDepth) return '';
16
- let result='';
17
- try { const items=fs.readdirSync(dir); const dirs=[], files=[]; for (const item of items) { if (item.startsWith('.') && item!=='.env') continue; const fullPath=path.join(dir,item); const stat=fs.statSync(fullPath); if (stat.isDirectory()) dirs.push(item); else files.push(item); } dirs.sort(); files.sort(); const all=[...dirs,...files]; for (let i=0;i<all.length;i++) { const item=all[i]; const isLast=i===all.length-1; const connector=isLast?'└── ':'├── '; result+=prefix+connector+item+'\n'; const fullPath=path.join(dir,item); if (fs.statSync(fullPath).isDirectory() && currentDepth<maxDepth) { const newPrefix=prefix+(isLast?' ':'│ '); result+=treeDir(fullPath,maxDepth,currentDepth+1,newPrefix); } } } catch { return ''; }
18
- return result;
19
- }
20
-
21
- function applyCodeBlocks(response) {
22
- const regex = /```[\w]*\n([\s\S]*?)```/g;
23
- const applied = [];
24
- let match;
25
- while ((match = regex.exec(response)) !== null) {
26
- const block = match[1];
27
- const filenameMatch = block.match(/\/\/\s*filename:\s*(.+)/);
28
- if (filenameMatch) { const filename=filenameMatch[1].trim(); const content=block.replace(/\/\/\s*filename:.+\n?/,''); const created=!fs.existsSync(filename); fs.mkdirSync(path.dirname(filename),{recursive:true}); fs.writeFileSync(filename,content,'utf-8'); applied.push({path:filename,created}); }
29
- }
30
- return applied;
31
- }
32
-
33
- async function runShell(command, timeout=60000, cwd) {
34
- return new Promise((resolve) => {
35
- const child = exec(command, {timeout, cwd: cwd || process.cwd()}, (error, stdout, stderr) => {
36
- if (error) resolve({ok:false, code:error.code, stdout, stderr, killed:error.killed});
37
- else resolve({ok:true, code:0, stdout, stderr});
38
- });
39
- });
40
- }
41
-
42
- async function openInBrowser(target) {
43
- const platform=os.platform(); let cmd;
44
- if (platform==='darwin') cmd=`open "${target}"`;
45
- else if (platform==='win32') cmd=`start "" "${target}"`;
46
- else cmd=`xdg-open "${target}"`;
47
- try { await runShell(cmd); return {ok:true, opener:cmd.split(' ')[0]}; } catch (e) { return {ok:false, error:e.message}; }
48
- }
49
-
50
- async function runInNewTerminal(command, cwd) {
51
- const platform=os.platform(); let cmd;
52
- if (platform==='darwin') cmd=`osascript -e 'tell application "Terminal" to do script "cd ${cwd||'.'} && ${command}"'`;
53
- else if (platform==='win32') cmd=`start cmd /k "cd ${cwd||'.'} && ${command}"`;
54
- else { const terminals=['gnome-terminal','konsole','xterm','terminator']; let term='xterm'; for (const t of terminals) { try { require('child_process').execSync(`which ${t}`,{stdio:'ignore'}); term=t; break; } catch {} } cmd=`${term} -e "bash -c 'cd ${cwd||'.'} && ${command}; exec bash'"`; }
55
- try { await runShell(cmd); return {ok:true, opener:cmd.split(' ')[0]}; } catch (e) { return {ok:false, error:e.message}; }
56
- }
57
-
58
- // Todo caminho vindo do modelo é resolvido DENTRO da pasta do projeto.
59
- // Sem isso o agente espalha arquivo por qualquer lugar do disco — e um
60
- // "../../.." no path viraria escrita fora da área de trabalho.
61
- function resolveInProject(rawPath, projectDir) {
62
- const base = projectDir || process.cwd();
63
- const target = path.resolve(base, rawPath || '.');
64
- const rel = path.relative(base, target);
65
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
66
- throw new Error(`Caminho fora do projeto: ${rawPath}`);
67
- }
68
- return target;
69
- }
70
-
71
- async function execute(name, args, ctx) {
72
- const projectDir = (ctx && ctx.projectDir) || process.cwd();
73
- args = args || {};
74
-
75
- try {
76
- switch (name) {
77
- case 'write_file': {
78
- const filePath = resolveInProject(args.path, projectDir);
79
- const content = args.content || '';
80
- const created = !fs.existsSync(filePath);
81
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
82
- fs.writeFileSync(filePath, content, 'utf-8');
83
- return { ok: true, created, path: path.relative(projectDir, filePath), bytes: content.length };
84
- }
85
- case 'read_file': {
86
- const filePath = resolveInProject(args.path, projectDir);
87
- const content = fs.readFileSync(filePath, 'utf-8');
88
- const truncated = content.length > 10000;
89
- return { ok: true, content: truncated ? content.slice(0, 10000) : content, bytes: content.length, truncated };
90
- }
91
- case 'list_dir': {
92
- const dirPath = resolveInProject(args.path, projectDir);
93
- return { ok: true, tree: treeDir(dirPath, 2) };
94
- }
95
- case 'run_shell':
96
- return runShell(args.command, args.timeout_ms || 60000, projectDir);
97
- case 'run_in_new_terminal':
98
- return runInNewTerminal(args.command, args.cwd || projectDir);
99
- case 'open_in_browser': {
100
- const target = /^https?:\/\//i.test(args.target || '')
101
- ? args.target
102
- : resolveInProject(args.target, projectDir);
103
- return openInBrowser(target);
104
- }
105
- default:
106
- return { ok: false, error: `Tool não implementada: ${name}` };
107
- }
108
- } catch (e) {
109
- return { ok: false, error: e.message };
110
- }
111
- }
112
-
113
- async function uninstall() {
114
- const os = require('os');
115
- const path = require('path');
116
- const { execSync } = require('child_process');
117
- const fs = require('fs');
118
-
119
- const home = os.homedir();
120
- const configDir = path.join(home, '.primocode');
121
-
122
- console.log('🗑️ Removendo PrimoCode...');
123
-
124
- // Remover configurações
125
- try {
126
- if (fs.existsSync(configDir)) {
127
- fs.rmSync(configDir, { recursive: true, force: true });
128
- console.log(' ✅ Configurações removidas');
129
- }
130
- } catch (e) {
131
- console.log(' ⚠️ Não foi possível remover configurações:', e.message);
132
- }
133
-
134
- // Remover link global npm
135
- try {
136
- execSync('npm unlink -g primocode', { stdio: 'ignore' });
137
- console.log(' ✅ Link global removido');
138
- } catch {
139
- // ignora se não estava linkado
140
- }
141
-
142
- console.log('\n✅ PrimoCode removido com sucesso.');
143
- console.log(' Para reinstalar: npm install -g primocode');
144
- process.exit(0);
145
- }
146
-
147
- module.exports = { fmtBytes, treeDir, applyCodeBlocks, runShell, openInBrowser, runInNewTerminal, execute, uninstall };
1
+ /**
2
+ * Ferramentas locais do agente.
3
+ *
4
+ * Tudo roda na máquina do usuário — o servidor só decide o que chamar.
5
+ * Todo caminho é resolvido dentro da pasta de trabalho da sessão.
6
+ */
7
+
8
+ 'use strict';
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const os = require('os');
13
+ const { exec } = require('child_process');
14
+
15
+ const IGNORAR = new Set(['node_modules', '.git', 'dist', 'build', '.next', 'coverage', '.cache', 'vendor']);
16
+ const MAX_ARQUIVOS_VARREDURA = 5000;
17
+
18
+ function fmtBytes(n) {
19
+ if (n < 1024) return `${n} B`;
20
+ if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
21
+ return `${(n / (1024 * 1024)).toFixed(1)} MB`;
22
+ }
23
+
24
+ // Todo caminho vindo do modelo é resolvido DENTRO da pasta de trabalho.
25
+ // Sem isso o agente espalha arquivo por qualquer lugar do disco — e um
26
+ // "../../.." viraria escrita fora da área do projeto.
27
+ function resolveInProject(rawPath, projectDir) {
28
+ const base = projectDir || process.cwd();
29
+ const target = path.resolve(base, rawPath || '.');
30
+ const rel = path.relative(base, target);
31
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
32
+ throw new Error(`Caminho fora da pasta de trabalho: ${rawPath}`);
33
+ }
34
+ return target;
35
+ }
36
+
37
+ function treeDir(dir, maxDepth = 2, currentDepth = 0, prefix = '') {
38
+ if (currentDepth > maxDepth) return '';
39
+ let result = '';
40
+ try {
41
+ const items = fs.readdirSync(dir);
42
+ const dirs = [], files = [];
43
+ for (const item of items) {
44
+ if (item.startsWith('.') && item !== '.env') continue;
45
+ if (IGNORAR.has(item)) continue;
46
+ (fs.statSync(path.join(dir, item)).isDirectory() ? dirs : files).push(item);
47
+ }
48
+ dirs.sort(); files.sort();
49
+ const all = [...dirs, ...files];
50
+ for (let i = 0; i < all.length; i++) {
51
+ const item = all[i];
52
+ const isLast = i === all.length - 1;
53
+ result += prefix + (isLast ? '└── ' : '├── ') + item + '\n';
54
+ const full = path.join(dir, item);
55
+ if (fs.statSync(full).isDirectory() && currentDepth < maxDepth) {
56
+ result += treeDir(full, maxDepth, currentDepth + 1, prefix + (isLast ? ' ' : '│ '));
57
+ }
58
+ }
59
+ } catch { return ''; }
60
+ return result;
61
+ }
62
+
63
+ // Percorre a árvore uma vez só; grep e glob se apoiam nisto.
64
+ function* walk(dir, base, profundidade = 0) {
65
+ if (profundidade > 12) return;
66
+ let entradas;
67
+ try { entradas = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
68
+ for (const e of entradas) {
69
+ if (IGNORAR.has(e.name)) continue;
70
+ if (e.name.startsWith('.') && e.name !== '.env') continue;
71
+ const full = path.join(dir, e.name);
72
+ if (e.isDirectory()) yield* walk(full, base, profundidade + 1);
73
+ else yield path.relative(base, full).replace(/\\/g, '/');
74
+ }
75
+ }
76
+
77
+ // Glob sem dependência externa: **, *, ? e {a,b}.
78
+ function globToRegex(pattern) {
79
+ let re = '';
80
+ for (let i = 0; i < pattern.length; i++) {
81
+ const ch = pattern[i];
82
+ if (ch === '*') {
83
+ if (pattern[i + 1] === '*') {
84
+ // **/ some caminho inteiro, inclusive "nenhuma pasta"
85
+ if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 2; }
86
+ else { re += '.*'; i += 1; }
87
+ } else re += '[^/]*';
88
+ } else if (ch === '?') re += '[^/]';
89
+ else if (ch === '{') {
90
+ const fim = pattern.indexOf('}', i);
91
+ if (fim === -1) re += '\\{';
92
+ else {
93
+ re += '(?:' + pattern.slice(i + 1, fim).split(',').map((s) => s.replace(/[.+^${}()|[\]\\]/g, '\\$&')).join('|') + ')';
94
+ i = fim;
95
+ }
96
+ } else re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
97
+ }
98
+ return new RegExp(`^${re}$`, 'i');
99
+ }
100
+
101
+ function globFiles(pattern, projectDir) {
102
+ const re = globToRegex(pattern);
103
+ const achados = [];
104
+ for (const rel of walk(projectDir, projectDir)) {
105
+ if (achados.length >= MAX_ARQUIVOS_VARREDURA) break;
106
+ if (re.test(rel)) achados.push(rel);
107
+ }
108
+ return achados.sort();
109
+ }
110
+
111
+ function pareceBinario(buf) {
112
+ const amostra = buf.slice(0, 1024);
113
+ for (let i = 0; i < amostra.length; i++) if (amostra[i] === 0) return true;
114
+ return false;
115
+ }
116
+
117
+ function grepFiles({ pattern, glob, ignoreCase, contexto = 0, maxResultados = 100 }, projectDir) {
118
+ let re;
119
+ try { re = new RegExp(pattern, ignoreCase ? 'i' : ''); }
120
+ catch (e) { return { ok: false, error: `Regex inválida: ${e.message}` }; }
121
+
122
+ const filtro = glob ? globToRegex(glob) : null;
123
+ const resultados = [];
124
+ let arquivosVistos = 0;
125
+
126
+ for (const rel of walk(projectDir, projectDir)) {
127
+ if (resultados.length >= maxResultados) break;
128
+ if (filtro && !filtro.test(rel)) continue;
129
+ arquivosVistos++;
130
+
131
+ let buf;
132
+ try { buf = fs.readFileSync(path.join(projectDir, rel)); } catch { continue; }
133
+ if (buf.length > 2 * 1024 * 1024 || pareceBinario(buf)) continue;
134
+
135
+ const linhas = buf.toString('utf-8').split('\n');
136
+ for (let i = 0; i < linhas.length && resultados.length < maxResultados; i++) {
137
+ if (!re.test(linhas[i])) continue;
138
+ const de = Math.max(0, i - contexto);
139
+ const ate = Math.min(linhas.length - 1, i + contexto);
140
+ const trecho = [];
141
+ for (let j = de; j <= ate; j++) trecho.push(`${j + 1}${j === i ? ':' : '-'}${linhas[j]}`.slice(0, 300));
142
+ resultados.push({ arquivo: rel, linha: i + 1, trecho: trecho.join('\n') });
143
+ }
144
+ }
145
+
146
+ return { ok: true, arquivosVistos, total: resultados.length, resultados, truncado: resultados.length >= maxResultados };
147
+ }
148
+
149
+ async function runShell(command, timeout = 60000, cwd) {
150
+ return new Promise((resolve) => {
151
+ exec(command, { timeout, cwd: cwd || process.cwd(), maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
152
+ if (error) resolve({ ok: false, code: error.code ?? 1, stdout, stderr, killed: error.killed });
153
+ else resolve({ ok: true, code: 0, stdout, stderr });
154
+ });
155
+ });
156
+ }
157
+
158
+ async function openInBrowser(target) {
159
+ const p = os.platform();
160
+ const cmd = p === 'darwin' ? `open "${target}"` : p === 'win32' ? `start "" "${target}"` : `xdg-open "${target}"`;
161
+ const r = await runShell(cmd);
162
+ return r.ok ? { ok: true, opener: cmd.split(' ')[0] } : { ok: false, error: r.stderr || 'falhou ao abrir' };
163
+ }
164
+
165
+ async function runInNewTerminal(command, cwd) {
166
+ const p = os.platform();
167
+ let cmd;
168
+ if (p === 'darwin') cmd = `osascript -e 'tell application "Terminal" to do script "cd ${cwd || '.'} && ${command}"'`;
169
+ else if (p === 'win32') cmd = `start cmd /k "cd /d ${cwd || '.'} && ${command}"`;
170
+ else cmd = `x-terminal-emulator -e "bash -c 'cd ${cwd || '.'} && ${command}; exec bash'"`;
171
+ const r = await runShell(cmd);
172
+ return r.ok ? { ok: true, opener: cmd.split(' ')[0] } : { ok: false, error: r.stderr || 'falhou' };
173
+ }
174
+
175
+ // Quando o old_string não bate, mostra as linhas do arquivo mais parecidas —
176
+ // com número — para o modelo copiar o texto certo em vez de chutar de novo.
177
+ function trechosParecidos(conteudo, alvo, quantas = 8) {
178
+ const termos = alvo
179
+ .split(/[\s{}();,=]+/)
180
+ .map((t) => t.trim().toLowerCase())
181
+ .filter((t) => t.length > 2);
182
+ if (!termos.length) return conteudo.split('\n').slice(0, quantas).map((l, i) => `${i + 1}\t${l}`);
183
+
184
+ const linhas = conteudo.split('\n');
185
+ const pontuadas = linhas.map((linha, i) => {
186
+ const baixa = linha.toLowerCase();
187
+ let pontos = 0;
188
+ for (const t of termos) if (baixa.includes(t)) pontos++;
189
+ return { i, linha, pontos };
190
+ }).filter((x) => x.pontos > 0);
191
+
192
+ pontuadas.sort((a, b) => b.pontos - a.pontos || a.i - b.i);
193
+ return pontuadas
194
+ .slice(0, quantas)
195
+ .sort((a, b) => a.i - b.i)
196
+ .map((x) => `${x.i + 1}\t${x.linha.slice(0, 200)}`);
197
+ }
198
+
199
+ // Edição cirúrgica: troca um trecho exato em vez de reescrever o arquivo.
200
+ // É o que impede o agente de reescrever 800 linhas para mudar uma.
201
+ function editFile({ path: rawPath, old_string, new_string, replace_all }, projectDir) {
202
+ const filePath = resolveInProject(rawPath, projectDir);
203
+ if (!fs.existsSync(filePath)) return { ok: false, error: `Arquivo não existe: ${rawPath}` };
204
+ if (typeof old_string !== 'string' || !old_string) return { ok: false, error: 'old_string é obrigatório' };
205
+ if (old_string === new_string) return { ok: false, error: 'old_string e new_string são iguais' };
206
+
207
+ const original = fs.readFileSync(filePath, 'utf-8');
208
+ const ocorrencias = original.split(old_string).length - 1;
209
+
210
+ if (ocorrencias === 0) {
211
+ // Erro seco faz o modelo chutar de novo. Devolvendo o que EXISTE no
212
+ // arquivo, ele acerta na segunda tentativa.
213
+ return {
214
+ ok: false,
215
+ error: 'Trecho não encontrado — o arquivo não contém esse texto exato.',
216
+ comoCorrigir: 'Copie o old_string LITERALMENTE de "trechosParecidos" abaixo, com a mesma indentação.',
217
+ trechosParecidos: trechosParecidos(original, old_string),
218
+ };
219
+ }
220
+ if (ocorrencias > 1 && !replace_all) {
221
+ return { ok: false, error: `Trecho aparece ${ocorrencias} vezes. Inclua mais linhas em volta para ficar único, ou use replace_all.` };
222
+ }
223
+
224
+ const atualizado = replace_all
225
+ ? original.split(old_string).join(new_string ?? '')
226
+ : original.replace(old_string, new_string ?? '');
227
+
228
+ fs.writeFileSync(filePath, atualizado, 'utf-8');
229
+ return {
230
+ ok: true,
231
+ path: path.relative(projectDir, filePath).replace(/\\/g, '/'),
232
+ substituicoes: replace_all ? ocorrencias : 1,
233
+ linhasAntes: original.split('\n').length,
234
+ linhasDepois: atualizado.split('\n').length,
235
+ };
236
+ }
237
+
238
+ function readFile({ path: rawPath, offset, limit }, projectDir) {
239
+ const filePath = resolveInProject(rawPath, projectDir);
240
+ const bruto = fs.readFileSync(filePath, 'utf-8');
241
+ const todas = bruto.split('\n');
242
+
243
+ // Sem recorte pedido: devolve o começo, avisando que cortou.
244
+ if (offset == null && limit == null) {
245
+ const truncado = bruto.length > 40000;
246
+ return {
247
+ ok: true,
248
+ content: truncado ? bruto.slice(0, 40000) : bruto,
249
+ linhas: todas.length,
250
+ bytes: bruto.length,
251
+ truncado,
252
+ dica: truncado ? 'Use offset/limit para ler o resto.' : undefined,
253
+ };
254
+ }
255
+
256
+ const inicio = Math.max(0, (offset || 1) - 1);
257
+ const fim = Math.min(todas.length, inicio + (limit || 200));
258
+ const recorte = todas.slice(inicio, fim).map((l, i) => `${inicio + i + 1}\t${l}`).join('\n');
259
+ return { ok: true, content: recorte, linhas: todas.length, de: inicio + 1, ate: fim, bytes: bruto.length };
260
+ }
261
+
262
+ async function execute(name, args, ctx) {
263
+ const projectDir = (ctx && ctx.projectDir) || process.cwd();
264
+ args = args || {};
265
+
266
+ try {
267
+ switch (name) {
268
+ case 'write_file': {
269
+ const filePath = resolveInProject(args.path, projectDir);
270
+ const content = args.content || '';
271
+ const created = !fs.existsSync(filePath);
272
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
273
+ fs.writeFileSync(filePath, content, 'utf-8');
274
+ return { ok: true, created, path: path.relative(projectDir, filePath).replace(/\\/g, '/'), bytes: content.length };
275
+ }
276
+ case 'edit_file':
277
+ return editFile(args, projectDir);
278
+ case 'read_file':
279
+ return readFile(args, projectDir);
280
+ case 'list_dir':
281
+ return { ok: true, tree: treeDir(resolveInProject(args.path, projectDir), args.depth || 2) };
282
+ case 'glob': {
283
+ const arquivos = globFiles(args.pattern || '**/*', projectDir);
284
+ return { ok: true, total: arquivos.length, arquivos: arquivos.slice(0, 200) };
285
+ }
286
+ case 'grep':
287
+ return grepFiles(args, projectDir);
288
+ case 'run_shell':
289
+ return runShell(args.command, args.timeout_ms || 60000, projectDir);
290
+ case 'run_in_new_terminal':
291
+ return runInNewTerminal(args.command, args.cwd || projectDir);
292
+ case 'open_in_browser': {
293
+ const alvo = /^https?:\/\//i.test(args.target || '')
294
+ ? args.target
295
+ : resolveInProject(args.target, projectDir);
296
+ return openInBrowser(alvo);
297
+ }
298
+ default:
299
+ return { ok: false, error: `Tool não implementada: ${name}` };
300
+ }
301
+ } catch (e) {
302
+ return { ok: false, error: e.message };
303
+ }
304
+ }
305
+
306
+ function applyCodeBlocks(response) {
307
+ const regex = /```[\w]*\n([\s\S]*?)```/g;
308
+ const applied = [];
309
+ let match;
310
+ while ((match = regex.exec(response || '')) !== null) {
311
+ const block = match[1];
312
+ const nome = block.match(/\/\/\s*filename:\s*(.+)/);
313
+ if (!nome) continue;
314
+ const filename = nome[1].trim();
315
+ const content = block.replace(/\/\/\s*filename:.+\n?/, '');
316
+ const created = !fs.existsSync(filename);
317
+ fs.mkdirSync(path.dirname(filename), { recursive: true });
318
+ fs.writeFileSync(filename, content, 'utf-8');
319
+ applied.push({ path: filename, created });
320
+ }
321
+ return applied;
322
+ }
323
+
324
+ async function uninstall() {
325
+ const configDir = path.join(os.homedir(), '.primocode');
326
+ console.log('Removendo PrimoCode…');
327
+ try {
328
+ if (fs.existsSync(configDir)) {
329
+ fs.rmSync(configDir, { recursive: true, force: true });
330
+ console.log(' configurações removidas');
331
+ }
332
+ } catch (e) {
333
+ console.log(' não consegui remover as configurações:', e.message);
334
+ }
335
+ const r = await runShell('npm uninstall -g primocode');
336
+ console.log(r.ok ? ' pacote global removido' : ' remova com: npm uninstall -g primocode');
337
+ console.log('\nSeus projetos em ~/primocode continuam lá.');
338
+ }
339
+
340
+ module.exports = {
341
+ fmtBytes, treeDir, applyCodeBlocks, runShell, openInBrowser, runInNewTerminal,
342
+ execute, uninstall, resolveInProject, globToRegex, globFiles, grepFiles, editFile,
343
+ };
package/lib/ui.js CHANGED
@@ -119,19 +119,13 @@ class ProgressBar {
119
119
  }
120
120
 
121
121
  function actionHeader(name, args) {
122
- const nice = {
123
- read_file:'Read', write_file:'Write', list_dir:'List', run_shell:'Bash',
124
- run_in_new_terminal:'Terminal', open_in_browser:'Open', browser_open:'Browser',
125
- browser_navigate:'Navigate', browser_click:'Click', browser_type:'Type',
126
- browser_key:'Key', browser_wait_for:'Wait', browser_wait:'Wait',
127
- browser_screenshot:'Screenshot', browser_eval:'Eval', browser_scroll:'Scroll',
128
- browser_url:'URL', finish:'Finish',
129
- desktop_click:'Click', desktop_move:'Move', desktop_drag:'Drag',
130
- desktop_type:'Type', desktop_key:'Key', desktop_scroll:'Scroll',
131
- desktop_screenshot:'Screenshot', desktop_screen_size:'Screen',
122
+ const nice = {
123
+ read_file:'Read', write_file:'Write', edit_file:'Edit', list_dir:'List',
124
+ glob:'Glob', grep:'Grep', run_shell:'Bash', run_in_new_terminal:'Terminal',
125
+ open_in_browser:'Open', spawn_agent:'Agent', finish:'Finish',
132
126
  };
133
127
  const label = nice[name] || name;
134
- const argStr = args ? (args.path || args.command || args.target || args.url || args.selector || JSON.stringify(args).slice(0,60)) : '';
128
+ const argStr = args ? (args.path || args.pattern || args.command || args.target || args.description || JSON.stringify(args).slice(0,60)) : '';
135
129
  return ` ${c.dim('⎿')} ${c.brand(label)}${argStr ? ' '+c.muted(argStr) : ''}`;
136
130
  }
137
131
 
package/package.json CHANGED
@@ -1,13 +1,14 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "7.2.0",
3
+ "version": "7.3.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": {
7
7
  "primocode": "bin/primocode.js"
8
8
  },
9
9
  "scripts": {
10
- "start": "node bin/primocode.js"
10
+ "start": "node bin/primocode.js",
11
+ "test": "node test/tools.test.js"
11
12
  },
12
13
  "engines": {
13
14
  "node": ">=18.17.0"