primocode 8.0.0 → 8.0.1

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/act.js CHANGED
@@ -299,9 +299,19 @@ async function runAct(userPrompt, opts) {
299
299
  const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
300
300
  logResult(tc.name, result, profundidade);
301
301
 
302
- // Uma escrita bem-sucedida limpa a dívida: o agente se corrigiu.
303
- if (result && result.ok === false) falhasPendentes.push(`${tc.name}(${tc.arguments?.path || ''}): ${result.error}`);
304
- else if (['write_file', 'edit_file'].includes(tc.name)) falhasPendentes.length = 0;
302
+ // Uma escrita bem-sucedida limpa a dívida — mas só a do MESMO
303
+ // arquivo. Escrever algo sem relação (ou um run_shell qualquer)
304
+ // não corrige um teste quebrado noutro lugar; antes isto zerava
305
+ // a lista inteira e o agente conseguia dizer "concluído" com uma
306
+ // falha real ainda pendente.
307
+ if (result && result.ok === false) {
308
+ falhasPendentes.push(`${tc.name}(${tc.arguments?.path || ''}): ${result.error}`);
309
+ } else if (['write_file', 'edit_file'].includes(tc.name) && tc.arguments?.path) {
310
+ const alvo = tc.arguments.path;
311
+ const mantidas = falhasPendentes.filter((f) => !f.includes(`(${alvo})`));
312
+ falhasPendentes.length = 0;
313
+ falhasPendentes.push(...mantidas);
314
+ }
305
315
 
306
316
  messages.push({ role: 'tool', tool_call_id: tc.id, content: summarizeForModel(result) });
307
317
  }
package/lib/desktop.js CHANGED
@@ -19,7 +19,9 @@ const os = require('os');
19
19
  const PLATFORM = os.platform();
20
20
 
21
21
  function hasCmd(cmd) {
22
- try { execSync(`which ${cmd} || where ${cmd}`, { stdio: 'ignore' }); return true; } catch { return false; }
22
+ // cmd pode vir de texto do usuário (desktop_open_app com nome sem alias
23
+ // conhecido) — sem aspas aqui, um ";"/"&&" no meio vira comando extra.
24
+ try { execSync(`which ${sh1(cmd)} || where ${sh1(cmd)}`, { stdio: 'ignore' }); return true; } catch { return false; }
23
25
  }
24
26
 
25
27
  // Detectar ferramenta disponível
package/lib/referencia.js CHANGED
@@ -64,11 +64,15 @@ function viaProxy(alvo, { timeout }) {
64
64
  });
65
65
  }
66
66
 
