primocode 8.22.0 → 8.25.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 (37) hide show
  1. package/lib/act.js +41 -1
  2. package/lib/catalogo.js +2 -0
  3. package/lib/config.js +53 -2
  4. package/lib/referencia.js +145 -18
  5. package/lib/studio.js +125 -11
  6. package/lib/tools.js +35 -1
  7. package/package.json +2 -2
  8. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  9. package/studio/arquivos/corte-estatico-corte-a5bab.json +150 -0
  10. package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +65 -0
  11. package/studio/arquivos/corte-legado-teste.json +1 -0
  12. package/studio/arquivos/corte-legado2.json +1 -0
  13. package/studio/arquivos/corte-minimo-2a829.json +24 -0
  14. package/studio/arquivos/corte-ok-00a3b.json +18 -0
  15. package/studio/arquivos/corte-ok-1270e.json +22 -0
  16. package/studio/arquivos/corte-sweep-corte-cfb29.json +147 -0
  17. package/studio/arquivos/corte-visual-completo-78f50.json +35 -0
  18. package/studio/arquivos/grade-sweep-grade-0c2f1.json +119 -0
  19. package/studio/arquivos/prisma-estatico-prisma-d8740.json +182 -0
  20. package/studio/arquivos/prisma-sweep-prisma-4c58c.json +166 -0
  21. package/studio/arquivos/prisma-y-b0785.json +52 -0
  22. package/studio/arquivos/prosa-sweep-prosa-f29cf.json +80 -0
  23. package/studio/arquivos/tela-estatico-tela-c7daf.json +90 -0
  24. package/studio/arquivos/tela-sweep-tela-0d2bf.json +84 -0
  25. package/studio/arquivos/traco-estatico-traco-25872.json +113 -0
  26. package/studio/arquivos/traco-sweep-traco-0bcf4.json +102 -0
  27. package/studio/ferramentas/corte.md +159 -12
  28. package/studio/server.py +264 -8
  29. package/studio/web/analise.html +52 -0
  30. package/studio/web/js/analise.js +202 -0
  31. package/studio/web/js/exportar.js +88 -1
  32. package/studio/web/js/player.js +172 -46
  33. package/studio/web/js/render.js +99 -1
  34. package/studio/web/js/video.js +434 -0
  35. package/studio/web/render.css +27 -0
  36. package/studio/web/tool.html +1 -0
  37. package/studio/web/view.html +2 -0
