primocode 9.9.0 → 10.0.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.
Files changed (63) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +100 -341
  3. package/lib/act.js +245 -23
  4. package/lib/browser.js +17 -0
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/config.js +10 -3
  8. package/lib/cursor.js +10 -1
  9. package/lib/entrada.js +4 -2
  10. package/lib/estudio/diretor.js +9 -1
  11. package/lib/estudio/ende.js +2 -0
  12. package/lib/estudio/estilo.js +51 -0
  13. package/lib/estudio/glifos.js +215 -0
  14. package/lib/estudio/index.js +63 -11
  15. package/lib/estudio/kit/base.jsx +285 -0
  16. package/lib/estudio/kit/index.jsx +11 -0
  17. package/lib/estudio/kit/marca.jsx +741 -0
  18. package/lib/estudio/kit/vox.jsx +357 -0
  19. package/lib/estudio/kit.js +72 -0
  20. package/lib/estudio/lancamento.js +413 -0
  21. package/lib/estudio/morfar.js +191 -0
  22. package/lib/estudio/motion.js +333 -0
  23. package/lib/estudio/plano.js +54 -7
  24. package/lib/estudio/remotion.js +167 -52
  25. package/lib/estudio/ritmo.js +77 -0
  26. package/lib/estudio/sons-leves.js +62 -0
  27. package/lib/estudio/vox.js +385 -0
  28. package/lib/fala.js +1 -94
  29. package/lib/pipeline.js +55 -56
  30. package/lib/repeticao.js +3 -1
  31. package/lib/skills.js +8 -2
  32. package/lib/snapshot.js +111 -0
  33. package/lib/tools.js +79 -20
  34. package/lib/ui.js +13 -6
  35. package/lib/video/analise.js +89 -0
  36. package/lib/video/cli.js +74 -0
  37. package/lib/video/delta.js +90 -0
  38. package/lib/video/edits.js +379 -0
  39. package/lib/video/filters.js +247 -0
  40. package/lib/video/ops.js +322 -0
  41. package/lib/video/projeto.js +133 -0
  42. package/lib/video/render.js +221 -0
  43. package/lib/video/texto.js +142 -0
  44. package/lib/video/timeline.js +310 -0
  45. package/lib/video/transcript.js +124 -0
  46. package/lib/voz.js +75 -223
  47. package/package.json +2 -3
  48. package/skills/motion-de-primeira/SKILL.md +21 -1
  49. package/studio/texto.py +208 -0
  50. package/voz/primo_voz/__main__.py +96 -236
  51. package/voz/primo_voz/transcrever.py +31 -1
  52. package/app/ansi.js +0 -55
  53. package/app/app.css +0 -500
  54. package/app/app.js +0 -1017
  55. package/app/icone.svg +0 -6
  56. package/app/index.html +0 -220
  57. package/app/primo-marca.js +0 -192
  58. package/lib/app.js +0 -615
  59. package/lib/icone.js +0 -263
  60. package/lib/instalar-app.js +0 -231
  61. package/lib/painel.js +0 -291
  62. package/voz/primo_voz/desperta.py +0 -195
  63. package/voz/primo_voz/orbe.py +0 -406
package/lib/pipeline.js CHANGED
@@ -217,8 +217,8 @@ Duas regras que valem mais que o resto:
217
217
 
218
218
  Responda com a especificação e mais nada.`;
219
219
 
220
- const CONSTRUTOR_1 = (spec, pasta) => `Você é o CONSTRUTOR 1, e o seu trabalho é
221
- o PROTÓTIPO que FUNCIONA.
220
+ const CONSTRUTOR_1 = (spec, pasta) => `Você é o CONSTRUTOR, e o seu trabalho é
221
+ o PRODUTO que FUNCIONA — de primeira, sem protótipo para alguém refazer depois.
222
222
 
223
223
  Construa na pasta: ${pasta}
224
224
 
@@ -243,39 +243,35 @@ Regras:
243
243
  digitar não mostra nada.
244
244
  · Não deixe função pela metade nem "TODO". O que não der para fazer, não
245
245
  prometa na tela.
246
+ · Com TELA: hierarquia de tamanhos, espaço para respirar, estados de foco e de
247
+ toque, mensagem que diz o que fazer quando o campo está vazio, e nada de
248
+ rolagem horizontal em 390px. Uma paleta, mantida.
249
+ · RÁPIDO: escreva cada arquivo UMA vez, inteiro, e vários na mesma rodada. O
250
+ resultado do write_file é a verdade — não releia o que acabou de escrever.
246
251
 
247
252
  Escreva os arquivos e diga em uma linha o que ficou pronto.`;
248
253
 
249
- const CONSTRUTOR_2 = (spec, pasta, relato) => `Você é o CONSTRUTOR 2, e o seu
250
- trabalho é virar o protótipo em PRODUTO.
254
+ const CONSTRUTOR_2 = (spec, pasta, relato) => `Você é o CORRETOR. O produto já
255
+ está construído na pasta ${pasta}, e alguém ABRIU e achou problema.
251
256
 
252
- A pasta é: ${pasta}
253
- LEIA os arquivos que já estão lá antes de mexer. Não recomece do zero: o que
254
- está funcionando fica.
255
-
256
- Especificação:
257
+ O que foi pedido (resumo):
257
258
 
258
259
  """
259
260
  ${spec}
260
261
  """
261
262
 
262
- ${relato ? `O revisor testou o que existe e achou isto:\n\n"""\n${relato}\n"""\n\n` : ''}\
263
- O que você faz:
263
+ O que está errado:
264
264
 
