primocode 1.0.1 → 4.1.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/lib/tools.js CHANGED
@@ -1,503 +1,503 @@
1
- // Ferramentas que o PrimoCode usa pra fazer as coisas.
2
- // Cada uma diz se deu certo ou nao.
3
-
4
- 'use strict';
5
-
6
- const fs = require('fs');
7
- const path = require('path');
8
- const os = require('os');
9
- const { spawn, execSync } = require('child_process');
10
- const { c } = require('./ui');
11
- const browser = require('./browser');
12
-
13
- const MAX_FILE_BYTES = 200 * 1024;
14
- const IGNORED = new Set(['node_modules', '.git', '.next', '.turbo', 'dist', 'build', 'out', '.venv', '__pycache__', '.cache']);
15
-
16
- function safePath(p) { return path.resolve(process.cwd(), p); }
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
- // ---- read_file --------------------------------------------------
25
- function readFile(rel) {
26
- try {
27
- const abs = safePath(rel);
28
- if (!fs.existsSync(abs)) return { ok: false, error: `arquivo não existe: ${rel}` };
29
- const stat = fs.statSync(abs);
30
- if (stat.isDirectory()) return { ok: false, error: `${rel} é um diretório` };
31
- let content = fs.readFileSync(abs, 'utf8');
32
- let truncated = false;
33
- if (stat.size > MAX_FILE_BYTES) {
34
- content = content.slice(0, MAX_FILE_BYTES) + `\n\n[... truncado em ${MAX_FILE_BYTES} bytes de ${stat.size} totais]`;
35
- truncated = true;
36
- }
37
- return { ok: true, path: rel, bytes: stat.size, truncated, content };
38
- } catch (e) {
39
- return { ok: false, error: e.message };
40
- }
41
- }
42
-
43
- // ---- write_file -------------------------------------------------
44
- function writeFile(rel, content) {
45
- try {
46
- const abs = safePath(rel);
47
- fs.mkdirSync(path.dirname(abs), { recursive: true });
48
- const existed = fs.existsSync(abs);
49
- fs.writeFileSync(abs, content, 'utf8');
50
- return { ok: true, path: rel, bytes: Buffer.byteLength(content, 'utf8'), created: !existed, abs };
51
- } catch (e) {
52
- return { ok: false, error: e.message };
53
- }
54
- }
55
-
56
- // ---- list_dir ---------------------------------------------------
57
- // Limites de segurança: profundidade + total de entradas + detecção de "home do usuário"
58
- const LIST_DIR_MAX_ENTRIES = 120;
59
- function isHomeDirectory(absPath) {
60
- const home = require('os').homedir();
61
- const norm = path.resolve(absPath);
62
- return norm === path.resolve(home);
63
- }
64
- function listDir(rel = '.', depth = 2) {
65
- try {
66
- const root = safePath(rel);
67
- if (!fs.existsSync(root)) return { ok: false, error: `diretório não existe: ${rel}` };
68
-
69
- // Proteção: recusa listar o home do usuário (retorna dezenas de pastas irrelevantes tipo AppData, Documents...)
70
- if (isHomeDirectory(root)) {
71
- return {
72
- ok: false,
73
- error: `list_dir do diretório home do usuário é bloqueado (retorna centenas de arquivos irrelevantes). Peça ao usuário para navegar para uma pasta específica com "cd" antes, ou crie os arquivos diretamente com write_file usando o nome do arquivo.`,
74
- is_home: true,
75
- };
76
- }
77
-
78
- const out = [];
79
- let truncated = false;
80
- function walk(dir, prefix, d) {
81
- if (d > depth || truncated) return;
82
- let entries;
83
- try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
84
- entries = entries.filter(e => !IGNORED.has(e.name) && !e.name.startsWith('.'));
85
- entries.sort((a, b) => (a.isDirectory() === b.isDirectory()) ? a.name.localeCompare(b.name) : (a.isDirectory() ? -1 : 1));
86
- for (let i = 0; i < entries.length; i++) {
87
- if (out.length >= LIST_DIR_MAX_ENTRIES) { truncated = true; return; }
88
- const e = entries[i];
89
- const last = i === entries.length - 1;
90
- out.push(prefix + (last ? '└── ' : '├── ') + e.name + (e.isDirectory() ? '/' : ''));
91
- if (e.isDirectory()) walk(path.join(dir, e.name), prefix + (last ? ' ' : '│ '), d + 1);
92
- if (truncated) return;
93
- }
94
- }
95
- out.push(path.basename(root) || rel);
96
- walk(root, '', 1);
97
- const entryCount = out.length - 1;
98
- const summary = entryCount === 0
99
- ? `Diretório '${rel}' está vazio (0 arquivos/pastas visíveis).`
100
- : `Diretório '${rel}' contém ${entryCount} entradas${truncated ? ' (TRUNCADO em ' + LIST_DIR_MAX_ENTRIES + ' — dir muito grande)' : ' (arquivos+pastas, ocultos e node_modules excluídos)'}.`;
101
- return { ok: true, path: rel, tree: out.join('\n'), summary, entryCount, truncated };
102
- } catch (e) {
103
- return { ok: false, error: e.message };
104
- }
105
- }
106
-
107
- // ---- run_shell (streaming ao vivo para stdout) ------------------
108
- function runShellStream(command, opts = {}) {
109
- return new Promise((resolve) => {
110
- const timeout = opts.timeout || 60000;
111
- const shell = process.platform === 'win32' ? true : '/bin/bash';
112
- const child = spawn(command, {
113
- shell,
114
- cwd: process.cwd(),
115
- env: process.env,
116
- stdio: ['ignore', 'pipe', 'pipe'],
117
- });
118
-
119
- let stdout = '';
120
- let stderr = '';
121
- let killed = false;
122
-
123
- const timer = setTimeout(() => {
124
- killed = true;
125
- try { child.kill('SIGKILL'); } catch {}
126
- }, timeout);
127
-
128
- const write = (data, target, color) => {
129
- const s = data.toString();
130
- if (target === 'stdout') stdout += s; else stderr += s;
131
- // stream ao vivo para o terminal do usuário
132
- const painted = color ? color(s) : s;
133
- process.stdout.write(painted);
134
- };
135
- child.stdout.on('data', d => write(d, 'stdout', null));
136
- child.stderr.on('data', d => write(d, 'stderr', c.err));
137
-
138
- child.on('close', (code) => {
139
- clearTimeout(timer);
140
- const totalOut = (stdout + stderr).slice(-4000); // limita retorno pro modelo
141
- resolve({
142
- ok: code === 0 && !killed,
143
- code: killed ? -1 : code,
144
- killed,
145
- stdout, stderr,
146
- summary: `exit=${code}${killed ? ' (timeout)' : ''}, output(last 4KB):\n${totalOut}`,
147
- });
148
- });
149
- child.on('error', (err) => {
150
- clearTimeout(timer);
151
- resolve({ ok: false, error: err.message, summary: `spawn error: ${err.message}` });
152
- });
153
- });
154
- }
155
-
156
- // versão sync usada pelo slash /run
157
- function runShellSync(command, opts = {}) {
158
- const timeout = opts.timeout || 30000;
159
- try {
160
- const stdout = execSync(command, { stdio: ['ignore', 'pipe', 'pipe'], timeout, encoding: 'utf8', cwd: process.cwd() });
161
- return { ok: true, stdout, code: 0 };
162
- } catch (err) {
163
- return {
164
- ok: false,
165
- stdout: err.stdout ? err.stdout.toString() : '',
166
- stderr: err.stderr ? err.stderr.toString() : String(err.message),
167
- code: err.status ?? 1,
168
- };
169
- }
170
- }
171
-
172
- // ---- open_in_browser (prefere Chrome) ---------------------------
173
- function findChrome() {
174
- const platform = process.platform;
175
- const candidates = platform === 'darwin' ? [
176
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
177
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
178
- ] : platform === 'win32' ? [
179
- `${process.env['PROGRAMFILES']}\\Google\\Chrome\\Application\\chrome.exe`,
180
- `${process.env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`,
181
- `${process.env['LOCALAPPDATA']}\\Google\\Chrome\\Application\\chrome.exe`,
182
- ] : [
183
- '/usr/bin/google-chrome',
184
- '/usr/bin/google-chrome-stable',
185
- '/usr/bin/chromium',
186
- '/usr/bin/chromium-browser',
187
- '/snap/bin/chromium',
188
- ];
189
- for (const p of candidates) {
190
- try { if (p && fs.existsSync(p)) return p; } catch {}
191
- }
192
- return null;
193
- }
194
-
195
- function openInBrowser(target) {
196
- try {
197
- let url = target;
198
- if (!/^https?:\/\//i.test(target)) {
199
- const abs = safePath(target);
200
- if (!fs.existsSync(abs)) return { ok: false, error: `arquivo não existe: ${target}` };
201
- url = 'file://' + abs.replace(/\\/g, '/');
202
- }
203
-
204
- const platform = process.platform;
205
- const chrome = findChrome();
206
- let cmd, args, opener;
207
-
208
- if (chrome) {
209
- cmd = chrome; args = [url]; opener = 'chrome';
210
- } else if (platform === 'darwin') {
211
- cmd = 'open'; args = [url]; opener = 'open';
212
- } else if (platform === 'win32') {
213
- cmd = 'cmd'; args = ['/c', 'start', '""', url]; opener = 'cmd start';
214
- } else {
215
- cmd = process.env.PRIMOCODE_BROWSER_OPENER || 'xdg-open';
216
- args = [url];
217
- opener = cmd;
218
- }
219
- const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
220
- child.on('error', () => {}); // não crasha se opener não existir
221
- child.unref();
222
- return { ok: true, url, opener };
223
- } catch (e) {
224
- return { ok: false, error: e.message };
225
- }
226
- }
227
-
228
- // ---- run_in_new_terminal (abre nova janela) ---------------------
229
- function findLinuxTerminal() {
230
- const list = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'terminator', 'xterm'];
231
- for (const t of list) {
232
- try {
233
- execSync(`command -v ${t}`, { stdio: 'ignore' });
234
- return t;
235
- } catch {}
236
- }
237
- return null;
238
- }
239
-
240
- function openInNewTerminal(command, cwd) {
241
- try {
242
- const workDir = cwd ? safePath(cwd) : process.cwd();
243
- if (!fs.existsSync(workDir)) return { ok: false, error: `cwd não existe: ${cwd}` };
244
-
245
- const platform = process.platform;
246
-
247
- // Windows: abrir NOVA janela do cmd.exe (não usa shell:true que travava dentro do processo pai)
248
- // Estratégia: 'cmd /c start "Título" cmd /k "…"' → o start desanexa e cria window nova.
249
- // Usamos shell:false para evitar aspas do PowerShell interpretando errado; passamos como array.
250
- if (platform === 'win32') {
251
- // Escapa aspas duplas para embutir em cmd /k
252
- const escaped = command.replace(/"/g, '""');
253
- // O truque é: cmd /c start "" cmd /k "cd /d "workdir" && comando"
254
- // "" = título vazio (senão o primeiro "..." vira título)
255
- const args = [
256
- '/c',
257
- 'start', '""', // start com título vazio → cria nova janela
258
- 'cmd', '/k', // /k mantém aberto após comando terminar
259
- `cd /d "${workDir}" && ${escaped}`,
260
- ];
261
- const child = spawn('cmd.exe', args, {
262
- detached: true,
263
- stdio: 'ignore',
264
- windowsHide: false,
265
- windowsVerbatimArguments: true,
266
- });
267
- child.on('error', () => {});
268
- child.unref();
269
- return { ok: true, opener: 'cmd (nova janela)', command, cwd: workDir };
270
- }
271
-
272
- if (platform === 'darwin') {
273
- const cwdEsc = workDir.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
274
- const cmdEsc = command.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
275
- const script = `tell application "Terminal" to activate
276
- tell application "Terminal" to do script "cd \\"${cwdEsc}\\" && ${cmdEsc}"`;
277
- const child = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
278
- child.on('error', () => {});
279
- child.unref();
280
- return { ok: true, opener: 'Terminal.app (nova janela)', command, cwd: workDir };
281
- }
282
-
283
- // Linux
284
- const term = findLinuxTerminal();
285
- if (!term) return { ok: false, error: 'nenhum terminal gráfico encontrado (gnome-terminal, konsole, xterm...)' };
286
- let args;
287
- if (term === 'gnome-terminal') {
288
- args = ['--working-directory=' + workDir, '--', 'bash', '-c', `${command}; exec bash`];
289
- } else if (term === 'konsole') {
290
- args = ['--workdir', workDir, '-e', 'bash', '-c', `${command}; exec bash`];
291
- } else if (term === 'xfce4-terminal' || term === 'terminator') {
292
- args = ['--working-directory=' + workDir, '-e', `bash -c '${command.replace(/'/g, `'\\''`)}; exec bash'`];
293
- } else {
294
- args = ['-e', `bash -c 'cd "${workDir}" && ${command.replace(/'/g, `'\\''`)}; exec bash'`];
295
- }
296
- const child = spawn(term, args, { detached: true, stdio: 'ignore' });
297
- child.on('error', () => {});
298
- child.unref();
299
- return { ok: true, opener: `${term} (nova janela)`, command, cwd: workDir };
300
- } catch (e) {
301
- return { ok: false, error: e.message };
302
- }
303
- }
304
-
305
- // ---- extractCodeBlocks (usado por /apply e /write) --------------
306
- function extractCodeBlocks(md) {
307
- const blocks = [];
308
- const re = /```([\w+.-]*)\n([\s\S]*?)```/g;
309
- let m;
310
- while ((m = re.exec(md)) !== null) {
311
- const lang = m[1] || '';
312
- let content = m[2];
313
- let filename = null;
314
- const first = content.split('\n')[0];
315
- const hint = first.match(/^(?:\/\/|#|--|<!--)\s*(?:filename|file|path)\s*[:=]\s*(.+?)\s*(?:-->)?$/i);
316
- if (hint) {
317
- filename = hint[1].trim();
318
- content = content.split('\n').slice(1).join('\n');
319
- }
320
- blocks.push({ lang, filename, content });
321
- }
322
- return blocks;
323
- }
324
-
325
- // Wrapper para /read e /write "safe" (lançam)
326
- function readFileSafe(rel) {
327
- const r = readFile(rel);
328
- if (!r.ok) throw new Error(r.error);
329
- return r.content;
330
- }
331
- function writeFileSafe(rel, content) {
332
- const r = writeFile(rel, content);
333
- if (!r.ok) throw new Error(r.error);
334
- return r;
335
- }
336
-
337
- // Executa uma tool pedida pelo modelo. Retorna { ok, text_for_model, ui_summary }.
338
- async function executeTool(name, args, log) {
339
- switch (name) {
340
- case 'read_file': {
341
- const r = readFile(args.path || '');
342
- log && log.event('read_file', args.path, r);
343
- return r.ok
344
- ? { ok: true, text_for_model: JSON.stringify({ ok: true, content: r.content, bytes: r.bytes, truncated: r.truncated }) }
345
- : { ok: false, text_for_model: JSON.stringify({ ok: false, error: r.error }) };
346
- }
347
- case 'write_file': {
348
- const r = writeFile(args.path || '', args.content ?? '');
349
- log && log.event('write_file', args.path, r);
350
- return r.ok
351
- ? { ok: true, text_for_model: JSON.stringify({ ok: true, path: r.path, bytes: r.bytes, created: r.created }) }
352
- : { ok: false, text_for_model: JSON.stringify({ ok: false, error: r.error }) };
353
- }
354
- case 'list_dir': {
355
- const r = listDir(args.path || '.');
356
- log && log.event('list_dir', args.path || '.', r);
357
- return r.ok
358
- ? { ok: true, text_for_model: JSON.stringify({ ok: true, cwd: process.cwd(), tree: r.tree, summary: r.summary, entry_count: r.entryCount }) }
359
- : { ok: false, text_for_model: JSON.stringify({ ok: false, error: r.error, hint: 'use path="." para o diretório atual' }) };
360
- }
361
- case 'run_shell': {
362
- log && log.event('run_shell', args.command, null); // header antes de executar
363
- const r = await runShellStream(args.command || '', { timeout: args.timeout_ms || 60000 });
364
- log && log.event('run_shell_done', args.command, r);
365
- return {
366
- ok: r.ok,
367
- text_for_model: JSON.stringify({ ok: r.ok, code: r.code, killed: r.killed, output: (r.stdout + r.stderr).slice(-4000) }),
368
- };
369
- }
370
- case 'open_in_browser': {
371
- const r = openInBrowser(args.target || '');
372
- log && log.event('open_in_browser', args.target, r);
373
- return r.ok
374
- ? { ok: true, text_for_model: JSON.stringify({ ok: true, url: r.url, opener: r.opener }) }
375
- : { ok: false, text_for_model: JSON.stringify({ ok: false, error: r.error }) };
376
- }
377
- case 'run_in_new_terminal': {
378
- const r = openInNewTerminal(args.command || '', args.cwd);
379
- log && log.event('run_in_new_terminal', args.command, r);
380
- return r.ok
381
- ? { ok: true, text_for_model: JSON.stringify({ ok: true, opener: r.opener, command: r.command, cwd: r.cwd }) }
382
- : { ok: false, text_for_model: JSON.stringify({ ok: false, error: r.error }) };
383
- }
384
- case 'browser_open': {
385
- try {
386
- const r = await browser.open(args.url || 'about:blank');
387
- log && log.event('browser_open', args.url, r);
388
- return { ok: true, text_for_model: JSON.stringify({ ok: true, url: r.url }) };
389
- } catch (e) {
390
- log && log.event('browser_open', args.url, { ok: false, error: e.message });
391
- return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
392
- }
393
- }
394
- case 'browser_navigate': {
395
- try {
396
- const r = await browser.navigate(args.url || '');
397
- log && log.event('browser_navigate', args.url, r);
398
- return { ok: true, text_for_model: JSON.stringify({ ok: true, url: r.url }) };
399
- } catch (e) {
400
- log && log.event('browser_navigate', args.url, { ok: false, error: e.message });
401
- return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
402
- }
403
- }
404
- case 'browser_click': {
405
- try {
406
- let r;
407
- if (args.selector) r = await browser.clickSelector(args.selector);
408
- else if (args.text) r = await browser.clickText(args.text);
409
- else if (typeof args.x === 'number' && typeof args.y === 'number') r = await browser.click(args.x, args.y);
410
- else throw new Error('informe selector, text ou (x,y)');
411
- log && log.event('browser_click', args.selector || args.text || `${args.x},${args.y}`, r);
412
- return { ok: true, text_for_model: JSON.stringify(r) };
413
- } catch (e) {
414
- log && log.event('browser_click', args.selector || args.text, { ok: false, error: e.message });
415
- return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
416
- }
417
- }
418
- case 'browser_type': {
419
- try {
420
- let r;
421
- if (args.selector) r = await browser.typeInSelector(args.selector, args.text || '', args.clear !== false);
422
- else r = await browser.type(args.text || '');
423
- log && log.event('browser_type', `${args.selector || ''} · ${(args.text || '').length} chars`, r);
424
- return { ok: true, text_for_model: JSON.stringify(r) };
425
- } catch (e) {
426
- log && log.event('browser_type', args.selector, { ok: false, error: e.message });
427
- return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
428
- }
429
- }
430
- case 'browser_key': {
431
- try {
432
- const r = await browser.key(args.key || 'Enter');
433
- log && log.event('browser_key', args.key, r);
434
- return { ok: true, text_for_model: JSON.stringify(r) };
435
- } catch (e) {
436
- return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
437
- }
438
- }
439
- case 'browser_wait_for': {
440
- try {
441
- const r = await browser.waitFor(args.selector, args.timeout_ms || 15000);
442
- log && log.event('browser_wait_for', args.selector, r);
443
- return { ok: true, text_for_model: JSON.stringify(r) };
444
- } catch (e) {
445
- return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
446
- }
447
- }
448
- case 'browser_wait': {
449
- const r = await browser.wait(args.ms || 1000);
450
- log && log.event('browser_wait', `${r.ms}ms`, r);
451
- return { ok: true, text_for_model: JSON.stringify(r) };
452
- }
453
- case 'browser_screenshot': {
454
- try {
455
- const r = await browser.screenshot(args.path || null);
456
- log && log.event('browser_screenshot', r.path, r);
457
- return { ok: true, text_for_model: JSON.stringify(r) };
458
- } catch (e) {
459
- return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
460
- }
461
- }
462
- case 'browser_eval': {
463
- try {
464
- const r = await browser.evaluate(args.js || '');
465
- log && log.event('browser_eval', (args.js || '').slice(0, 60), { ok: true });
466
- return { ok: true, text_for_model: JSON.stringify({ ok: true, value: r }) };
467
- } catch (e) {
468
- return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
469
- }
470
- }
471
- case 'browser_scroll': {
472
- try {
473
- const r = await browser.scroll(args.dy || 400);
474
- log && log.event('browser_scroll', `${args.dy}`, r);
475
- return { ok: true, text_for_model: JSON.stringify(r) };
476
- } catch (e) {
477
- return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
478
- }
479
- }
480
- case 'browser_url': {
481
- try {
482
- const u = await browser.currentUrl();
483
- return { ok: true, text_for_model: JSON.stringify({ ok: true, url: u }) };
484
- } catch (e) {
485
- return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
486
- }
487
- }
488
- case 'finish': {
489
- log && log.event('finish', args.summary, { ok: true });
490
- return { ok: true, text_for_model: JSON.stringify({ ok: true, summary: args.summary }), finish: true };
491
- }
492
- default:
493
- return { ok: false, text_for_model: JSON.stringify({ ok: false, error: `ferramenta desconhecida: ${name}` }) };
494
- }
495
- }
496
-
497
- module.exports = {
498
- readFile, writeFile, listDir, runShellStream, runShellSync,
499
- openInBrowser, openInNewTerminal, findChrome,
500
- readFileSafe, writeFileSafe,
501
- extractCodeBlocks, executeTool, fmtBytes, safePath,
502
- MAX_FILE_BYTES,
503
- };
1
+ // Ferramentas que o PrimoCode usa pra fazer as coisas.
2
+ // Cada uma diz se deu certo ou nao.
3
+
4
+ 'use strict';
5
+
6
+ const fs = require('fs');
7
+ const path = require('path');
8
+ const os = require('os');
9
+ const { spawn, execSync } = require('child_process');
10
+ const { c } = require('./ui');
11
+ const browser = require('./browser');
12
+
13
+ const MAX_FILE_BYTES = 200 * 1024;
14
+ const IGNORED = new Set(['node_modules', '.git', '.next', '.turbo', 'dist', 'build', 'out', '.venv', '__pycache__', '.cache']);
15
+
16
+ function safePath(p) { return path.resolve(process.cwd(), p); }
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
+ // ---- read_file --------------------------------------------------
25
+ function readFile(rel) {
26
+ try {
27
+ const abs = safePath(rel);
28
+ if (!fs.existsSync(abs)) return { ok: false, error: `arquivo não existe: ${rel}` };
29
+ const stat = fs.statSync(abs);
30
+ if (stat.isDirectory()) return { ok: false, error: `${rel} é um diretório` };
31
+ let content = fs.readFileSync(abs, 'utf8');
32
+ let truncated = false;
33
+ if (stat.size > MAX_FILE_BYTES) {
34
+ content = content.slice(0, MAX_FILE_BYTES) + `\n\n[... truncado em ${MAX_FILE_BYTES} bytes de ${stat.size} totais]`;
35
+ truncated = true;
36
+ }
37
+ return { ok: true, path: rel, bytes: stat.size, truncated, content };
38
+ } catch (e) {
39
+ return { ok: false, error: e.message };
40
+ }
41
+ }
42
+
43
+ // ---- write_file -------------------------------------------------
44
+ function writeFile(rel, content) {
45
+ try {
46
+ const abs = safePath(rel);
47
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
48
+ const existed = fs.existsSync(abs);
49
+ fs.writeFileSync(abs, content, 'utf8');
50
+ return { ok: true, path: rel, bytes: Buffer.byteLength(content, 'utf8'), created: !existed, abs };
51
+ } catch (e) {
52
+ return { ok: false, error: e.message };
53
+ }
54
+ }
55
+
56
+ // ---- list_dir ---------------------------------------------------
57
+ // Limites de segurança: profundidade + total de entradas + detecção de "home do usuário"
58
+ const LIST_DIR_MAX_ENTRIES = 120;
59
+ function isHomeDirectory(absPath) {
60
+ const home = require('os').homedir();
61
+ const norm = path.resolve(absPath);
62
+ return norm === path.resolve(home);
63
+ }
64
+ function listDir(rel = '.', depth = 2) {
65
+ try {
66
+ const root = safePath(rel);
67
+ if (!fs.existsSync(root)) return { ok: false, error: `diretório não existe: ${rel}` };
68
+
69
+ // Proteção: recusa listar o home do usuário (retorna dezenas de pastas irrelevantes tipo AppData, Documents...)
70
+ if (isHomeDirectory(root)) {
71
+ return {
72
+ ok: false,
73
+ error: `list_dir do diretório home do usuário é bloqueado (retorna centenas de arquivos irrelevantes). Peça ao usuário para navegar para uma pasta específica com "cd" antes, ou crie os arquivos diretamente com write_file usando o nome do arquivo.`,
74
+ is_home: true,
75
+ };
76
+ }
77
+
78
+ const out = [];
79
+ let truncated = false;
80
+ function walk(dir, prefix, d) {
81
+ if (d > depth || truncated) return;
82
+ let entries;
83
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
84
+ entries = entries.filter(e => !IGNORED.has(e.name) && !e.name.startsWith('.'));
85
+ entries.sort((a, b) => (a.isDirectory() === b.isDirectory()) ? a.name.localeCompare(b.name) : (a.isDirectory() ? -1 : 1));
86
+ for (let i = 0; i < entries.length; i++) {
87
+ if (out.length >= LIST_DIR_MAX_ENTRIES) { truncated = true; return; }
88
+ const e = entries[i];
89
+ const last = i === entries.length - 1;
90
+ out.push(prefix + (last ? '└── ' : '├── ') + e.name + (e.isDirectory() ? '/' : ''));
91
+ if (e.isDirectory()) walk(path.join(dir, e.name), prefix + (last ? ' ' : '│ '), d + 1);
92
+ if (truncated) return;
93
+ }
94
+ }
95
+ out.push(path.basename(root) || rel);
96
+ walk(root, '', 1);
97
+ const entryCount = out.length - 1;
98
+ const summary = entryCount === 0
99
+ ? `Diretório '${rel}' está vazio (0 arquivos/pastas visíveis).`
100
+ : `Diretório '${rel}' contém ${entryCount} entradas${truncated ? ' (TRUNCADO em ' + LIST_DIR_MAX_ENTRIES + ' — dir muito grande)' : ' (arquivos+pastas, ocultos e node_modules excluídos)'}.`;
101
+ return { ok: true, path: rel, tree: out.join('\n'), summary, entryCount, truncated };
102
+ } catch (e) {
103
+ return { ok: false, error: e.message };
104
+ }
105
+ }
106
+
107
+ // ---- run_shell (streaming ao vivo para stdout) ------------------
108
+ function runShellStream(command, opts = {}) {
109
+ return new Promise((resolve) => {
110
+ const timeout = opts.timeout || 60000;
111
+ const shell = process.platform === 'win32' ? true : '/bin/bash';
112
+ const child = spawn(command, {
113
+ shell,
114
+ cwd: process.cwd(),
115
+ env: process.env,
116
+ stdio: ['ignore', 'pipe', 'pipe'],
117
+ });
118
+
119
+ let stdout = '';
120
+ let stderr = '';
121
+ let killed = false;
122
+
123
+ const timer = setTimeout(() => {
124
+ killed = true;
125
+ try { child.kill('SIGKILL'); } catch {}
126
+ }, timeout);
127
+
128
+ const write = (data, target, color) => {
129
+ const s = data.toString();
130
+ if (target === 'stdout') stdout += s; else stderr += s;
131
+ // stream ao vivo para o terminal do usuário
132
+ const painted = color ? color(s) : s;
133
+ process.stdout.write(painted);
134
+ };
135
+ child.stdout.on('data', d => write(d, 'stdout', null));
136
+ child.stderr.on('data', d => write(d, 'stderr', c.err));
137
+
138
+ child.on('close', (code) => {
139
+ clearTimeout(timer);
140
+ const totalOut = (stdout + stderr).slice(-4000); // limita retorno pro modelo
141
+ resolve({
142
+ ok: code === 0 && !killed,
143
+ code: killed ? -1 : code,
144
+ killed,
145
+ stdout, stderr,
146
+ summary: `exit=${code}${killed ? ' (timeout)' : ''}, output(last 4KB):\n${totalOut}`,
147
+ });
148
+ });
149
+ child.on('error', (err) => {
150
+ clearTimeout(timer);
151
+ resolve({ ok: false, error: err.message, summary: `spawn error: ${err.message}` });
152
+ });
153
+ });
154
+ }
155
+
156
+ // versão sync usada pelo slash /run
157
+ function runShellSync(command, opts = {}) {
158
+ const timeout = opts.timeout || 30000;
159
+ try {
160
+ const stdout = execSync(command, { stdio: ['ignore', 'pipe', 'pipe'], timeout, encoding: 'utf8', cwd: process.cwd() });
161
+ return { ok: true, stdout, code: 0 };
162
+ } catch (err) {
163
+ return {
164
+ ok: false,
165
+ stdout: err.stdout ? err.stdout.toString() : '',
166
+ stderr: err.stderr ? err.stderr.toString() : String(err.message),
167
+ code: err.status ?? 1,
168
+ };
169
+ }
170
+ }
171
+
172
+ // ---- open_in_browser (prefere Chrome) ---------------------------
173
+ function findChrome() {
174
+ const platform = process.platform;
175
+ const candidates = platform === 'darwin' ? [
176
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
177
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
178
+ ] : platform === 'win32' ? [
179
+ `${process.env['PROGRAMFILES']}\\Google\\Chrome\\Application\\chrome.exe`,
180
+ `${process.env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`,
181
+ `${process.env['LOCALAPPDATA']}\\Google\\Chrome\\Application\\chrome.exe`,
182
+ ] : [
183
+ '/usr/bin/google-chrome',
184
+ '/usr/bin/google-chrome-stable',
185
+ '/usr/bin/chromium',
186
+ '/usr/bin/chromium-browser',
187
+ '/snap/bin/chromium',
188
+ ];
189
+ for (const p of candidates) {
190
+ try { if (p && fs.existsSync(p)) return p; } catch {}
191
+ }
192
+ return null;
193
+ }
194
+
195
+ function openInBrowser(target) {
196
+ try {
197
+ let url = target;
198
+ if (!/^https?:\/\//i.test(target)) {
199
+ const abs = safePath(target);
200
+ if (!fs.existsSync(abs)) return { ok: false, error: `arquivo não existe: ${target}` };
201
+ url = 'file://' + abs.replace(/\\/g, '/');
202
+ }
203
+
204
+ const platform = process.platform;
205
+ const chrome = findChrome();
206
+ let cmd, args, opener;
207
+
208
+ if (chrome) {
209
+ cmd = chrome; args = [url]; opener = 'chrome';
210
+ } else if (platform === 'darwin') {
211
+ cmd = 'open'; args = [url]; opener = 'open';
212
+ } else if (platform === 'win32') {
213
+ cmd = 'cmd'; args = ['/c', 'start', '""', url]; opener = 'cmd start';
214
+ } else {
215
+ cmd = process.env.PRIMOCODE_BROWSER_OPENER || 'xdg-open';
216
+ args = [url];
217
+ opener = cmd;
218
+ }
219
+ const child = spawn(cmd, args, { detached: true, stdio: 'ignore' });
220
+ child.on('error', () => {}); // não crasha se opener não existir
221
+ child.unref();
222
+ return { ok: true, url, opener };
223
+ } catch (e) {
224
+ return { ok: false, error: e.message };
225
+ }
226
+ }
227
+
228
+ // ---- run_in_new_terminal (abre nova janela) ---------------------
229
+ function findLinuxTerminal() {
230
+ const list = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'terminator', 'xterm'];
231
+ for (const t of list) {
232
+ try {
233
+ execSync(`command -v ${t}`, { stdio: 'ignore' });
234
+ return t;
235
+ } catch {}
236
+ }
237
+ return null;
238
+ }
239
+
240
+ function openInNewTerminal(command, cwd) {
241
+ try {
242
+ const workDir = cwd ? safePath(cwd) : process.cwd();
243
+ if (!fs.existsSync(workDir)) return { ok: false, error: `cwd não existe: ${cwd}` };
244
+
245
+ const platform = process.platform;
246
+
247
+ // Windows: abrir NOVA janela do cmd.exe (não usa shell:true que travava dentro do processo pai)
248
+ // Estratégia: 'cmd /c start "Título" cmd /k "…"' → o start desanexa e cria window nova.
249
+ // Usamos shell:false para evitar aspas do PowerShell interpretando errado; passamos como array.
250
+ if (platform === 'win32') {
251
+ // Escapa aspas duplas para embutir em cmd /k
252
+ const escaped = command.replace(/"/g, '""');
253
+ // O truque é: cmd /c start "" cmd /k "cd /d "workdir" && comando"
254
+ // "" = título vazio (senão o primeiro "..." vira título)
255
+ const args = [
256
+ '/c',
257
+ 'start', '""', // start com título vazio → cria nova janela
258
+ 'cmd', '/k', // /k mantém aberto após comando terminar
259
+ `cd /d "${workDir}" && ${escaped}`,
260
+ ];
261
+ const child = spawn('cmd.exe', args, {
262
+ detached: true,
263
+ stdio: 'ignore',
264
+ windowsHide: false,
265
+ windowsVerbatimArguments: true,
266
+ });
267
+ child.on('error', () => {});
268
+ child.unref();
269
+ return { ok: true, opener: 'cmd (nova janela)', command, cwd: workDir };
270
+ }
271
+
272
+ if (platform === 'darwin') {
273
+ const cwdEsc = workDir.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
274
+ const cmdEsc = command.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
275
+ const script = `tell application "Terminal" to activate
276
+ tell application "Terminal" to do script "cd \\"${cwdEsc}\\" && ${cmdEsc}"`;
277
+ const child = spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' });
278
+ child.on('error', () => {});
279
+ child.unref();
280
+ return { ok: true, opener: 'Terminal.app (nova janela)', command, cwd: workDir };
281
+ }
282
+
283
+ // Linux
284
+ const term = findLinuxTerminal();
285
+ if (!term) return { ok: false, error: 'nenhum terminal gráfico encontrado (gnome-terminal, konsole, xterm...)' };
286
+ let args;
287
+ if (term === 'gnome-terminal') {
288
+ args = ['--working-directory=' + workDir, '--', 'bash', '-c', `${command}; exec bash`];
289
+ } else if (term === 'konsole') {
290
+ args = ['--workdir', workDir, '-e', 'bash', '-c', `${command}; exec bash`];
291
+ } else if (term === 'xfce4-terminal' || term === 'terminator') {
292
+ args = ['--working-directory=' + workDir, '-e', `bash -c '${command.replace(/'/g, `'\\''`)}; exec bash'`];
293
+ } else {
294
+ args = ['-e', `bash -c 'cd "${workDir}" && ${command.replace(/'/g, `'\\''`)}; exec bash'`];
295
+ }
296
+ const child = spawn(term, args, { detached: true, stdio: 'ignore' });
297
+ child.on('error', () => {});
298
+ child.unref();
299
+ return { ok: true, opener: `${term} (nova janela)`, command, cwd: workDir };
300
+ } catch (e) {
301
+ return { ok: false, error: e.message };
302
+ }
303
+ }
304
+
305
+ // ---- extractCodeBlocks (usado por /apply e /write) --------------
306
+ function extractCodeBlocks(md) {
307
+ const blocks = [];
308
+ const re = /```([\w+.-]*)\n([\s\S]*?)```/g;
309
+ let m;
310
+ while ((m = re.exec(md)) !== null) {
311
+ const lang = m[1] || '';
312
+ let content = m[2];
313
+ let filename = null;
314
+ const first = content.split('\n')[0];
315
+ const hint = first.match(/^(?:\/\/|#|--|<!--)\s*(?:filename|file|path)\s*[:=]\s*(.+?)\s*(?:-->)?$/i);
316
+ if (hint) {
317
+ filename = hint[1].trim();
318
+ content = content.split('\n').slice(1).join('\n');
319
+ }
320
+ blocks.push({ lang, filename, content });
321
+ }
322
+ return blocks;
323
+ }
324
+
325
+ // Wrapper para /read e /write "safe" (lançam)
326
+ function readFileSafe(rel) {
327
+ const r = readFile(rel);
328
+ if (!r.ok) throw new Error(r.error);
329
+ return r.content;
330
+ }
331
+ function writeFileSafe(rel, content) {
332
+ const r = writeFile(rel, content);
333
+ if (!r.ok) throw new Error(r.error);
334
+ return r;
335
+ }
336
+
337
+ // Executa uma tool pedida pelo modelo. Retorna { ok, text_for_model, ui_summary }.
338
+ async function executeTool(name, args, log) {
339
+ switch (name) {
340
+ case 'read_file': {
341
+ const r = readFile(args.path || '');
342
+ log && log.event('read_file', args.path, r);
343
+ return r.ok
344
+ ? { ok: true, text_for_model: JSON.stringify({ ok: true, content: r.content, bytes: r.bytes, truncated: r.truncated }) }
345
+ : { ok: false, text_for_model: JSON.stringify({ ok: false, error: r.error }) };
346
+ }
347
+ case 'write_file': {
348
+ const r = writeFile(args.path || '', args.content ?? '');
349
+ log && log.event('write_file', args.path, r);
350
+ return r.ok
351
+ ? { ok: true, text_for_model: JSON.stringify({ ok: true, path: r.path, bytes: r.bytes, created: r.created }) }
352
+ : { ok: false, text_for_model: JSON.stringify({ ok: false, error: r.error }) };
353
+ }
354
+ case 'list_dir': {
355
+ const r = listDir(args.path || '.');
356
+ log && log.event('list_dir', args.path || '.', r);
357
+ return r.ok
358
+ ? { ok: true, text_for_model: JSON.stringify({ ok: true, cwd: process.cwd(), tree: r.tree, summary: r.summary, entry_count: r.entryCount }) }
359
+ : { ok: false, text_for_model: JSON.stringify({ ok: false, error: r.error, hint: 'use path="." para o diretório atual' }) };
360
+ }
361
+ case 'run_shell': {
362
+ log && log.event('run_shell', args.command, null); // header antes de executar
363
+ const r = await runShellStream(args.command || '', { timeout: args.timeout_ms || 60000 });
364
+ log && log.event('run_shell_done', args.command, r);
365
+ return {
366
+ ok: r.ok,
367
+ text_for_model: JSON.stringify({ ok: r.ok, code: r.code, killed: r.killed, output: (r.stdout + r.stderr).slice(-4000) }),
368
+ };
369
+ }
370
+ case 'open_in_browser': {
371
+ const r = openInBrowser(args.target || '');
372
+ log && log.event('open_in_browser', args.target, r);
373
+ return r.ok
374
+ ? { ok: true, text_for_model: JSON.stringify({ ok: true, url: r.url, opener: r.opener }) }
375
+ : { ok: false, text_for_model: JSON.stringify({ ok: false, error: r.error }) };
376
+ }
377
+ case 'run_in_new_terminal': {
378
+ const r = openInNewTerminal(args.command || '', args.cwd);
379
+ log && log.event('run_in_new_terminal', args.command, r);
380
+ return r.ok
381
+ ? { ok: true, text_for_model: JSON.stringify({ ok: true, opener: r.opener, command: r.command, cwd: r.cwd }) }
382
+ : { ok: false, text_for_model: JSON.stringify({ ok: false, error: r.error }) };
383
+ }
384
+ case 'browser_open': {
385
+ try {
386
+ const r = await browser.open(args.url || 'about:blank');
387
+ log && log.event('browser_open', args.url, r);
388
+ return { ok: true, text_for_model: JSON.stringify({ ok: true, url: r.url }) };
389
+ } catch (e) {
390
+ log && log.event('browser_open', args.url, { ok: false, error: e.message });
391
+ return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
392
+ }
393
+ }
394
+ case 'browser_navigate': {
395
+ try {
396
+ const r = await browser.navigate(args.url || '');
397
+ log && log.event('browser_navigate', args.url, r);
398
+ return { ok: true, text_for_model: JSON.stringify({ ok: true, url: r.url }) };
399
+ } catch (e) {
400
+ log && log.event('browser_navigate', args.url, { ok: false, error: e.message });
401
+ return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
402
+ }
403
+ }
404
+ case 'browser_click': {
405
+ try {
406
+ let r;
407
+ if (args.selector) r = await browser.clickSelector(args.selector);
408
+ else if (args.text) r = await browser.clickText(args.text);
409
+ else if (typeof args.x === 'number' && typeof args.y === 'number') r = await browser.click(args.x, args.y);
410
+ else throw new Error('informe selector, text ou (x,y)');
411
+ log && log.event('browser_click', args.selector || args.text || `${args.x},${args.y}`, r);
412
+ return { ok: true, text_for_model: JSON.stringify(r) };
413
+ } catch (e) {
414
+ log && log.event('browser_click', args.selector || args.text, { ok: false, error: e.message });
415
+ return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
416
+ }
417
+ }
418
+ case 'browser_type': {
419
+ try {
420
+ let r;
421
+ if (args.selector) r = await browser.typeInSelector(args.selector, args.text || '', args.clear !== false);
422
+ else r = await browser.type(args.text || '');
423
+ log && log.event('browser_type', `${args.selector || ''} · ${(args.text || '').length} chars`, r);
424
+ return { ok: true, text_for_model: JSON.stringify(r) };
425
+ } catch (e) {
426
+ log && log.event('browser_type', args.selector, { ok: false, error: e.message });
427
+ return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
428
+ }
429
+ }
430
+ case 'browser_key': {
431
+ try {
432
+ const r = await browser.key(args.key || 'Enter');
433
+ log && log.event('browser_key', args.key, r);
434
+ return { ok: true, text_for_model: JSON.stringify(r) };
435
+ } catch (e) {
436
+ return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
437
+ }
438
+ }
439
+ case 'browser_wait_for': {
440
+ try {
441
+ const r = await browser.waitFor(args.selector, args.timeout_ms || 15000);
442
+ log && log.event('browser_wait_for', args.selector, r);
443
+ return { ok: true, text_for_model: JSON.stringify(r) };
444
+ } catch (e) {
445
+ return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
446
+ }
447
+ }
448
+ case 'browser_wait': {
449
+ const r = await browser.wait(args.ms || 1000);
450
+ log && log.event('browser_wait', `${r.ms}ms`, r);
451
+ return { ok: true, text_for_model: JSON.stringify(r) };
452
+ }
453
+ case 'browser_screenshot': {
454
+ try {
455
+ const r = await browser.screenshot(args.path || null);
456
+ log && log.event('browser_screenshot', r.path, r);
457
+ return { ok: true, text_for_model: JSON.stringify(r) };
458
+ } catch (e) {
459
+ return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
460
+ }
461
+ }
462
+ case 'browser_eval': {
463
+ try {
464
+ const r = await browser.evaluate(args.js || '');
465
+ log && log.event('browser_eval', (args.js || '').slice(0, 60), { ok: true });
466
+ return { ok: true, text_for_model: JSON.stringify({ ok: true, value: r }) };
467
+ } catch (e) {
468
+ return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
469
+ }
470
+ }
471
+ case 'browser_scroll': {
472
+ try {
473
+ const r = await browser.scroll(args.dy || 400);
474
+ log && log.event('browser_scroll', `${args.dy}`, r);
475
+ return { ok: true, text_for_model: JSON.stringify(r) };
476
+ } catch (e) {
477
+ return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
478
+ }
479
+ }
480
+ case 'browser_url': {
481
+ try {
482
+ const u = await browser.currentUrl();
483
+ return { ok: true, text_for_model: JSON.stringify({ ok: true, url: u }) };
484
+ } catch (e) {
485
+ return { ok: false, text_for_model: JSON.stringify({ ok: false, error: e.message }) };
486
+ }
487
+ }
488
+ case 'finish': {
489
+ log && log.event('finish', args.summary, { ok: true });
490
+ return { ok: true, text_for_model: JSON.stringify({ ok: true, summary: args.summary }), finish: true };
491
+ }
492
+ default:
493
+ return { ok: false, text_for_model: JSON.stringify({ ok: false, error: `ferramenta desconhecida: ${name}` }) };
494
+ }
495
+ }
496
+
497
+ module.exports = {
498
+ readFile, writeFile, listDir, runShellStream, runShellSync,
499
+ openInBrowser, openInNewTerminal, findChrome,
500
+ readFileSafe, writeFileSafe,
501
+ extractCodeBlocks, executeTool, fmtBytes, safePath,
502
+ MAX_FILE_BYTES,
503
+ };