package/lib/act.js CHANGED
@@ -349,6 +349,12 @@ async function runAct(userPrompt, opts) {
349
349
  // Quem já foi chamado, e quantas vezes. Ver lib/repeticao.js.
350
350
  const jaChamado = repeticao.novo();
351
351
  let voltasEmFalso = 0;
352
+ // Pesquisas que voltaram de mãos vazias. A partir da terceira, a tool
353
+ // para de rodar e devolve ORDEM DE SEGUIR: sem isto o agente entrava em
354
+ // ciclo — buscava, não achava, lia "tente outra consulta", buscava de
355
+ // novo — até a cota do minuto acabar e o modelo responder vazio. Foi
356
+ // assim que "crie um vídeo para a tostudy" morreu sem entregar nada.
357
+ let pesquisasSecas = 0;
352
358
  let rodadasParadas = 0;
353
359
 
354
360
  const messages = [];
@@ -515,9 +521,22 @@ async function runAct(userPrompt, opts) {
515
521
  // o modelo grátis engasgando. Cair direto no "parei sem concluir"
516
522
  // jogava a tarefa fora quando repetir a MESMA rodada quase sempre
517
523
  // resolve — visto em produção no primeiro pedido de vídeo.
518
- if (!finalText && respostasVazias < 2 && !PARAR) {
524
+ if (!finalText && respostasVazias < 3 && !PARAR) {
519
525
  respostasVazias++;
520
526
  if (!subagent) console.log(ident(profundidade) + c.dim(' ⎿ o modelo respondeu vazio — tentando de novo'));
527
+ // Repetir o MESMO contexto costuma produzir o mesmo vazio: se
528
+ // o turno anterior já engasgou, o que falta é um empurrão
529
+ // curto e concreto. A partir da segunda vez, entra um pedido
530
+ // explícito de fechamento — barato em tokens (é justamente
531
+ // quando a cota está no fim) e suficiente para o modelo
532
+ // escrever a resposta ou chamar finish.
533
+ if (respostasVazias >= 2) {
534
+ messages.push({
535
+ role: 'user',
536
+ content: 'Sua última resposta veio vazia. Não pesquise mais nem explique o processo: '
537
+ + 'faça agora a entrega pedida com o que você já tem e finalize.',
538
+ });
539
+ }
521
540
  iteration--;
522
541
  continue;
523
542
  }
@@ -616,6 +635,22 @@ async function runAct(userPrompt, opts) {
616
635
  temPasta = true;
617
636
  }
618
637
 
638
+ if (tc.name === 'pesquisar_referencia' && pesquisasSecas >= 3) {
639
+ voltasEmFalso++;
640
+ console.log(ident(profundidade) + actionHeader(tc.name, tc.arguments));
641
+ console.log(ident(profundidade) + c.muted(' ⎿ ') + c.warn('3 pesquisas sem resultado — siga com o que já tem'));
642
+ messages.push({
643
+ role: 'tool',
644
+ tool_call_id: tc.id,
645
+ content: 'PARE DE PESQUISAR. Três buscas seguidas não trouxeram fonte nenhuma; '
646
+ + 'mais uma vai gastar a cota e devolver o mesmo nada. FAÇA AGORA a entrega '
647
+ + 'pedida com o que você já sabe. Se faltar um fato específico (número, data, '
648
+ + 'preço, nome de pessoa), NÃO invente: escreva a peça sem ele e avise o '
649
+ + 'usuário no final, em uma linha, o que não deu para confirmar.',
650
+ });
651
+ continue;
652
+ }
653
+
619
654
  algoRodou = true;
620
655
  console.log(ident(profundidade) + actionHeader(tc.name, tc.arguments));
621
656
  const result = await tools.execute(tc.name, tc.arguments, { projectDir: pastaAtual });
@@ -626,6 +661,11 @@ async function runAct(userPrompt, opts) {
626
661
  // não corrige um teste quebrado noutro lugar; antes isto zerava
627
662
  // a lista inteira e o agente conseguia dizer "concluído" com uma
628
663
  // falha real ainda pendente.
664
+ if (tc.name === 'pesquisar_referencia') {
665
+ if (result && result.ok === false) pesquisasSecas++;
666
+ else pesquisasSecas = 0; // achou fonte: o ciclo se rompeu
667
+ }
668
+
629
669
  if (result && result.ok === false) {
630
670
  falhasPendentes.push({
631
671
  tool: tc.name,
package/lib/catalogo.js CHANGED
@@ -89,6 +89,8 @@ const GRUPOS = [
89
89
  { tool: 'studio_listar', escreve: false, faz: 'lista o que já existe no estúdio', args: null },
90
90
  { tool: 'studio_midia', escreve: false, faz: 'manda imagem, música ou vídeo seu para usar nas peças',
91
91
  args: 'arquivo (opcional): caminho no projeto — sem ele, lista a galeria. nome (opcional).' },
92
+ { tool: 'studio_analisar', escreve: false, faz: 'ouve um vídeo já enviado e diz onde estão as pausas, para cortá-las',
93
+ args: 'arquivo: a URL "/midia/..." do vídeo. pausa (opcional): segundos a partir dos quais conta como pausa (padrão 0.6). Devolve "trechos": vire cada um numa cena com {"tipo":"clipe","de":...,"ate":...}.' },
92
94
  ],
93
95
  },
94
96
  {
package/lib/config.js CHANGED
@@ -135,9 +135,31 @@ function findSimilarProject(projectName, minScore = 0.4) {
135
135
  return best;
136
136
  }
137
137
 
138
- function readGeneralMemory(limit = 2000) {
139
- try { return fs.readFileSync(GENERAL_MEMORY, 'utf-8').slice(0, limit); }
138
+ /* A memória geral como o modelo precisa vê-la: o começo E o fim.
139
+ *
140
+ * Isto lia os PRIMEIROS 2000 caracteres — que são o cabeçalho do template.
141
+ * As anotações vão para o FIM do arquivo, então tudo que o agente aprendia
142
+ * ficava fora do corte e nunca chegava ao modelo. O usuário via o sintoma
143
+ * exato: "nunca lembra do que fez no chat anterior". O arquivo enchia, a
144
+ * poda funcionava, e nada daquilo era lido.
145
+ *
146
+ * Agora vêm as duas pontas: o cabeçalho (o que a PESSOA escreveu sobre si,
147
+ * que é permanente) e as anotações mais recentes (o que o agente aprendeu,
148
+ * que é o que muda). O meio é o que se perde — e é o certo a perder. */
149
+ function readGeneralMemory(limit = 2600) {
150
+ let texto;
151
+ try { texto = fs.readFileSync(GENERAL_MEMORY, 'utf-8'); }
140
152
  catch { return ''; }
153
+ if (texto.length <= limit) return texto;
154
+
155
+ const blocos = texto.split(/\n(?=### )/);
156
+ const cabeca = (blocos.shift() || '').slice(0, Math.floor(limit * 0.45));
157
+ let recente = '';
158
+ for (let i = blocos.length - 1; i >= 0; i--) {
159
+ if (cabeca.length + recente.length + blocos[i].length > limit) break;
160
+ recente = '\n' + blocos[i] + recente;
161
+ }
162
+ return cabeca + (recente ? '\n' + recente : '');
141
163
  }
142
164
 
143
165
  // Recebem o DIRETÓRIO de trabalho, não o nome do projeto: quando o PrimoCode
@@ -156,6 +178,34 @@ function appendProjectMemory(dir, entry) {
156
178
  } catch {}
157
179
  }
158
180
 
181
+ /* O que o usuário JÁ criou no estúdio, para "lembra do vídeo tal" funcionar.
182
+ *
183
+ * A memória geral guarda "pedido → resultado" em texto corrido, e o id da
184
+ * peça se perdia no meio da frase. O dono pediu:
185
+ *
186
+ * "Ter memória entre os chats. Ela lembrar do que fez no chat anterior.
187
+ * Quando eu falar 'lembra do vídeo tal', ela vai lembrar. Na estrutura de
188
+ * arquivos, eu quero que você crie para mim a partir de vídeos, não só de
189
+ * código."
190
+ *
191
+ * Uma linha por peça, com tipo, título e id — que é o que o agente precisa
192
+ * para chamar studio_atualizar ou abrir de novo. Sem link: ele é derivável
193
+ * do id e ocuparia espaço em todo pedido seguinte, para sempre.
194
+ *
195
+ * A entrada é DEDUPLICADA por id: recriar ou atualizar a mesma peça não pode
196
+ * encher a memória de cópias — foi o que aconteceria num vídeo ajustado dez
197
+ * vezes numa tarefa só. */
198
+ function lembrarCriacao({ ferramenta, titulo, id }) {
199
+ if (!id) return;
200
+ const linha = `- criou ${ferramenta || 'peça'} "${String(titulo || 'sem título').slice(0, 70)}" — id \`${id}\``;
201
+ try {
202
+ ensureStructure();
203
+ const texto = fs.existsSync(GENERAL_MEMORY) ? fs.readFileSync(GENERAL_MEMORY, 'utf-8') : '';
204
+ if (texto.includes(`\`${id}\``)) return; // já anotada
205
+ appendGeneralMemory(linha);
206
+ } catch {}
207
+ }
208
+
159
209
  // A contraparte de appendProjectMemory, para o que vale em TODOS os projetos
160
210
  // (nome do usuário, preferência, decisão de longo prazo). Existia a promessa
161
211
  // no prompt — "escreve aqui o que descobrir sobre você" — mas nenhuma função
@@ -240,4 +290,5 @@ module.exports = {
240
290
  ensureStructure, slugify, stripAccents,
241
291
  createProjectDir, getProjectDir, listProjects, findSimilarProject,
242
292
  readGeneralMemory, readProjectMemory, appendProjectMemory, appendGeneralMemory,
293
+ lembrarCriacao,
243
294
  };
package/lib/referencia.js CHANGED
@@ -244,21 +244,116 @@ function relevante(cand, palavras) {
244
244
  * Foi o que salvou este caso — parisgroup.ai abre e tem o texto todo, enquanto
245
245
  * DuckDuckGo devolvia 202 e o Bing devolvia Paris, a cidade.
246
246
  *
247
- * Só dispara quando a consulta tem DUAS PALAVRAS COM INICIAL MAIÚSCULA
248
- * seguidas, que é como se escreve nome de empresa. "react hooks tutorial" não
249
- * dispara; "Paris Group" dispara.
247
+ * Reconhece nome de DUAS palavras com maiúscula ("Paris Group") e também de
248
+ * UMA palavra, mesmo em minúscula — que é como as pessoas digitam no meio de
249
+ * uma frase. Exigir duas maiúsculas custou caro: para "crie um vídeo para a
250
+ * tostudy, plataforma de aprendizagem com IA" a função devolvia lista vazia,
251
+ * o buscador não conhecia o nome, a pesquisa falhava — e o agente repetia
252
+ * buscas até a tarefa morrer, com tostudy.ai no ar o tempo todo.
250
253
  */
254
+
255
+ // Palavras que aparecem em qualquer pedido e NUNCA são o nome da marca. Sem
256
+ // esta peneira o chute viraria plataforma.com, aprendizagem.com, video.com.
257
+ const NAO_E_MARCA = new Set([
258
+ 'plataforma', 'plataformas', 'empresa', 'startup', 'site', 'oficial', 'aplicativo', 'app',
259
+ 'aprendizagem', 'aprendizado', 'ensino', 'educacao', 'educacional', 'curso', 'cursos', 'escola',
260
+ 'video', 'videos', 'reels', 'anuncio', 'campanha', 'marketing', 'institucional', 'apresentacao',
261
+ 'inteligencia', 'artificial', 'brasil', 'brasileira', 'sobre', 'para', 'como', 'uma', 'que',
262
+ 'crie', 'criar', 'faca', 'fazer', 'quero', 'preciso', 'estudo', 'estudos', 'tecnologia',
263
+ 'software', 'sistema', 'servico', 'servicos', 'produto', 'produtos', 'negocio', 'digital',
264
+ 'documentacao', 'tutorial', 'exemplo', 'exemplos', 'guia', 'referencia',
265
+ ]);
266
+
267
+ function _slug(txt) {
268
+ return String(txt || '').normalize('NFD').replace(/[̀-ͯ]/g, '')
269
+ .toLowerCase().replace(/[^a-z0-9]/g, '');
270
+ }
271
+
251
272
  function sitesProvaveis(consulta) {
252
- const nome = /\b([A-ZÀ-Ý][\w\u00C0-\u024F]{1,})\s+([A-ZÀ-Ý][\w\u00C0-\u024F]{1,})\b/.exec(String(consulta || ''));
253
- if (!nome) return [];
254
- const slug = `${nome[1]}${nome[2]}`
255
- .normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
256
- if (slug.length < 5) return [];
257
- return ['com', 'ai', 'com.br', 'io', 'co'].map((tld) => ({
258
- titulo: `${nome[1]} ${nome[2]} — site oficial`,
259
- url: `https://www.${slug}.${tld}`,
260
- nota: 'site provável da empresa',
273
+ const texto = String(consulta || '');
274
+ const marcas = [];
275
+
276
+ // Entre aspas é a forma de a pessoa dizer "o nome é ESTE". Vem primeiro.
277
+ const citado = /["“'”]([^"“'”]{3,40})["“'”]/.exec(texto);
278
+ if (citado) marcas.push({ rotulo: citado[1].trim(), slug: _slug(citado[1]) });
279
+
280
+ const duplo = /\b([A-ZÀ-Ý][\wÀ-ɏ]{1,})\s+([A-ZÀ-Ý][\wÀ-ɏ]{1,})\b/.exec(texto);
281
+ if (duplo) marcas.push({ rotulo: `${duplo[1]} ${duplo[2]}`, slug: _slug(duplo[1] + duplo[2]) });
282
+
283
+ // Marca de UMA palavra só é chutada quando a consulta dá SINAL de que
284
+ // fala de um negócio ("plataforma", "empresa", "app", "site"...) ou põe o
285
+ // nome entre aspas. Sem essa exigência, "react hooks tutorial" viraria
286
+ // react.com e "como fazer um bolo" viraria fazer.com — dez buscas
287
+ // inúteis para cada pesquisa técnica.
288
+ const SINAL_DE_MARCA = /\b(plataforma|empresa|startup|site|marca|neg[óo]cio|produto|servi[çc]o|app|aplicativo|institucional|cliente|fabricante|loja|ag[êe]ncia|ecommerce|e-commerce|saas)\b/i;
289
+ const temSinal = SINAL_DE_MARCA.test(texto) || !!citado;
290
+ if (temSinal) {
291
+ const util = (bruto) => {
292
+ const s = _slug(bruto);
293
+ return (s.length >= 4 && s.length <= 24 && !NAO_E_MARCA.has(s) && !/^\d+$/.test(s)) ? s : null;
294
+ };
295
+ // O nome vem quase sempre depois de artigo ou preposição: "um mouse
296
+ // DA multilaser", "vídeo para A tostudy". Pegar simplesmente a
297
+ // primeira palavra não-genérica escolhia o verbo do pedido
298
+ // ("anunciar") e chutava anunciar.ai.
299
+ const depoisDeArtigo = [...texto.matchAll(/\b(?:d[aeo]s?|[ao]|n[ao]s?|à)\s+([A-Za-zÀ-ÿ][\wÀ-ÿ-]{3,23})\b/gi)];
300
+ for (const m of depoisDeArtigo) {
301
+ const s = util(m[1]);
302
+ if (s) { marcas.push({ rotulo: m[1], slug: s }); break; }
303
+ }
304
+ // Só quando o artigo não entregou nada: senão o verbo do pedido
305
+ // ("anunciar") entraria junto e gastaria dez sondas à toa.
306
+ if (!marcas.length) {
307
+ for (const bruto of texto.split(/[\s,.;:!?()]+/)) {
308
+ const s = util(bruto);
309
+ if (s) { marcas.push({ rotulo: bruto, slug: s }); break; }
310
+ }
311
+ }
312
+ }
313
+
314
+ const vistos = new Set();
315
+ const saida = [];
316
+ for (const m of marcas.slice(0, 2)) {
317
+ if (!m.slug || m.slug.length < 4 || vistos.has(m.slug)) continue;
318
+ vistos.add(m.slug);
319
+ for (const tld of ['ai', 'com', 'com.br', 'io', 'co']) {
320
+ // Com e sem "www": um resolve onde o outro não, e quem decide é a
321
+ // hospedagem. Como estes candidatos passam pela sonda antes de
322
+ // entrar na fila de leitura, tentar os dois não custa vaga.
323
+ saida.push({ titulo: `${m.rotulo} — site oficial`, url: `https://${m.slug}.${tld}`, nota: 'site provável da empresa' });
324
+ saida.push({ titulo: `${m.rotulo} — site oficial`, url: `https://www.${m.slug}.${tld}`, nota: 'site provável da empresa' });
325
+ }
326
+ }
327
+ return saida;
328
+ }
329
+
330
+ /**
331
+ * Sonda os domínios chutados e devolve só os que responderam, com o HTML já
332
+ * na mão. Sem isto, dez chutes entrariam na fila de leitura e expulsariam os
333
+ * resultados de verdade do buscador — e a maioria deles nem existe.
334
+ */
335
+ async function chutesVivos(chutes, limite = 2) {
336
+ if (!chutes.length) return { vivos: [], html: new Map() };
337
+ // 12 s, não 6: a sonda roda em paralelo e a home de uma empresa hoje pesa
338
+ // centenas de KB. Com 6 s o site CERTO chegava atrasado, era descartado
339
+ // como morto, e a pesquisa falhava com o site no ar — medido.
340
+ const respostas = await Promise.all(chutes.slice(0, 12).map(async (c) => {
341
+ const html = await pegar(c.url, { timeout: 12000 });
342
+ return html && html.length > 500 ? { cand: c, html } : null;
261
343
  }));
344
+ const vivos = [];
345
+ const html = new Map();
346
+ const dominios = new Set();
347
+ for (const r of respostas.filter(Boolean)) {
348
+ // www.x.ai e x.ai são o mesmo site: vale o primeiro que respondeu.
349
+ const raiz = r.cand.url.replace(/^https?:\/\/(www\.)?/, '').replace(/\/$/, '');
350
+ if (dominios.has(raiz)) continue;
351
+ dominios.add(raiz);
352
+ vivos.push(r.cand);
353
+ html.set(r.cand.url, r.html);
354
+ if (vivos.length >= limite) break;
355
+ }
356
+ return { vivos, html };
262
357
  }
263
358
 
264
359
  // Links que não são referência de nada — resultado de busca sujo.
@@ -460,7 +555,17 @@ async function pesquisar({ consulta, paginas = 3 }) {
460
555
  // O site provável da empresa vai NA FRENTE: quando a consulta nomeia um
461
556
  // negócio, o site dele é a melhor fonte que existe, e não depende de
462
557
  // buscador nenhum estar de bom humor.
463
- const chutes = sitesProvaveis(termo);
558
+ // Os chutes de domínio são SONDADOS antes de entrar na fila: só os que
559
+ // respondem ocupam vaga. São dez palpites (5 TLDs x com/sem www) e a
560
+ // maioria não existe — enfileirar todos expulsaria os resultados reais
561
+ // do buscador. A sonda roda em paralelo e o HTML já volta aproveitado.
562
+ const palpites = sitesProvaveis(termo);
563
+ const { vivos, html: htmlChutes } = await chutesVivos(palpites);
564
+ // A sonda ORDENA, não faz porteiro. Ela é uma corrida contra o relógio
565
+ // contra home pesada e rede lenta, e perdê-la não pode significar perder
566
+ // o site certo: quando nada responde a tempo, os primeiros palpites vão
567
+ // para a fila mesmo assim e o laço de leitura tenta de novo, sem pressa.
568
+ const chutes = vivos.length ? vivos : palpites.slice(0, 3);
464
569
  const palavras = palavrasDaConsulta(termo);
465
570
  const brutos = [...chutes, ...web, ...so, ...docsConhecidas(termo)];
466
571
 
@@ -481,7 +586,15 @@ async function pesquisar({ consulta, paginas = 3 }) {
481
586
  provavelCausa: descartados
482
587
  ? `Vieram ${descartados} link(s), mas nenhum era sobre "${termo}" — o buscador não achou o assunto e devolveu página aleatória. Fonte errada é pior que fonte nenhuma, então foram descartadas.`
483
588
  : 'Os buscadores costumam recusar chamadas automáticas (bloqueio ou limite por excesso de buscas seguidas), e não a sua conexão.',
484
- comoCorrigir: 'Tente OUTRA consulta, mais curta e específica. Se souber o site do assunto, vá direto nele com browser_open + browser_read — é o caminho mais confiável para fato de empresa.',
589
+ // "Tente outra consulta" era um convite ao ciclo: o agente
590
+ // reformulava a busca, falhava igual, e repetia até a cota acabar.
591
+ // A ordem agora é explícita — no máximo UMA nova tentativa, e por
592
+ // um caminho DIFERENTE (o site direto), senão segue sem a fonte.
593
+ comoCorrigir: 'NÃO repita esta busca com outras palavras — o buscador não conhece o assunto e '
594
+ + 'vai devolver o mesmo nada. Se souber (ou conseguir deduzir) o site, tente UMA vez '
595
+ + 'browser_open + browser_read nele. Se também não abrir, SIGA COM A TAREFA usando o que '
596
+ + 'você já sabe e avise ao usuário, no final, qual fato não deu para confirmar. Nunca invente '
597
+ + 'dado sobre empresa, número, data, preço ou pessoa.',
485
598
  };
486
599
  }
487
600
 
@@ -491,7 +604,9 @@ async function pesquisar({ consulta, paginas = 3 }) {
491
604
  const chutesLidos = [];
492
605
  for (const cand of candidatos) {
493
606
  if (lidas.length >= paginas) break;
494
- const html = await pegar(cand.url);
607
+ // A sonda já baixou o HTML dos chutes: reaproveita em vez de pedir
608
+ // a mesma página duas vezes.
609
+ const html = htmlChutes.get(cand.url) || await pegar(cand.url);
495
610
  if (!html) continue;
496
611
  const texto = textoDe(html);
497
612
  if (texto.length < 200) continue;
@@ -502,7 +617,17 @@ async function pesquisar({ consulta, paginas = 3 }) {
502
617
  // da página, sim. Sem esta prova o agente escreveria sobre a errada.
503
618
  const ehChute = cand.nota === 'site provável da empresa';
504
619
  if (ehChute) {
505
- const nota = quantoBate({ titulo: texto.slice(0, 4000), url: cand.url }, palavras);
620
+ let nota = quantoBate({ titulo: texto.slice(0, 4000), url: cand.url }, palavras);
621
+ // O NOME DA MARCA no domínio, confirmado no texto da página, já é
622
+ // prova — e é a prova mais forte que existe. Exigir duas palavras
623
+ // genéricas da consulta ("plataforma", "aprendizagem") reprovava o
624
+ // site certo: tostudy.ai abria, dizia "ToStudy" na primeira linha,
625
+ // e mesmo assim era descartado por não repetir o vocabulário do
626
+ // pedido. O agente então repetia buscas até a tarefa morrer.
627
+ const marca = (cand.url.match(/^https?:\/\/(?:www\.)?([a-z0-9-]+)\./i) || [])[1] || '';
628
+ const textoLimpo = texto.slice(0, 8000).normalize('NFD')
629
+ .replace(/[\u0300-\u036f]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '');
630
+ if (marca.length >= 4 && textoLimpo.includes(marca.toLowerCase())) nota = Math.max(nota, 2);
506
631
  if (nota < Math.min(2, palavras.length)) continue;
507
632
  // Passar não basta: entre dois domínios do MESMO nome, fica o que
508
633
  // fala mais do assunto. parisgroup.ai e parisgroup.co os dois
@@ -532,8 +657,10 @@ async function pesquisar({ consulta, paginas = 3 }) {
532
657
  error: 'achei os links mas não consegui abrir nenhum',
533
658
  consulta: termo,
534
659
  links: candidatos.map((c) => c.url),
535
- comoCorrigir: 'Tente browser_open + browser_read num desses links, ou pesquise com outras palavras. '
536
- + 'Se nada abrir, diga ao usuário que não conseguiu confirmar os fatos — NUNCA invente dado sobre empresa, data, preço ou pessoa.',
660
+ comoCorrigir: 'Tente browser_open + browser_read num desses links — UMA vez. Não refaça a busca '
661
+ + 'com outras palavras: os links são estes mesmos. Se nada abrir, SIGA COM A TAREFA e diga ao '
662
+ + 'usuário, no final, que não conseguiu confirmar os fatos — NUNCA invente dado sobre empresa, '
663
+ + 'data, preço ou pessoa.',
537
664
  };
538
665
  }
539
666
 
package/lib/studio.js CHANGED
@@ -412,9 +412,32 @@ const TIPOS_MIDIA = {
412
412
 
413
413
  const midiaListar = () => comStudio(async () => ({ ok: true, midia: await pedir('GET', '/api/midia') }));
414
414
 
415
+ /* O caminho como ele chega quando a pessoa ARRASTA o arquivo para o terminal.
416
+ *
417
+ * "Eu quero que eu consiga arrastar os meus vídeos pra dentro do terminal."
418
+ * Arrastar, num terminal, cola o caminho — e cola do jeito do sistema: o
419
+ * macOS escapa espaço com barra invertida, o Windows põe aspas em volta, e
420
+ * alguns colam file:// na frente. `path.resolve` de qualquer um desses dá um
421
+ * caminho que não existe, e a resposta era "arquivo não existe" para um
422
+ * arquivo que está bem ali.
423
+ */
424
+ function limparCaminho(bruto) {
425
+ let s = String(bruto || '').trim();
426
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
427
+ s = s.slice(1, -1);
428
+ }
429
+ if (/^file:\/\//i.test(s)) {
430
+ try { s = decodeURIComponent(s.replace(/^file:\/\//i, '')); } catch { /* fica como está */ }
431
+ }
432
+ // Só desescapa o que o shell escapa; um "\" de caminho do Windows fica.
433
+ s = s.replace(/\\([ ()'"&!$`\\])/g, '$1');
434
+ if (s.startsWith('~/') || s === '~') s = path.join(os.homedir(), s.slice(1));
435
+ return s.trim();
436
+ }
437
+
415
438
  const midiaEnviar = ({ arquivo, nome }) => comStudio(async () => {
416
439
  if (!arquivo) return { ok: false, error: 'Diga o caminho do arquivo (arquivo: "foto.jpg").' };
417
- const caminho = path.resolve(arquivo);
440
+ const caminho = path.resolve(limparCaminho(arquivo));
418
441
  if (!fs.existsSync(caminho)) {
419
442
  return {
420
443
  ok: false,
@@ -430,22 +453,113 @@ const midiaEnviar = ({ arquivo, nome }) => comStudio(async () => {
430
453
  return { ok: false, error: `Formato não aceito: ${ext}`, aceitos: Object.keys(TIPOS_MIDIA).join(' ') };
431
454
  }
432
455
  const bytes = fs.statSync(caminho).size;
433
- if (bytes > 60 * 1024 * 1024) return { ok: false, error: `Arquivo tem ${(bytes / 1048576).toFixed(1)} MB; o limite é 60 MB.` };
456
+ const ehVideo = ext === '.mp4' || ext === '.webm' || ext === '.mov';
457
+ const teto = ehVideo ? 2 * 1024 * 1024 * 1024 : 60 * 1024 * 1024;
458
+ if (bytes > teto) {
459
+ return { ok: false, error: `Arquivo tem ${(bytes / 1048576).toFixed(1)} MB; `
460
+ + `o limite é ${Math.round(teto / 1048576)} MB.` };
461
+ }
434
462
 
435
- const tipo = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
436
- '.webp': 'image/webp', '.svg': 'image/svg+xml', '.avif': 'image/avif', '.mp3': 'audio/mpeg',
437
- '.m4a': 'audio/mp4', '.wav': 'audio/wav', '.ogg': 'audio/ogg', '.aac': 'audio/aac',
438
- '.flac': 'audio/flac', '.opus': 'audio/opus', '.weba': 'audio/webm', '.mp4': 'video/mp4',
439
- '.webm': 'video/webm', '.mov': 'video/quicktime' }[ext] || 'application/octet-stream';
463
+ // O corpo vai em pedaços, direto do disco para o socket. Em base64 um
464
+ // vídeo de celular precisaria caber inteiro na memória três vezes — e o
465
+ // Node estourava antes de o estúdio ver um byte.
466
+ const r = await enviarArquivo(caminho, nome || path.basename(caminho));
467
+ const comoUsar = ehVideo
468
+ ? `Use "${r.url}" no "src" de um elemento {"tipo":"clipe"}. Para cortar as pausas, `
469
+ + 'escreva VÁRIOS clipes do mesmo src, cada um com "de" e "ate" em segundos.'
470
+ : `Use "${r.url}" em fundo.imagem, em src de uma imagem, ou em trilha.src.`;
471
+ return { ok: true, ...r, comoUsar };
472
+ });
440
473
 
441
- const dados = `data:${tipo};base64,` + fs.readFileSync(caminho).toString('base64');
442
- const r = await pedir('POST', '/api/midia', { nome: nome || path.basename(caminho), dados }, 120000);
443
- return { ok: true, ...r, comoUsar: `Use "${r.url}" em fundo.imagem, em src de uma imagem, ou em trilha.src.` };
474
+ /* PUT /api/midia/<nome> com o arquivo cru no corpo. */
475
+ function enviarArquivo(caminho, nome) {
476
+ return new Promise((resolve, reject) => {
477
+ const bytes = fs.statSync(caminho).size;
478
+ const req = http.request({
479
+ hostname: '127.0.0.1', port: PORTA, method: 'PUT',
480
+ path: '/api/midia/' + encodeURIComponent(nome),
481
+ // Vídeo grande copiando para o disco leva tempo; meia hora é
482
+ // folga para um arquivo de duas horas, e continua sendo um teto.
483
+ timeout: 30 * 60 * 1000,
484
+ headers: { 'X-Primo-Agente': '1', 'Content-Type': 'application/octet-stream',
485
+ 'Content-Length': bytes },
486
+ }, (res) => {
487
+ let texto = '';
488
+ res.setEncoding('utf8');
489
+ res.on('data', (c) => { texto += c; });
490
+ res.on('end', () => {
491
+ let corpo = null;
492
+ try { corpo = JSON.parse(texto); } catch { /* texto cru serve */ }
493
+ if (res.statusCode >= 400) {
494
+ return reject(new Error((corpo && corpo.erro) || texto.slice(0, 300)));
495
+ }
496
+ resolve(corpo || {});
497
+ });
498
+ });
499
+ req.on('timeout', () => { req.destroy(new Error('o envio demorou demais')); });
500
+ req.on('error', reject);
501
+ const leitura = fs.createReadStream(caminho);
502
+ leitura.on('error', (e) => { req.destroy(e); reject(e); });
503
+ leitura.pipe(req);
504
+ });
505
+ }
506
+
507
+ /* ── Ouvir o vídeo para achar as pausas ──────────────────────────────────
508
+ *
509
+ * "Ele vai cortar as pausas demoradas no vídeo."
510
+ *
511
+ * Cortar pausa exige saber onde a fala para, e nada aqui lê o áudio de um
512
+ * .mp4: o PrimoCode não tem dependência, e o Python da máquina lê WAV e mais
513
+ * nada. ffmpeg resolveria e seria mais uma instalação de 80 MB para quem só
514
+ * quer editar um vídeo.
515
+ *
516
+ * Quem decodifica MP4, MOV e webm já está aberto na mesa. A conta roda numa
517
+ * aba que abre, trabalha e se fecha; aqui a gente espera a resposta.
518
+ *
519
+ * `abrir` é injetado (é a função de abrir navegador do tools.js) para esta
520
+ * camada não passar a conhecer o sistema operacional.
521
+ */
522
+ const analisarAudio = ({ arquivo, pausa, abrir }) => comStudio(async () => {
523
+ if (!arquivo) return { ok: false, error: 'Diga qual mídia analisar (arquivo: "/midia/x.mp4").' };
524
+ const tarefa = await pedir('POST', '/api/analise', { arquivo, pausa });
525
+ if (abrir) { try { abrir(`${BASE}${tarefa.pagina}`); } catch { /* segue e expira */ } }
526
+
527
+ // Um vídeo de meia hora leva alguns segundos para decodificar; três
528
+ // minutos é folga com sobra, e continua sendo um teto — sem ele um
529
+ // navegador que nunca abriu deixaria a tarefa pendurada para sempre.
530
+ const prazo = Date.now() + 180000;
531
+ while (Date.now() < prazo) {
532
+ await new Promise((ok) => setTimeout(ok, 700));
533
+ const estado = await pedir('GET', `/api/analise/${tarefa.id}`, null, 8000).catch(() => null);
534
+ if (!estado) continue;
535
+ if (estado.estado === 'erro') {
536
+ return { ok: false, error: (estado.resultado && estado.resultado.erro) || 'a análise falhou' };
537
+ }
538
+ if (estado.estado === 'pronto') {
539
+ const r = estado.resultado || {};
540
+ return {
541
+ ok: true,
542
+ duracao: r.duracao,
543
+ cortado: r.cortado,
544
+ pausas: r.pausas,
545
+ trechos: r.trechos,
546
+ comoUsar: `${(r.trechos || []).length} trecho(s) sem pausa. Escreva UMA CENA por trecho, `
547
+ + `cada uma com {"tipo":"clipe","src":"${arquivo}","de":<início>,"ate":<fim>}. `
548
+ + 'NÃO escreva "duracao" nessas cenas: a cena já dura o trecho.',
549
+ };
550
+ }
551
+ }
552
+ return {
553
+ ok: false,
554
+ error: 'A aba de análise não respondeu em 3 minutos. Ela precisa do navegador aberto. '
555
+ + 'Siga sem o corte automático: escreva os trechos que o usuário pedir, ou use o vídeo inteiro '
556
+ + 'num clipe só (sem "de"/"ate").',
557
+ };
444
558
  });
445
559
 
446
560
  module.exports = {
447
561
  BASE, PORTA, CASA,
448
562
  garantirNoAr, estaNoAr, prepararCasa, pythonCmd,
449
563
  manual, ferramentas, spec, listar, ler, criar, atualizar, versoesDe,
450
- midiaListar, midiaEnviar,
564
+ midiaListar, midiaEnviar, analisarAudio,
451
565
  };
package/lib/tools.js CHANGED
@@ -172,6 +172,8 @@ async function runShell(command, timeout = 60000, cwd) {
172
172
  // `https://x/"; touch /tmp/pwned; echo "` injetava comando (achado de segurança).
173
173
  // Quando o auto-abrir do Studio abriu pela última vez — o freio das mil abas.
174
174
  let ultimaAberturaStudio = 0;
175
+ // Uma rodada de melhoria por tarefa (ver o aviso em studio_criar).
176
+ let melhorouStudio = false;
175
177
 
176
178
  function openInBrowser(target) {
177
179
  const p = os.platform();
@@ -513,6 +515,13 @@ async function despachar(name, args, ctx) {
513
515
  // saber abrir". E cada "abre pra mim" digitado depois custava
514
516
  // uma tarefa inteira de tokens para o modelo redescobrir o
515
517
  // link. Abrir é determinístico: não se gasta modelo com isso.
518
+ // Fica anotado na memória geral: é assim que "lembra do
519
+ // vídeo tal" funciona na conversa seguinte. O id é o que
520
+ // permite reabrir ou ajustar sem recriar.
521
+ if (r && r.ok !== false && r.id) {
522
+ cfg.lembrarCriacao({ ferramenta: r.ferramenta || args.ferramenta,
523
+ titulo: r.titulo || args.titulo, id: r.id });
524
+ }
516
525
  if (r && r.ok !== false && r.ver) {
517
526
  // UMA aba por tarefa. O modelo às vezes cria três versões
518
527
  // seguidas, e abrir todas enchia o navegador do usuário de
@@ -523,7 +532,24 @@ async function despachar(name, args, ctx) {
523
532
  ultimaAberturaStudio = agora;
524
533
  try { openInBrowser(r.ver); } catch {}
525
534
  r.abertoNoNavegador = true;
526
- r.aviso = 'JÁ ABERTO no navegador do usuário. Chame finish AGORA dizendo o que foi criado — não abra de novo, não leia de volta, não ajuste sem pedido.';
535
+ // O estúdio revisa a peça e devolve o que ficou
536
+ // monótono (mesmo fundo em tudo, transição repetida,
537
+ // sem trilha). Sem este ramo o aviso mandaria "chame
538
+ // finish, não ajuste" — e a revisão morreria ali, que
539
+ // é justamente o "fica tudo genérico".
540
+ //
541
+ // UMA rodada de conserto, e acabou: mais que isso
542
+ // vira o agente polindo sozinho enquanto o usuário
543
+ // espera.
544
+ if (Array.isArray(r.podeMelhorar) && r.podeMelhorar.length && !melhorouStudio) {
545
+ melhorouStudio = true;
546
+ r.aviso = 'JÁ ABERTO no navegador. Antes de finalizar, faça UMA correção: '
547
+ + `use studio_atualizar no id "${r.id}" mandando SÓ as chaves que mudam, `
548
+ + 'para resolver os pontos de "podeMelhorar". Depois chame finish. '
549
+ + 'Não crie outro arquivo e não repita esta correção.';
550
+ } else {
551
+ r.aviso = 'JÁ ABERTO no navegador do usuário. Chame finish AGORA dizendo o que foi criado — não abra de novo, não leia de volta, não ajuste sem pedido.';
552
+ }
527
553
  } else {
528
554
  r.aviso = `Você JÁ criou uma versão nesta tarefa, e ela está aberta no navegador. NÃO crie outra: `
529
555
  + `para mudar algo, use studio_atualizar com o id "${r.id}". Chame finish AGORA.`;
@@ -544,6 +570,14 @@ async function despachar(name, args, ctx) {
544
570
  return args.arquivo
545
571
  ? studio.midiaEnviar({ arquivo: resolveInProject(args.arquivo, projectDir), nome: args.nome })
546
572
  : studio.midiaListar();
573
+ case 'studio_analisar':
574
+ // Ouvir o vídeo para achar as pausas. A conta acontece no
575
+ // navegador (é quem sabe decodificar MP4 sem instalar nada) —
576
+ // por isso passa a função de abrir, e por isso demora alguns
577
+ // segundos em vez de responder na hora.
578
+ return studio.analisarAudio({
579
+ arquivo: args.arquivo, pausa: args.pausa, abrir: openInBrowser,
580
+ });
547
581
 
548
582
  case 'desktop_double_click':
549
583
  return desktop.doubleClick(args.x, args.y);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.22.0",
3
+ "version": "8.25.0",
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": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "scripts": {
10
10
  "start": "node bin/primocode.js",
11
- "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"