265
- · CONSERTA o que o revisor apontou. Isso vem primeiro; nada de recurso novo com
266
- erro aberto.
267
- · COMPLETA o que a especificação pede e o protótipo não faz.
268
- · REDESENHA: hierarquia de tamanhos, espaço para respirar, estados de foco e de
269
- toque, e o que aparece quando não há dados. Escolha uma paleta e mantenha.
270
- · CUIDA DO ERRO: campo vazio, valor impossível, lista vazia. Cada um com uma
271
- mensagem que diz o que fazer, não "erro".
272
- · RESPONSIVO de verdade: nada de rolagem horizontal em 390px de largura.
265
+ """
266
+ ${relato || '(sem relato)'}
267
+ """
273
268
 
274
- As três últimas valem para o que TEM TELA. Se isto roda no terminal, o
275
- equivalente é: mensagem de uso quando faltar argumento, saída legível, e código
276
- de saída diferente de zero quando der errado.
269
+ · Conserte SÓ o que está apontado. Nada de recurso novo, nada de recomeçar.
270
+ · Leia apenas o arquivo citado no erro — não a pasta inteira — e troque o trecho
271
+ com edit_file. Reescrever o arquivo inteiro só se ele estiver vazio ou em
272
+ branco.
273
+ · Não releia o arquivo depois de editar: o resultado do edit_file é a verdade.
277
274
 
278
- Não escreva "pronto" sem ter aberto os arquivos e mudado o que precisa.
279
275
  Diga em uma linha o que mudou.`;
280
276
 
281
277
  const REVISOR = (spec, pasta, arquivos, relato) => `Você é o REVISADOR, e você
@@ -327,11 +323,12 @@ const APROVOU = /^\s*APROVAD[OA]\b/im;
327
323
  * @param {(prompt:string,rotulo:string)=>Promise<string>} op.correr roda um agente
328
324
  * @param {(linha:string)=>void} op.mostrar escreve na tela do usuário
329
325
  * @param {(alvo:string)=>any} [op.abrir] abre no navegador da pessoa
330
- * @param {number} [op.rodadas] voltas de correção (padrão 2)
326
+ * @param {number} [op.rodadas] conferências, contando a primeira (padrão 3)
331
327
  */