67
- function pegar(url, { timeout = TIMEOUT_MS } = {}) {
67
+ function pegar(url, { timeout = TIMEOUT_MS, saltos = 0 } = {}) {
68
68
  return new Promise((resolve) => {
69
69
  let req;
70
70
  const alvo = (() => { try { return new URL(url); } catch { return null; } })();
71
71
  if (!alvo) return resolve(null);
72
+ // 5 saltos cobre qualquer doc real; sem teto, um redirect em loop
73
+ // (A→B→A→...) trava essa fonte — e a pesquisa inteira — para sempre.
74
+ if (saltos > 5) return resolve(null);
75
+ const cliente = alvo.protocol === 'http:' ? http : https;
72
76
 
73
77
  const opcoes = {
74
78
  headers: {
@@ -88,12 +92,12 @@ function pegar(url, { timeout = TIMEOUT_MS } = {}) {
88
92
  abrir.then((cfg) => {
89
93
  if (!cfg) return resolve(null);
90
94
  try {
91
- req = https.get(url, cfg, (res) => {
92
- // Redirecionamento: segue uma vez, o suficiente para docs.
95
+ req = cliente.get(url, cfg, (res) => {
96
+ // Redirecionamento: segue até o teto de saltos acima.
93
97
  if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
94
98
  res.resume();
95
99
  const destino = new URL(res.headers.location, url).href;
96
- return pegar(destino, { timeout }).then(resolve);
100
+ return pegar(destino, { timeout, saltos: saltos + 1 }).then(resolve);
97
101
  }
98
102
  if (res.statusCode !== 200) { res.resume(); return resolve(null); }
99
103
  let corpo = '';
package/lib/tools.js CHANGED
@@ -289,7 +289,7 @@ function multiEdit({ path: rawPath, edits }, projectDir) {
289
289
  if (ocorrencias > 1 && !replace_all) {
290
290
  return { ok: false, error: `edit ${i + 1}: trecho aparece ${ocorrencias}x — use replace_all ou inclua mais contexto para torná-lo único` };
291
291
  }
292
- conteudo = replace_all ? conteudo.split(old_string).join(new_string) : conteudo.replace(old_string, new_string);
292
+ conteudo = replace_all ? conteudo.split(old_string).join(new_string ?? '') : conteudo.replace(old_string, new_string ?? '');
293
293
  substituicoes++;
294
294
  }
295
295
 
@@ -422,7 +422,10 @@ async function despachar(name, args, ctx) {
422
422
  return texto == null ? { ok: false, error: 'nada na página (ou seletor não encontrado)' } : { ok: true, text: texto };
423
423
  }
424
424
  case 'browser_screenshot':
425
- return browser.screenshot(args.path ? resolveInProject(args.path, projectDir) : undefined);
425
+ // Sem path, cai num nome-padrão — mas SEMPRE dentro do projeto: sem
426
+ // isso o arquivo ia para o cwd real do processo, não para a pasta
427
+ // virtual que o CLI mostra ao usuário (o processo nunca faz chdir).
428
+ return browser.screenshot(resolveInProject(args.path || `screenshot-${Date.now()}.png`, projectDir));
426
429
  case 'browser_scroll':
427
430
  return browser.scroll(args.dy != null ? args.dy : 600);
428
431
  case 'browser_wait':
@@ -432,7 +435,9 @@ async function despachar(name, args, ctx) {
432
435
 
433
436
  // ── Desktop físico — mouse, teclado e tela do usuário ───────────
434
437
  case 'desktop_screenshot':
435
- return desktop.screenshot(args.path ? resolveInProject(args.path, projectDir) : undefined);
438
+ // Mesmo motivo do browser_screenshot: nome-padrão também precisa
439
+ // resolver dentro do projeto, não do cwd real do processo.
440
+ return desktop.screenshot(resolveInProject(args.path || `desktop-${Date.now()}.png`, projectDir));
436
441
  case 'desktop_click':
437
442
  return desktop.click(args.x, args.y, args.button || 'left');
438
443
  case 'desktop_move':
@@ -484,24 +489,6 @@ async function despachar(name, args, ctx) {
484
489
  }
485
490
  }
486
491
 
487
- function applyCodeBlocks(response) {
488
- const regex = /```[\w]*\n([\s\S]*?)```/g;
489
- const applied = [];
490
- let match;
491
- while ((match = regex.exec(response || '')) !== null) {
492
- const block = match[1];
493
- const nome = block.match(/\/\/\s*filename:\s*(.+)/);
494
- if (!nome) continue;
495
- const filename = nome[1].trim();
496
- const content = block.replace(/\/\/\s*filename:.+\n?/, '');
497
- const created = !fs.existsSync(filename);
498
- fs.mkdirSync(path.dirname(filename), { recursive: true });
499
- fs.writeFileSync(filename, content, 'utf-8');
500
- applied.push({ path: filename, created });
501
- }
502
- return applied;
503
- }
504
-
505
492
  async function uninstall() {
506
493
  const configDir = path.join(os.homedir(), '.primocode');
507
494
  console.log('Removendo PrimoCode…');
@@ -519,7 +506,7 @@ async function uninstall() {
519
506
  }
520
507
 
521
508
  module.exports = {
522
- fmtBytes, treeDir, applyCodeBlocks, runShell, openInBrowser, runInNewTerminal,
509
+ fmtBytes, treeDir, runShell, openInBrowser, runInNewTerminal,
523
510
  execute, uninstall, resolveInProject, globToRegex, globFiles, grepFiles, editFile,
524
511
  multiEdit, writeTodos, getTodos,
525
512
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.0.0",
3
+ "version": "8.0.1",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
5
5
  "main": "bin/primocode.js",
6
6
  "bin": {
package/studio/server.py CHANGED
@@ -9,6 +9,7 @@ Só biblioteca padrão do Python. Nada para instalar.
9
9
  """
10
10
 
11
11
  import base64
12
+ import functools
12
13
  import json
13
14
  import mimetypes
14
15
  import os
@@ -98,10 +99,17 @@ POR_SLUG = {f["slug"]: f for f in FERRAMENTAS_INFO}
98
99
 
99
100
  # -------------------------------------------------------------- utilidades --
100
101
 
101
- def slugify(texto):
102
+ def _normaliza(texto, tamanho):
103
+ """NFKD → ASCII → não-alfanumérico vira hífen → minúsculo → corta.
104
+ Compartilhada por slugify() e nome_seguro(), que faziam a mesma conta
105
+ com um corte de tamanho diferente cada uma."""
102
106
  texto = unicodedata.normalize("NFKD", texto or "").encode("ascii", "ignore").decode()
103
107
  texto = re.sub(r"[^a-zA-Z0-9]+", "-", texto).strip("-").lower()
104
- return (texto or "arquivo")[:40]
108
+ return (texto or "arquivo")[:tamanho]
109
+
110
+
111
+ def slugify(texto):
112
+ return _normaliza(texto, 40)
105
113
 
106
114
 
107
115
  def caminho(arq_id):
@@ -163,10 +171,7 @@ mimetypes.add_type("audio/mp4", ".m4a")
163
171
 
164
172
  def nome_seguro(nome):
165
173
  base, ext = os.path.splitext(nome or "arquivo")
166
- ext = ext.lower()
167
- base = unicodedata.normalize("NFKD", base).encode("ascii", "ignore").decode()
168
- base = re.sub(r"[^a-zA-Z0-9]+", "-", base).strip("-").lower()[:48] or "arquivo"
169
- return base, ext
174
+ return _normaliza(base, 48), ext.lower()
170
175
 
171
176
 
172
177
  def listar_midia():
@@ -218,16 +223,31 @@ def guardar_midia(dados):
218
223
 
219
224
 
220
225
  def criar(dados):
221
- ferramenta = (dados.get("ferramenta") or "prisma").lower()
226
+ # O cliente é um agente de IA falando JSON cru — um tipo errado aqui
227
+ # (titulo como lista, por exemplo) não pode virar AttributeError sem
228
+ # resposta HTTP nenhuma; melhor um 400 com o motivo.
229
+ ferramenta_bruta = dados.get("ferramenta")
230
+ if ferramenta_bruta is not None and not isinstance(ferramenta_bruta, str):
231
+ raise ValueError("ferramenta precisa ser texto")
232
+ ferramenta = (ferramenta_bruta or "prisma").lower()
222
233
  if ferramenta not in POR_SLUG:
223
234
  return None
224
- titulo = (dados.get("titulo") or "Sem título").strip()
235
+
236
+ titulo_bruto = dados.get("titulo")
237
+ if titulo_bruto is not None and not isinstance(titulo_bruto, str):
238
+ raise ValueError("titulo precisa ser texto")
239
+ titulo = (titulo_bruto or "Sem título").strip()
240
+
241
+ doc = dados.get("doc")
242
+ if doc is not None and not isinstance(doc, dict):
243
+ raise ValueError("doc precisa ser um objeto")
244
+
225
245
  arq_id = ferramenta + "-" + slugify(titulo) + "-" + uuid.uuid4().hex[:5]
226
246
  return gravar({
227
247
  "id": arq_id,
228
248
  "ferramenta": ferramenta,
229
249
  "titulo": titulo,
230
- "doc": dados.get("doc") or exemplo(ferramenta),
250
+ "doc": doc or exemplo(ferramenta),
231
251
  })
232
252
 
233
253
 
@@ -311,6 +331,23 @@ def manual():
311
331
 
312
332
  # ----------------------------------------------------------------- handler --
313
333
 
334
+ def seguro(metodo):
335
+ """Qualquer exceção não prevista na rota vira 500 em JSON, não conexão
336
+ fechada sem resposta nenhuma — era o comportamento padrão do
337
+ BaseHTTPRequestHandler para exceção não tratada num do_*, e o cliente (o
338
+ agente de IA) não tem como interpretar uma conexão que só morre."""
339
+ @functools.wraps(metodo)
340
+ def envolto(self, *a, **kw):
341
+ try:
342
+ return metodo(self, *a, **kw)
343
+ except Exception as e:
344
+ try:
345
+ self.js({"erro": "erro interno: %s" % e}, 500)
346
+ except Exception:
347
+ pass
348
+ return envolto
349
+
350
+
314
351
  class Handler(SimpleHTTPRequestHandler):
315
352
  server_version = "PrimoCode/1.0"
316
353
 
@@ -360,6 +397,7 @@ class Handler(SimpleHTTPRequestHandler):
360
397
 
361
398
  # -------------------------------------------------------------- rotas --
362
399
 
400
+ @seguro
363
401
  def do_GET(self):
364
402
  p = urlparse(self.path).path
365
403
 
@@ -426,10 +464,17 @@ class Handler(SimpleHTTPRequestHandler):
426
464
  if faixa:
427
465
  m = re.match(r"bytes=(\d*)-(\d*)", faixa)
428
466
  if m:
429
- if m.group(1):
430
- inicio = int(m.group(1))
431
- if m.group(2):
432
- fim = min(int(m.group(2)), tamanho - 1)
467
+ if not m.group(1) and m.group(2):
468
+ # "bytes=-500" é sufixo HTTP válido: os ÚLTIMOS 500 bytes,
469
+ # não os primeiros 500 — sem este caso à parte, group(2)
470
+ # virava fim absoluto e o pedido servia o pedaço errado.
471
+ inicio = max(0, tamanho - int(m.group(2)))
472
+ fim = tamanho - 1
473
+ else:
474
+ if m.group(1):
475
+ inicio = int(m.group(1))
476
+ if m.group(2):
477
+ fim = min(int(m.group(2)), tamanho - 1)
433
478
  if inicio > fim or inicio >= tamanho:
434
479
  self.send_response(416)
435
480
  self.send_header("Content-Range", "bytes */%d" % tamanho)
@@ -455,13 +500,18 @@ class Handler(SimpleHTTPRequestHandler):
455
500
  self.wfile.write(pedaco)
456
501
  restante -= len(pedaco)
457
502
 
503
+ @seguro
458
504
  def do_POST(self):
459
- if urlparse(self.path).path == "/api/midia":
505
+ rota = urlparse(self.path).path
506
+ if rota == "/api/midia":
460
507
  corpo, status = guardar_midia(self.corpo())
461
508
  return self.js(corpo, status)
462
509
 
463
- if urlparse(self.path).path == "/api/arquivos":
464
- a = criar(self.corpo())
510
+ if rota == "/api/arquivos":
511
+ try:
512
+ a = criar(self.corpo())
513
+ except ValueError as e:
514
+ return self.js({"erro": str(e)}, 400)
465
515
  if not a:
466
516
  return self.js({"erro": "ferramenta inválida",
467
517
  "validas": list(POR_SLUG)}, 400)
@@ -475,6 +525,7 @@ class Handler(SimpleHTTPRequestHandler):
475
525
  }, 201)
476
526
  return self.js({"erro": "não encontrado"}, 404)
477
527
 
528
+ @seguro
478
529
  def do_PUT(self):
479
530
  m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
480
531
  if not m:
@@ -483,13 +534,21 @@ class Handler(SimpleHTTPRequestHandler):
483
534
  if not a:
484
535
  return self.js({"erro": "não encontrado"}, 404)
485
536
  dados = self.corpo()
537
+ # Mesma validação de criar(): sem isso, um PUT com titulo/doc do tipo
538
+ # errado grava sujeira que só quebra depois, no consumo (export etc).
539
+ if "titulo" in dados and dados["titulo"] is not None and not isinstance(dados["titulo"], str):
540
+ return self.js({"erro": "titulo precisa ser texto"}, 400)
541
+ if "doc" in dados and dados["doc"] is not None and not isinstance(dados["doc"], dict):
542
+ return self.js({"erro": "doc precisa ser um objeto"}, 400)
486
543
  for chave in ("titulo", "doc"):
487
544
  if chave in dados:
488
545
  a[chave] = dados[chave]
489
546
  return self.js(gravar(a))
490
547
 
548
+ @seguro
491
549
  def do_DELETE(self):
492
- m = re.fullmatch(r"/api/midia/([^/]+)", urlparse(self.path).path)
550
+ rota = urlparse(self.path).path
551
+ m = re.fullmatch(r"/api/midia/([^/]+)", rota)
493
552
  if m:
494
553
  nome = unquote(m.group(1))
495
554
  alvo = os.path.join(MIDIA, nome)
@@ -498,7 +557,7 @@ class Handler(SimpleHTTPRequestHandler):
498
557
  return self.js({"ok": True})
499
558
  return self.js({"erro": "não encontrado"}, 404)
500
559
 
501
- m = re.fullmatch(r"/api/arquivos/([^/]+)", urlparse(self.path).path)
560
+ m = re.fullmatch(r"/api/arquivos/([^/]+)", rota)
502
561
  c = caminho(m.group(1)) if m else None
503
562
  if c and os.path.exists(c):
504
563
  os.remove(c)
@@ -535,7 +594,7 @@ def csv_celula(valor):
535
594
  if valor == int(valor):
536
595
  valor = int(valor)
537
596
  s = "" if valor is None else str(valor)
538
- return '"%s"' % s.replace('"', '""') if re.search(r'[",\n]', s) else s
597
+ return '"%s"' % s.replace('"', '""') if re.search(r'[",\n\r]', s) else s
539
598
 
540
599
 
541
600
  # ------------------------------------------------- fórmulas (espelha o JS) --
@@ -602,7 +661,11 @@ def calcular_aba(aba):
602
661
  linhas = [list(l) for l in aba.get("linhas", [])]
603
662
 
604
663
  def celula(c, l, prof):
605
- if prof > 24 or l >= len(linhas) or c >= len(linhas[l]):
664
+ # l/c negativos (referência tipo "A0", que vira índice -1) não podem
665
+ # passar: índice negativo do Python "funciona" contando do fim da
666
+ # lista, e a última linha da planilha aparecia como resposta de uma
667
+ # referência inválida em vez de virar 0.
668
+ if prof > 24 or l < 0 or c < 0 or l >= len(linhas) or c >= len(linhas[l]):
606
669
  return 0
607
670
  bruto = linhas[l][c]
608
671
  if isinstance(bruto, str) and bruto.startswith("="):
@@ -642,7 +705,14 @@ def calcular_aba(aba):
642
705
  elif parte:
643
706
  # qualquer outro argumento é uma expressão: resolve antes
644
707
  vals.append(_num(avaliar(parte, prof)))
645
- return repr(f(vals))
708
+ try:
709
+ # MAIOR/MENOR estouram IndexError com k maior que o intervalo;
710
+ # POT estoura OverflowError com base/expoente grandes demais.
711
+ # Uma fórmula ruim vira "#ERRO" NESSA célula, não derruba a
712
+ # exportação inteira (mesma regra do eval() logo abaixo).
713
+ return repr(f(vals))
714
+ except Exception:
715
+ return "#ERRO"
646
716
 
647
717
  # repete até esgotar: resolve de dentro para fora em fórmula aninhada
648
718
  e, voltas = expr, 0