332
328
  async function construir(op) {
333
329
  const { pedido, pasta, correr, mostrar = () => {}, abrir } = op;
334
- const rodadasMax = Math.max(1, Math.min(4, op.rodadas == null ? 2 : op.rodadas));
330
+ // Quantas CONFERÊNCIAS (a primeira + os consertos). 3 = até dois consertos.
331
+ const rodadasMax = Math.max(1, Math.min(4, op.rodadas == null ? 3 : op.rodadas));
335
332
  if (!pedido) return { ok: false, error: 'Diga o que construir.' };
336
333
  if (!pasta) return { ok: false, error: 'Diga em que pasta construir.' };
337
334
  fs.mkdirSync(pasta, { recursive: true });
@@ -345,45 +342,47 @@ async function construir(op) {
345
342
  return { ok: false, error: 'O engenheiro de prompt não devolveu especificação. Faça você mesmo.' };
346
343
  }
347
344
 
348
- mostrar('construtor 1 — protótipo');
349
- await correr(CONSTRUTOR_1(spec, pasta), 'construtor 1');
350
- etapas.push({ papel: 'construtor 1', arquivos: listar(pasta).length });
351
-
345
+ mostrar('construtor');
346
+ await correr(CONSTRUTOR_1(spec, pasta), 'construtor');
347
+ etapas.push({ papel: 'construtor', arquivos: listar(pasta).length });
348
+
349
+ /* ── CONFERIR: a máquina primeiro, o modelo só quando ela não sabe ──
350
+ * Antes eram quatro agentes no mínimo: engenheiro, protótipo, um
351
+ * construtor 2 que RELIA tudo para refazer como MVP, e um revisor que
352
+ * relia de novo para opinar. Era o "escreve dois arquivos e fica o dia
353
+ * lendo o que fez". Agora: a página abriu limpa no Chrome escondido?
354
+ * Está pronta — a medida é fato, e opinião por cima dela só custava.
355
+ * Abriu com erro? O corretor recebe o erro e troca o trecho. O revisor
356
+ * de modelo só entra quando não há página para medir (um app de
357
+ * terminal), porque aí "não sei" precisa de alguém que leia. */
352
358
  let relato = null;
353
359
  let aprovado = false;
354
360
  let ultimoRevisor = '';
355
361
 
356
362
  for (let volta = 1; volta <= rodadasMax; volta++) {
357
- mostrar(`construtor 2 — ${volta === 1 ? 'MVP' : `correção ${volta - 1}`}`);
358
- await correr(CONSTRUTOR_2(spec, pasta, relato), `construtor 2 (${volta})`);
359
-
360
- mostrar('revisador — testando escondido');
363
+ mostrar(volta === 1 ? 'conferindo — abrindo a página escondido' : `conferindo o conserto ${volta - 1}`);
361
364
  const medida = await medirNoNavegador(pasta);
362
- const arquivos = listar(pasta);
363
- ultimoRevisor = await correr(
364
- REVISOR(spec, pasta, arquivos, medida.relato), `revisador (${volta})`);
365
-
366
- etapas.push({ papel: `revisador ${volta}`, testou: medida.testou,
367
- limpo: medida.limpo, veredito: (ultimoRevisor || '').split('\n')[0] });
368
-
369
- /* O veredito do modelo NÃO passa por cima do que a máquina mediu.
370
- Página em branco ou erro de JavaScript é reprovação, e um
371
- "APROVADO" escrito em cima disso é o revisor lendo o que ele
372
- queria que estivesse lá — que é exatamente o defeito que separar
373
- os papéis existe para evitar.
374
-
375
- Mas o VETO precisa de MEDIDA: quando não deu para abrir nada —
376
- porque o projeto não é uma página, ou porque não há navegador
377
- nesta máquina — o que a máquina tem a dizer é "não sei", e "não
378
- sei" não reprova. Sem esta distinção, um app de linha de comando
379
- nunca seria aprovado: gastaria as voltas todas e terminaria
380
- dizendo que não está pronto, sobre um trabalho que está. */
381
- aprovado = APROVOU.test(ultimoRevisor || '') && !(medida.testou && !medida.limpo);
365
+ let veredito;
366
+ if (medida.testou) {
367
+ aprovado = medida.limpo;
368
+ veredito = medida.limpo ? 'APROVADO pela medida' : 'REPROVADO pela medida';
369
+ relato = medida.limpo ? null : medida.relato;
370
+ } else {
371
+ mostrar('revisador — lendo o que foi feito');
372
+ ultimoRevisor = await correr(
373
+ REVISOR(spec, pasta, listar(pasta), medida.relato), `revisador (${volta})`);
374
+ aprovado = APROVOU.test(ultimoRevisor || '');
375
+ veredito = (ultimoRevisor || '').split('\n')[0];
376
+ relato = aprovado ? null : ultimoRevisor;
377
+ }
378
+ etapas.push({ papel: `conferência ${volta}`, testou: medida.testou, limpo: medida.limpo, veredito });
382
379
  if (aprovado) { mostrar('aprovado'); break; }
383
-
384
- relato = medida.limpo ? ultimoRevisor : `${medida.relato}\n\n${ultimoRevisor || ''}`;
385
- if (volta < rodadasMax) mostrar('reprovado — voltando para o construtor');
380
+ if (volta === rodadasMax) break;
381
+ if (!ultimoRevisor) ultimoRevisor = relato || '';
382
+ mostrar(`corretor — consertando (${volta})`);
383
+ await correr(CONSTRUTOR_2(spec, pasta, relato), `corretor (${volta})`);
386
384
  }
385
+ if (!ultimoRevisor && relato) ultimoRevisor = relato;
387
386
 
388
387
  const entrada = acharEntrada(pasta);
389
388
  let abriu = false;
package/lib/repeticao.js CHANGED
@@ -62,7 +62,9 @@ function palavras(texto) {
62
62
  /** Quanto duas buscas se sobrepõem, de 0 a 1. */
63
63
  function semelhanca(a, b) {
64
64
  const A = palavras(a), B = palavras(b);
65
- if (!A.size || !B.size) return A.size === B.size ? 1 : 0;
65
+ // Sem palavra que conte dos dois lados ("go" e "js"): só é a mesma busca
66
+ // se o texto for o mesmo.
67
+ if (!A.size || !B.size) return normalizar(a).trim() === normalizar(b).trim() ? 1 : 0;
66
68
  let juntos = 0;
67
69
  for (const p of A) if (B.has(p)) juntos++;
68
70
  return juntos / (A.size + B.size - juntos);
package/lib/skills.js CHANGED
@@ -80,7 +80,13 @@ function separar(texto) {
80
80
  }
81
81
 
82
82
  function lerEstado() {
83
- try { return JSON.parse(fs.readFileSync(ESTADO, 'utf8')); } catch { return { desligadas: [] }; }
83
+ let e;
84
+ try { e = JSON.parse(fs.readFileSync(ESTADO, 'utf8')); } catch { e = null; }
85
+ // Um estado.json válido sem `desligadas` (um `{}`) derrubava /skills,
86
+ // /marketplace e a entrada das skills em todo pedido.
87
+ if (!e || typeof e !== 'object') e = {};
88
+ if (!Array.isArray(e.desligadas)) e.desligadas = [];
89
+ return e;
84
90
  }
85
91
 
86
92
  function salvarEstado(estado) {
@@ -200,7 +206,7 @@ function ler(nome) {
200
206
  quando: String(cabecalho.quando || '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean),
201
207
  autor: cabecalho.autor || '',
202
208
  corpo,
203
- ligada: !estado.desligadas.includes(nome),
209
+ ligada: !(Array.isArray(estado.desligadas) && estado.desligadas.includes(nome)),
204
210
  doProduto: fs.existsSync(path.join(ORIGEM, nome, 'SKILL.md')),
205
211
  caminho: arquivo,
206
212
  };
@@ -0,0 +1,111 @@
1
+ /**
2
+ * snapshot.js — a página como o agente precisa dela: o que dá para clicar e
3
+ * digitar, cada coisa com uma REF curta.
4
+ *
5
+ * @e1 [link] "Preços" → /precos
6
+ * @e2 [textbox] "E-mail" (seu@email.com)
7
+ * @e3 [button] "Entrar"
8
+ *
9
+ * Antes o agente lia o innerText da página (6.000 caracteres de texto corrido,
10
+ * sem dizer o que é botão) e chutava um seletor CSS — `input[name="q"]` no
11
+ * Google, que é `textarea`. Errava, lia de novo, chutava de novo. Com a ref,
12
+ * clicar é `browser_click selector:"@e3"`: sem CSS, sem chute. A ideia é a do
13
+ * agent-browser da Vercel e do Playwright MCP (árvore de acessibilidade com
14
+ * refs); o código é nosso, roda no mesmo CDP que o PrimoCode já usa e não pede
15
+ * instalar nada.
16
+ *
17
+ * A ref mora no próprio elemento (data-primo-ref). Navegou ou a página se
18
+ * redesenhou, a ref some — e o erro diz para ler de novo, em vez de clicar no
19
+ * lugar errado.
20
+ */
21
+
22
+ 'use strict';
23
+
24
+ const ATRIBUTO = 'data-primo-ref';
25
+
26
+ /** O script que roda DENTRO da página e devolve { titulo, url, linhas, total }. */
27
+ function scriptDoRetrato(max = 80) {
28
+ return `(function(){
29
+ var MAX = ${Number(max) || 80};
30
+ var ATR = ${JSON.stringify(ATRIBUTO)};
31
+ var velhos = document.querySelectorAll('[' + ATR + ']');
32
+ for (var i = 0; i < velhos.length; i++) velhos[i].removeAttribute(ATR);
33
+ function visivel(el) {
34
+ var r = el.getBoundingClientRect();
35
+ if (r.width < 2 || r.height < 2) return false;
36
+ var s = getComputedStyle(el);
37
+ return s.visibility !== 'hidden' && s.display !== 'none' && Number(s.opacity) > 0.05;
38
+ }
39
+ function limpo(t, n) { return String(t || '').replace(/\\s+/g, ' ').trim().slice(0, n || 60); }
40
+ function papel(el) {
41
+ var r = el.getAttribute('role');
42
+ if (r) return r;
43
+ var tag = el.tagName.toLowerCase();
44
+ if (tag === 'a') return 'link';
45
+ if (tag === 'button' || tag === 'summary') return 'button';
46
+ if (tag === 'select') return 'select';
47
+ if (tag === 'textarea') return 'textbox';
48
+ if (tag === 'input') {
49
+ var t = (el.type || 'text').toLowerCase();
50
+ if (t === 'checkbox' || t === 'radio') return t;
51
+ if (t === 'submit' || t === 'button' || t === 'reset') return 'button';
52
+ return 'textbox';
53
+ }
54
+ if (el.isContentEditable) return 'textbox';
55
+ return 'button';
56
+ }
57
+ function nome(el) {
58
+ var n = el.getAttribute('aria-label') || el.getAttribute('title') || '';
59
+ if (!n && el.id) { var l = document.querySelector('label[for="' + el.id + '"]'); if (l) n = l.innerText; }
60
+ if (!n && el.closest && el.closest('label')) n = el.closest('label').innerText;
61
+ // O innerText de um select são TODAS as opções — o nome dele é o rótulo.
62
+ if (!n && el.tagName !== 'SELECT') n = el.innerText || el.value || el.getAttribute('alt') || '';
63
+ if (!n) { var img = el.querySelector && el.querySelector('img[alt]'); if (img) n = img.alt; }
64
+ return limpo(n);
65
+ }
66
+ var SEL = 'a[href],button,input:not([type=hidden]),textarea,select,summary,[contenteditable=""],[contenteditable=true],'
67
+ + '[role=button],[role=link],[role=checkbox],[role=radio],[role=tab],[role=menuitem],[role=option],[role=textbox],'
68
+ + '[role=combobox],[role=switch],[role=searchbox],[onclick],h1,h2,h3';
69
+ var linhas = [], n = 0, total = 0;
70
+ var todos = document.querySelectorAll(SEL);
71
+ for (var j = 0; j < todos.length; j++) {
72
+ var el = todos[j];
73
+ if (!visivel(el)) continue;
74
+ var tag = el.tagName.toLowerCase();
75
+ if (/^h[1-3]$/.test(tag)) { var h = limpo(el.innerText, 80); if (h) linhas.push('#'.repeat(Number(tag[1])) + ' ' + h); continue; }
76
+ total++;
77
+ if (n >= MAX) continue;
78
+ n++;
79
+ var ref = 'e' + n;
80
+ el.setAttribute(ATR, ref);
81
+ var p = papel(el), txt = nome(el), extra = '';
82
+ if (p === 'link') { var href = el.getAttribute('href') || ''; if (href && href.indexOf('javascript:') !== 0) extra = ' → ' + href.slice(0, 60); }
83
+ if (p === 'textbox' && el.placeholder && el.placeholder !== txt) extra = ' (' + limpo(el.placeholder, 40) + ')';
84
+ if ((p === 'textbox' || p === 'select') && el.value) extra += ' = "' + limpo(el.value, 40) + '"';
85
+ if ((p === 'checkbox' || p === 'radio') && el.checked) extra += ' [marcado]';
86
+ if (el.disabled) extra += ' [desativado]';
87
+ linhas.push('@' + ref + ' [' + p + '] "' + txt + '"' + extra);
88
+ }
89
+ return { titulo: document.title, url: location.href, linhas: linhas, total: total };
90
+ })()`;
91
+ }
92
+
93
+ /** Texto do retrato, pronto para o modelo. */
94
+ function formatar(r, max = 80) {
95
+ if (!r) return '';
96
+ const cab = `${r.titulo || '(sem título)'} — ${r.url || ''}`;
97
+ const mais = r.total > max ? `\n… e mais ${r.total - max} elementos (role a página e leia de novo)` : '';
98
+ return `${cab}\n${(r.linhas || []).join('\n')}${mais}`;
99
+ }
100
+
101
+ /** "@e3" ou "e3" → o seletor CSS da ref; qualquer outra coisa passa igual. */
102
+ function seletorDaRef(s) {
103
+ const m = /^@?(e\d+)$/.exec(String(s || '').trim());
104
+ return m ? `[${ATRIBUTO}="${m[1]}"]` : s;
105
+ }
106
+ function ehRef(s) { return /^@?e\d+$/.test(String(s || '').trim()); }
107
+
108
+ const COMO_USAR = 'Para agir, use a ref como selector: browser_click selector:"@e3", '
109
+ + 'browser_type selector:"@e2" text:"…". A ref vale até a página mudar; depois de navegar, leia de novo.';
110
+
111
+ module.exports = { scriptDoRetrato, formatar, seletorDaRef, ehRef, COMO_USAR, ATRIBUTO };
package/lib/tools.js CHANGED
@@ -61,6 +61,8 @@ async function clicarPeloCursor(x, y, { botao = 'left', duplo = false, rotulo }
61
61
  () => (duplo ? desktop.doubleClick(nx, ny) : desktop.click(nx, ny, botao)));
62
62
  }
63
63
  const referencia = require('./referencia');
64
+ const snapshot = require('./snapshot');
65
+ const casar = require('./casar');
64
66
  const estudio = require('./estudio');
65
67
  const cfg = require('./config');
66
68
 
@@ -85,7 +87,8 @@ function resolveInProject(rawPath, projectDir) {
85
87
  }
86
88
  const target = path.resolve(base, rawPath || '.');
87
89
  const rel = path.relative(base, target);
88
- if (rel.startsWith('..') || path.isAbsolute(rel)) {
90
+ // `..` como SEGMENTO, não como começo de nome: "..rascunho" é legítimo.
91
+ if (rel === '..' || rel.startsWith('..' + path.sep) || rel.startsWith('../') || path.isAbsolute(rel)) {
89
92
  throw new Error(`Caminho fora da pasta de trabalho: ${rawPath}`);
90
93
  }
91
94
  return target;
@@ -100,7 +103,10 @@ function treeDir(dir, maxDepth = 2, currentDepth = 0, prefix = '') {
100
103
  for (const item of items) {
101
104
  if (item.startsWith('.') && item !== '.env') continue;
102
105
  if (IGNORAR.has(item)) continue;
103
- (fs.statSync(path.join(dir, item)).isDirectory() ? dirs : files).push(item);
106
+ // Link quebrado não derruba a listagem inteira: entra como arquivo.
107
+ let ehPasta = false;
108
+ try { ehPasta = fs.statSync(path.join(dir, item)).isDirectory(); } catch { ehPasta = false; }
109
+ (ehPasta ? dirs : files).push(item);
104
110
  }
105
111
  dirs.sort(); files.sort();
106
112
  const all = [...dirs, ...files];
@@ -109,7 +115,7 @@ function treeDir(dir, maxDepth = 2, currentDepth = 0, prefix = '') {
109
115
  const isLast = i === all.length - 1;
110
116
  result += prefix + (isLast ? '└── ' : '├── ') + item + '\n';
111
117
  const full = path.join(dir, item);
112
- if (fs.statSync(full).isDirectory() && currentDepth < maxDepth) {
118
+ if (dirs.includes(item) && currentDepth < maxDepth) {
113
119
  result += treeDir(full, maxDepth, currentDepth + 1, prefix + (isLast ? ' ' : '│ '));
114
120
  }
115
121
  }
@@ -262,7 +268,19 @@ async function runInNewTerminal(command, cwd) {
262
268
  como ~/Documents/Meus Projetos virava dois argumentos e o cd falhava
263
269
  calado: o terminal abria na pasta errada e o comando rodava no lugar
264
270
  errado, que é pior que não abrir. */
265
- cmd = `osascript -e 'tell application "Terminal" to do script "cd ${pastaAlvo.replace(/ /g, '\\\\ ')} && ${command}"'`;
271
+ /* Sem shell no meio: o script vai como UM argumento do osascript
272
+ (execFile), e dentro dele a string do AppleScript é escapada (\\ e
273
+ \"). Antes o comando entrava cru entre aspas duplas dentro de aspas
274
+ simples — `node server.js --name="x"` quebrava o AppleScript. A
275
+ pasta vai entre aspas simples para o shell do Terminal (a aspa
276
+ simples está barrada acima). */
277
+ const doTerminal = `cd '${pastaAlvo}' && ${command}`;
278
+ const script = `tell application "Terminal" to do script "${doTerminal.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
279
+ return new Promise((resolve) => {
280
+ execFile('osascript', ['-e', script], { timeout: 60000 }, (erro, _o, stderr) => {
281
+ resolve(erro ? { ok: false, error: String(stderr || erro.message).trim() || 'falhou' } : { ok: true, opener: 'osascript' });
282
+ });
283
+ });
266
284
  } else if (p === 'win32') {
267
285
  // Aspas em volta do caminho: "C:\Users\Joel Silva\..." é comum, e sem
268
286
  // elas o cmd corta no espaço. A aspa dupla já está barrada acima, então
@@ -273,7 +291,8 @@ async function runInNewTerminal(command, cwd) {
273
291
  o caminho não precisa entrar na string de shell nenhuma. É a solução
274
292
  mais segura das três: o que não é interpolado não pode ser mal
275
293
  escapado. */
276
- cmd = `x-terminal-emulator -e "bash -c '${command}; exec bash'"`;
294
+ // A aspa simples do comando fecharia o `bash -c '…'`: vira '\''.
295
+ cmd = `x-terminal-emulator -e "bash -c '${String(command).replace(/'/g, "'\\''")}; exec bash'"`;
277
296
  pastaDoProcesso = pastaAlvo;
278
297
  }
279
298
  const r = await runShell(cmd, 60000, pastaDoProcesso);
@@ -315,7 +334,19 @@ function editFile({ path: rawPath, old_string, new_string, replace_all }, projec
315
334
  const original = fs.readFileSync(filePath, 'utf-8');
316
335
  const ocorrencias = original.split(old_string).length - 1;
317
336
 
337
+ // Não bateu letra por letra: o casador frouxo (lib/casar.js) acha o
338
+ // trecho com o espaço, a indentação ou o número de linha errados — desde
339
+ // que seja UM lugar só. Poupa a volta "não achei → relê → edita de novo".
340
+ let casado = null;
318
341
  if (ocorrencias === 0) {
342
+ const r = casar.achar(original, old_string);
343
+ if (r.erro === 'varios') {
344
+ return { ok: false, error: `Trecho aparece ${r.vezes} vezes (${r.como}). Inclua mais linhas em volta para ficar único.` };
345
+ }
346
+ if (!r.erro) casado = r;
347
+ }
348
+
349
+ if (ocorrencias === 0 && !casado) {
319
350
  // Erro seco faz o modelo chutar de novo. Devolvendo o que EXISTE no
320
351
  // arquivo, ele acerta na segunda tentativa.
321
352
  return {
@@ -329,15 +360,18 @@ function editFile({ path: rawPath, old_string, new_string, replace_all }, projec
329
360
  return { ok: false, error: `Trecho aparece ${ocorrencias} vezes. Inclua mais linhas em volta para ficar único, ou use replace_all.` };
330
361
  }
331
362
 
332
- const atualizado = replace_all
333
- ? original.split(old_string).join(new_string ?? '')
334
- : original.replace(old_string, new_string ?? '');
363
+ const atualizado = casado
364
+ ? original.slice(0, casado.inicio) + (new_string ?? '') + original.slice(casado.fim)
365
+ : replace_all
366
+ ? original.split(old_string).join(new_string ?? '')
367
+ : original.replace(old_string, () => new_string ?? '');
335
368
 
336
369
  fs.writeFileSync(filePath, atualizado, 'utf-8');
337
370
  return {
338
371
  ok: true,
339
372
  path: path.relative(projectDir, filePath).replace(/\\/g, '/'),
340
- substituicoes: replace_all ? ocorrencias : 1,
373
+ substituicoes: replace_all && !casado ? ocorrencias : 1,
374
+ casouPor: casado ? casado.como : undefined,
341
375
  linhasAntes: original.split('\n').length,
342
376
  linhasDepois: atualizado.split('\n').length,
343
377
  // As duas versões, para a TELA desenhar o diff. O `_` na frente marca
@@ -391,12 +425,21 @@ function multiEdit({ path: rawPath, edits }, projectDir) {
391
425
  if (old_string === new_string) return { ok: false, error: `edit ${i + 1}: old_string e new_string são iguais` };
392
426
  const ocorrencias = conteudo.split(old_string).length - 1;
393
427
  if (ocorrencias === 0) {
394
- return { ok: false, error: `edit ${i + 1}: trecho não encontrado`, trechosParecidos: trechosParecidos(conteudo, old_string) };
428
+ const r = casar.achar(conteudo, old_string);
429
+ if (r.erro === 'varios') {
430
+ return { ok: false, error: `edit ${i + 1}: trecho aparece ${r.vezes}x (${r.como}) — inclua mais contexto para torná-lo único` };
431
+ }
432
+ if (r.erro) {
433
+ return { ok: false, error: `edit ${i + 1}: trecho não encontrado`, trechosParecidos: trechosParecidos(conteudo, old_string) };
434
+ }
435
+ conteudo = conteudo.slice(0, r.inicio) + (new_string ?? '') + conteudo.slice(r.fim);
436
+ substituicoes++;
437
+ continue;
395
438
  }
396
439
  if (ocorrencias > 1 && !replace_all) {
397
440
  return { ok: false, error: `edit ${i + 1}: trecho aparece ${ocorrencias}x — use replace_all ou inclua mais contexto para torná-lo único` };
398
441
  }
399
- conteudo = replace_all ? conteudo.split(old_string).join(new_string ?? '') : conteudo.replace(old_string, new_string ?? '');
442
+ conteudo = replace_all ? conteudo.split(old_string).join(new_string ?? '') : conteudo.replace(old_string, () => new_string ?? '');
400
443
  substituicoes++;
401
444
  }
402
445
 
@@ -544,24 +587,40 @@ async function despachar(name, args, ctx) {
544
587
  }
545
588
 
546
589
  // ── Navegador controlado (CDP) — o agente age numa página real ──
590
+ /* O navegador fala em REFS (lib/snapshot.js): abrir e clicar já
591
+ devolvem o retrato da página com "@e3 [button] Entrar", e o
592
+ selector aceita "@e3". Uma leitura a menos por passo, e nenhum
593
+ seletor CSS chutado. */
547
594
  case 'browser_open': {
548
595
  const alvo = /^https?:\/\//i.test(args.url || '')
549
596
  ? args.url
550
597
  : 'file://' + resolveInProject(args.url, projectDir);
551
- return browser.open(alvo);
598
+ const r = await browser.open(alvo);
599
+ return r && r.ok !== false ? { ...r, pagina: await browser.retrato(40), comoUsar: snapshot.COMO_USAR } : r;
600
+ }
601
+ case 'browser_click': {
602
+ let r;
603
+ if (args.selector || snapshot.ehRef(args.text)) r = await browser.clickSelector(snapshot.seletorDaRef(args.selector || args.text));
604
+ else if (args.text) r = await browser.clickText(args.text);
605
+ else if (args.x != null && args.y != null) r = await browser.click(args.x, args.y);
606
+ else return { ok: false, error: 'browser_click precisa de selector (ou a ref "@e3"), text ou x/y' };
607
+ // O clique quase sempre muda a página: o retrato novo vai junto.
608
+ await new Promise((ok) => setTimeout(ok, 500));
609
+ return r && r.ok !== false ? { ...r, pagina: await browser.retrato(40) } : r;
552
610
  }
553
- case 'browser_click':
554
- if (args.selector) return browser.clickSelector(args.selector);
555
- if (args.text) return browser.clickText(args.text);
556
- if (args.x != null && args.y != null) return browser.click(args.x, args.y);
557
- return { ok: false, error: 'browser_click precisa de selector, text ou x/y' };
558
611
  case 'browser_type':
559
- if (args.selector) return browser.typeInSelector(args.selector, args.text || '', args.clear !== false);
612
+ if (args.selector) return browser.typeInSelector(snapshot.seletorDaRef(args.selector), args.text || '', args.clear !== false);
560
613
  return browser.type(args.text || '');
561
614
  case 'browser_key':
562
615
  return browser.key(args.key);
563
616
  case 'browser_read': {
564
- const sel = args.selector ? `document.querySelector(${JSON.stringify(args.selector)})` : 'document.body';
617
+ if (!args.selector) {
618
+ const pagina = await browser.retrato(80);
619
+ const texto = await browser.evaluate('(function(){return document.body?document.body.innerText.slice(0,2500):null;})()');
620
+ if (!pagina && texto == null) return { ok: false, error: 'nada na página — ela pode não ter carregado' };
621
+ return { ok: true, pagina, text: texto || '', comoUsar: snapshot.COMO_USAR };
622
+ }
623
+ const sel = `document.querySelector(${JSON.stringify(snapshot.seletorDaRef(args.selector))})`;
565
624
  const texto = await browser.evaluate(`(function(){const e=${sel};return e?e.innerText.slice(0,6000):null;})()`);
566
625
  return texto == null ? { ok: false, error: 'nada na página (ou seletor não encontrado)' } : { ok: true, text: texto };
567
626
  }
@@ -573,7 +632,7 @@ async function despachar(name, args, ctx) {
573
632
  case 'browser_scroll':
574
633
  return browser.scroll(args.dy != null ? args.dy : 600);
575
634
  case 'browser_wait':
576
- return browser.waitFor(args.selector, args.timeout_ms || 15000);
635
+ return browser.waitFor(snapshot.seletorDaRef(args.selector), args.timeout_ms || 15000);
577
636
  case 'browser_eval':
578
637
  return { ok: true, value: await browser.evaluate(args.js || 'null') };
579
638
 
package/lib/ui.js CHANGED
@@ -310,10 +310,10 @@ const TIPS = [
310
310
  ];
311
311
 
312
312
  const NEWS = [
313
- 'Vídeo com DIRETOR: roteiro, narração e voz decididos antes de produzir',
314
- 'Ele busca e baixa imagem, logo e música — com a licença junto do arquivo',
315
- '/voice abre o Primo Code Voice · /marketplace instala skills',
316
- 'Do "high" para cima ele PESQUISA referências antes de escrever',
313
+ 'Digite enquanto ele trabalha: o recado entra na tarefa depois do passo atual',
314
+ 'Mais rápido: não relê o que escreveu, e a espera aparece com os segundos',
315
+ 'primocode video help — o editor de vídeo que o agente opera sozinho',
316
+ 'Motion novo: vox, marca e lancamento · /voice: fale, e ele responde em voz',
317
317
  'Esc Esc a qualquer momento para o agente no meio do trabalho',
318
318
  ];
319
319
 
@@ -413,6 +413,7 @@ class Spinner {
413
413
  this.text = this.textos[0];
414
414
  this.frames = ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'];
415
415
  this.i = 0; this.timer = null; this.desde = 0; this.largura = 0;
416
+ this.detalhe = ''; // o que está chegando agora (act.js atualiza)
416
417
  }
417
418
  start() {
418
419
  // Fora de um terminal (pipe, CI) a animação só sujaria a saída.
@@ -423,10 +424,16 @@ class Spinner {
423
424
  // curta que a anterior: o resto da linha velha ficaria na tela, então
424
425
  // a linha é sempre preenchida até a maior largura já escrita.
425
426
  if (this.textos.length > 1) this.text = this.textos[Math.floor((Date.now() - this.desde) / 4000) % this.textos.length];
426
- const cru = ` ${this.frames[this.i]} ${this.text}`;
427
+ /* O relógio e o que está chegando. "Parece travado" era isto: a
428
+ mesma frase girando por um minuto inteiro enquanto o modelo
429
+ escrevia um arquivo grande. Com os segundos contando (e o que
430
+ já chegou, quando há), quem olha sabe que está andando. */
431
+ const seg = Math.floor((Date.now() - this.desde) / 1000);
432
+ const extra = (seg >= 3 ? ` · ${seg}s` : '') + (this.detalhe ? ` · ${this.detalhe}` : '');
433
+ const cru = ` ${this.frames[this.i]} ${this.text}${extra}`;
427
434
  const falta = Math.max(0, this.largura - cru.length);
428
435
  this.largura = Math.max(this.largura, cru.length);
429
- process.stdout.write(`\r ${c.muted(this.frames[this.i])} ${c.muted(this.text)}${' '.repeat(falta)}`);
436
+ process.stdout.write(`\r ${c.muted(this.frames[this.i])} ${c.muted(this.text)}${c.dim(extra)}${' '.repeat(falta)}`);
430
437
  this.i = (this.i+1) % this.frames.length;
431
438
  }, 80);
432
439
  return this;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * analise.js — silêncio e batida, como conta pura sobre números medidos.
3
+ *
4
+ * O ffmpeg mede (onde o áudio fica abaixo do limiar; as amostras cruas), e
5
+ * as decisões ficam aqui, testáveis sem áudio nenhum:
6
+ *
7
+ * · o SILÊNCIO vira trecho da linha do tempo com `padding` de respiro em
8
+ * cada ponta — cortar no exato instante em que a voz volta é o que faz
9
+ * corte automático soar como máquina (lib/estudio/video.js conta a mesma
10
+ * história). O silêncio no começo e no fim do arquivo sai inteiro;
11
+ * · a BATIDA é o pico de subida de energia (onset), com limiar que se
12
+ * adapta ao volume da música em volta. Não é um detector de tempo de
13
+ * estúdio; é o bastante para cortar no ritmo.
14
+ */
15
+
16
+ 'use strict';
17
+
18
+ const T = require('./timeline.js');
19
+
20
+ /**
21
+ * Os trechos (quadros da linha do tempo) a apagar, dados os silêncios de
22
+ * cada mídia em segundos da origem: {assetId: [{de, ate}]}.
23
+ */
24
+ function trechosDeSilencio(tl, clipes, silencios, { padding = 0.15, minimo = 2 } = {}) {
25
+ const ranges = [];
26
+ for (const c of clipes) {
27
+ const lista = silencios[c.asset] || [];
28
+ const dur = (tl.assets[c.asset] || {}).seconds || Infinity;
29
+ const { de, ate } = T.janelaOrigem(tl, c);
30
+ for (const s of lista) {
31
+ const a = s.de <= 0.05 ? s.de : s.de + padding;
32
+ const b = s.ate >= dur - 0.05 ? s.ate : s.ate - padding;
33
+ const ia = Math.max(a, de);
34
+ const ib = Math.min(b, ate);
35
+ if (ib <= ia) continue;
36
+ const fa = T.origemParaQuadro(tl, c, ia);
37
+ const fb = T.origemParaQuadro(tl, c, ib);
38
+ if (fb - fa >= minimo) ranges.push([fa, fb]);
39
+ }
40
+ }
41
+ return ranges;
42
+ }
43
+
44
+ /** Amostras (Float32, mono) → {bpm, beats: [segundos]}. */
45
+ function batidas(amostras, taxa, { sensibilidade = 1.4, minimoEntre = 0.22 } = {}) {
46
+ const hop = Math.max(64, Math.round(taxa * 0.0116)); // ~11,6 ms
47
+ const janela = hop * 2;
48
+ const energia = [];
49
+ for (let i = 0; i + janela <= amostras.length; i += hop) {
50
+ let s = 0;
51
+ for (let j = i; j < i + janela; j++) s += amostras[j] * amostras[j];
52
+ energia.push(Math.log(1e-9 + s / janela));
53
+ }
54
+ const onset = energia.map((e, i) => (i ? Math.max(0, e - energia[i - 1]) : 0));
55
+ const porSeg = taxa / hop;
56
+ const raio = Math.round(porSeg * 0.5);
57
+ const beats = [];
58
+ let ultimo = -Infinity;
59
+ for (let i = 1; i < onset.length - 1; i++) {
60
+ const a = Math.max(0, i - raio);
61
+ const b = Math.min(onset.length, i + raio);
62
+ let soma = 0;
63
+ let soma2 = 0;
64
+ for (let j = a; j < b; j++) { soma += onset[j]; soma2 += onset[j] * onset[j]; }
65
+ const n = b - a;
66
+ const media = soma / n;
67
+ const dp = Math.sqrt(Math.max(0, soma2 / n - media * media));
68
+ const limiar = media + sensibilidade * dp;
69
+ let pico = onset[i] > limiar && onset[i] > 0.3;
70
+ for (let j = Math.max(0, i - 3); pico && j <= Math.min(onset.length - 1, i + 3); j++) if (onset[j] > onset[i]) pico = false;
71
+ const t = (i * hop) / taxa;
72
+ if (pico && t - ultimo >= minimoEntre) { beats.push(Math.round(t * 1000) / 1000); ultimo = t; }
73
+ }
74
+ return { bpm: bpmDe(beats), beats };
75
+ }
76
+
77
+ function bpmDe(beats) {
78
+ if (beats.length < 4) return null;
79
+ const iv = [];
80
+ for (let i = 1; i < beats.length; i++) iv.push(beats[i] - beats[i - 1]);
81
+ iv.sort((a, b) => a - b);
82
+ let med = iv[Math.floor(iv.length / 2)];
83
+ let bpm = 60 / med;
84
+ while (bpm < 70) bpm *= 2;
85
+ while (bpm > 180) bpm /= 2;
86
+ return Math.round(bpm);
87
+ }
88
+
89
+ module.exports = { trechosDeSilencio, batidas, bpmDe };