primocode 9.10.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 (60) 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/estilo.js +51 -0
  12. package/lib/estudio/glifos.js +215 -0
  13. package/lib/estudio/index.js +41 -18
  14. package/lib/estudio/kit/base.jsx +285 -0
  15. package/lib/estudio/kit/index.jsx +11 -0
  16. package/lib/estudio/kit/marca.jsx +741 -0
  17. package/lib/estudio/kit/vox.jsx +357 -0
  18. package/lib/estudio/kit.js +72 -0
  19. package/lib/estudio/lancamento.js +413 -0
  20. package/lib/estudio/motion.js +2 -5
  21. package/lib/estudio/plano.js +28 -3
  22. package/lib/estudio/remotion.js +87 -43
  23. package/lib/estudio/ritmo.js +77 -0
  24. package/lib/estudio/vox.js +385 -0
  25. package/lib/fala.js +1 -94
  26. package/lib/pipeline.js +55 -56
  27. package/lib/repeticao.js +3 -1
  28. package/lib/skills.js +8 -2
  29. package/lib/snapshot.js +111 -0
  30. package/lib/tools.js +79 -20
  31. package/lib/ui.js +13 -6
  32. package/lib/video/analise.js +89 -0
  33. package/lib/video/cli.js +74 -0
  34. package/lib/video/delta.js +90 -0
  35. package/lib/video/edits.js +379 -0
  36. package/lib/video/filters.js +247 -0
  37. package/lib/video/ops.js +322 -0
  38. package/lib/video/projeto.js +133 -0
  39. package/lib/video/render.js +221 -0
  40. package/lib/video/texto.js +142 -0
  41. package/lib/video/timeline.js +310 -0
  42. package/lib/video/transcript.js +124 -0
  43. package/lib/voz.js +75 -223
  44. package/package.json +2 -3
  45. package/skills/motion-de-primeira/SKILL.md +3 -4
  46. package/studio/texto.py +208 -0
  47. package/voz/primo_voz/__main__.py +96 -236
  48. package/voz/primo_voz/transcrever.py +31 -1
  49. package/app/ansi.js +0 -55
  50. package/app/app.css +0 -500
  51. package/app/app.js +0 -1017
  52. package/app/icone.svg +0 -6
  53. package/app/index.html +0 -220
  54. package/app/primo-marca.js +0 -192
  55. package/lib/app.js +0 -615
  56. package/lib/icone.js +0 -263
  57. package/lib/instalar-app.js +0 -231
  58. package/lib/painel.js +0 -291
  59. package/voz/primo_voz/desperta.py +0 -195
  60. package/voz/primo_voz/orbe.py +0 -406
package/lib/act.js CHANGED
@@ -55,6 +55,44 @@ function interromper() {
55
55
  if (emVoo) { try { emVoo.abort(); } catch {} }
56
56
  }
57
57
 
58
+ /* ── A CAIXA DE ENTRADA: o que a pessoa digita enquanto ele trabalha ────
59
+ * Antes, a linha digitada durante uma tarefa ia para a fila do terminal e só
60
+ * rodava DEPOIS — como um pedido novo, que não sabia nada do que estava em
61
+ * curso. "Mesmo quando ela estiver trabalhando poderá enviar mensagem; ela
62
+ * vai à fila e, quando o PrimoCode termina um passo, recebe a mensagem e
63
+ * integra na tarefa." É o que o Claude Code e o Codex fazem: o recado entra
64
+ * na conversa na fronteira seguinte — depois do resultado das ferramentas,
65
+ * antes da próxima ida ao modelo. Nenhuma ferramenta é cortada no meio.
66
+ *
67
+ * Só o pedido PRINCIPAL lê a caixa (o subagente trabalha para ele). O que
68
+ * sobrar quando a tarefa acabar volta para o terminal como pedido normal. */
69
+ const CAIXA = [];
70
+ let TRABALHANDO = false;
71
+ function mandarDuranteATarefa(texto) {
72
+ const t = String(texto || '').trim();
73
+ if (!t || !TRABALHANDO) return false;
74
+ CAIXA.push(t);
75
+ return true;
76
+ }
77
+ function trabalhando() { return TRABALHANDO; }
78
+ /** O que ficou na caixa sem ser lido — o terminal roda como pedido novo. */
79
+ function sobrasDaCaixa() { return CAIXA.splice(0); }
80
+ /* Esvazia a caixa dentro da conversa. Devolve quantos recados entraram. */
81
+ function entregarRecados(messages, profundidade) {
82
+ if (!CAIXA.length) return 0;
83
+ const recados = CAIXA.splice(0);
84
+ for (const r of recados) fala.pensamento(`recado recebido — entrando na tarefa: ${r}`, profundidade);
85
+ messages.push({
86
+ role: 'user',
87
+ content: (recados.length === 1 ? 'MENSAGEM NOVA do usuário, enviada enquanto você trabalhava:\n'
88
+ : 'MENSAGENS NOVAS do usuário, enviadas enquanto você trabalhava:\n')
89
+ + recados.map((r) => `- ${r}`).join('\n')
90
+ + '\n\nIntegre isto na tarefa em curso: se é um ajuste, aplique; se muda o rumo, siga o novo rumo; '
91
+ + 'se é uma pergunta, responda em uma frase e continue. Não recomece o que já está feito.',
92
+ });
93
+ return recados.length;
94
+ }
95
+
58
96
  /**
59
97
  * Este erro é "não coube no minuto"?
60
98
  *
@@ -138,9 +176,6 @@ function limparParaOModelo(result) {
138
176
 
139
177
  function logResult(kind, result, profundidade) {
140
178
  const p = fala.resultado(profundidade);
141
- // A linha da janela (lib/app.js) fecha aqui: o mesmo resultado que o
142
- // terminal desenha embaixo, resumido. Ver o canal em lib/fala.js.
143
- fala.avisar('resultado', fala.resumoDoResultado(kind, result));
144
179
  if (!result) return;
145
180
  // Uma tool que falhou é informação para o AGENTE, que vai consertar na
146
181
  // volta seguinte — não é o erro do usuário. Âmbar, uma linha, sem o
@@ -221,9 +256,12 @@ function logResult(kind, result, profundidade) {
221
256
  case 'browser_open':
222
257
  console.log(p + c.muted(result.url || 'aberto'));
223
258
  return;
224
- case 'browser_read':
225
- console.log(p + c.muted(`${(result.text || '').length} caracteres lidos da página`));
259
+ case 'browser_read': {
260
+ const refs = (String(result.pagina || '').match(/^@e\d+/gm) || []).length;
261
+ console.log(p + c.muted(refs ? `${refs} elementos com ref · ${(result.text || '').length} caracteres de texto`
262
+ : `${(result.text || '').length} caracteres lidos da página`));
226
263
  return;
264
+ }
227
265
  case 'pesquisar_referencia': {
228
266
  console.log(p + c.muted(`${result.fontes} fonte${result.fontes === 1 ? '' : 's'} lida${result.fontes === 1 ? '' : 's'} sobre "${result.consulta}"`));
229
267
  for (const r of (result.referencias || []).slice(0, 4)) {
@@ -298,6 +336,50 @@ const RESTO_DO_RESULTADO = 300;
298
336
  const RESTO_DA_PESQUISA = 1500;
299
337
  const MATERIA_PRIMA = new Set(['pesquisar_referencia']);
300
338
 
339
+ /* ── O ARQUIVO JÁ GRAVADO NÃO VIAJA DE NOVO ─────────────────────────────
340
+ * O `compactar` encolhia o resultado das ferramentas e deixava intacto o que
341
+ * o MODELO escreveu — e o que ele escreve num site é o arquivo inteiro, dentro
342
+ * da chamada write_file. Medido em 29/09 ("crie uma landing de barbearia"):
343
+ * depois do index.html (7,6 KB) e do style.css (15 KB), cada ida ao modelo
344
+ * levava os dois de novo, passava do teto de tokens por minuto do plano
345
+ * grátis e ficava esperando a janela virar, em silêncio — "escreve dois
346
+ * arquivos e trava". O conteúdo já está no disco: nas voltas antigas ele vira
347
+ * um ponteiro. As duas últimas ficam inteiras (é nelas que o modelo decide o
348
+ * próximo passo, e é delas que sai a releitura sem disco — ver read_file). */
349
+ const ESCRITAS_INTEIRAS = 2;
350
+ const RESTO_DA_ESCRITA = 400;
351
+ function assistentesAntigas(messages) {
352
+ const idx = [];
353
+ for (let i = 0; i < messages.length; i++) {
354
+ const m = messages[i];
355
+ if (m && m.role === 'assistant' && Array.isArray(m.tool_calls) && m.tool_calls.length) idx.push(i);
356
+ }
357
+ return new Set(idx.slice(0, -ESCRITAS_INTEIRAS));
358
+ }
359
+ function encolherEscritas(m) {
360
+ let mudou = false;
361
+ const tool_calls = m.tool_calls.map((tc) => {
362
+ const f = tc && tc.function;
363
+ if (!f || !/^(write_file|edit_file|multi_edit)$/.test(f.name)) return tc;
364
+ const bruto = String(f.arguments || '');
365
+ if (bruto.length <= RESTO_DA_ESCRITA) return tc;
366
+ let a;
367
+ try { a = JSON.parse(bruto); } catch { return tc; }
368
+ const ponteiro = (n) => `(${n} caracteres — já gravado no disco; para mudar, leia com read_file e use edit_file)`;
369
+ if (f.name === 'write_file' && typeof a.content === 'string' && a.content.length > RESTO_DA_ESCRITA) a.content = ponteiro(a.content.length);
370
+ else if (f.name === 'edit_file' && typeof a.new_string === 'string' && a.new_string.length > RESTO_DA_ESCRITA) {
371
+ a.new_string = ponteiro(a.new_string.length);
372
+ if (typeof a.old_string === 'string' && a.old_string.length > 200) a.old_string = a.old_string.slice(0, 200) + '…';
373
+ } else if (f.name === 'multi_edit' && Array.isArray(a.edits)) {
374
+ a.edits = a.edits.map((e) => (e && typeof e.new_string === 'string' && e.new_string.length > RESTO_DA_ESCRITA
375
+ ? { ...e, new_string: ponteiro(e.new_string.length), old_string: String(e.old_string || '').slice(0, 200) } : e));
376
+ } else return tc;
377
+ mudou = true;
378
+ return { ...tc, function: { ...f, arguments: JSON.stringify(a) } };
379
+ });
380
+ return mudou ? { ...m, tool_calls } : m;
381
+ }
382
+
301
383
  /**
302
384
  * Encolhe os resultados de tool ANTIGOS antes de mandar a conversa.
303
385
  *
@@ -322,11 +404,13 @@ function compactar(messages) {
322
404
 
323
405
  const indices = [];
324
406
  for (let i = 0; i < messages.length; i++) if (messages[i].role === 'tool') indices.push(i);
325
- if (indices.length <= RESULTADOS_INTEIROS && !duplicadas.size) return messages;
407
+ if (indices.length <= RESULTADOS_INTEIROS && !duplicadas.size && !assistentesAntigas(messages).size) return messages;
326
408
 
327
409
  const chamadas = repeticao.chamadasPorId(messages);
328
410
  const encolher = new Set(indices.slice(0, -RESULTADOS_INTEIROS));
411
+ const antigas = assistentesAntigas(messages);
329
412
  return messages.map((m, i) => {
413
+ if (antigas.has(i)) return encolherEscritas(m);
330
414
  if (duplicadas.has(i)) {
331
415
  return { ...m, content: '(a mesma referência aparece inteira mais adiante nesta conversa — use aquela)' };
332
416
  }
@@ -383,10 +467,21 @@ function esperaDoErro(e, tentativa = 0) {
383
467
  return Math.min(20, 3 * 2 ** Math.max(0, tentativa - 1)) * 1000;
384
468
  }
385
469
 
386
- /** Espera com o spinner de PENSAR girando — nunca um de "limite" ou
387
- * "continuando em Ns". Quem olha vê o Primo pensando um pouco mais, e só. */
470
+ /** Espera a vez no modelo — À VISTA, com os segundos, também no subagente.
471
+ * Antes girava o spinner de "pensando" (e no subagente, nada): eram minutos
472
+ * de tela parada que pareciam trava. "As coisas estão acontecendo, só não
473
+ * estão sendo mostradas." Dizer que é a vez do modelo, sem alarme, é a
474
+ * diferença entre esperar e desistir. */
475
+ /* PRIMOCODE_TRACO=1: uma linha por ida ao modelo no stderr — o tamanho da
476
+ conversa, o erro e a espera. É o que diz ONDE uma tarefa travou quando a
477
+ tela (ou um log sem terminal) só mostra o último arquivo escrito. */
478
+ function traco(texto) {
479
+ if (process.env.PRIMOCODE_TRACO === '1') process.stderr.write(` [traço ${new Date().toTimeString().slice(0, 8)}] ${texto}\n`);
480
+ }
481
+
388
482
  async function pausar(ms, subagent) {
389
- const sp = subagent ? null : new Spinner(PENSANDO).start();
483
+ traco(`esperando ${Math.round(ms / 1000)}s pela vez no modelo${subagent ? ' (agente)' : ''}`);
484
+ const sp = new Spinner(subagent ? 'o agente espera a vez no modelo' : 'esperando a vez no modelo').start();
390
485
  await new Promise((r) => setTimeout(r, ms));
391
486
  if (sp) sp.stop();
392
487
  tempos.somar('espera de cota/rede', ms);
@@ -487,6 +582,58 @@ function mapaDoProjeto(dir, limite = 80, achados = arquivosDoProjeto(dir, limite
487
582
  return `Arquivos que JÁ existem neste projeto (edite-os em vez de recriar):\n${achados.join('\n')}${mais}`;
488
583
  }
489
584
 
585
+ /* O comando de shell que só LÊ um arquivo — `cat x`, `sed -n '10,40p' x`,
586
+ `head -50 x`, `tail x`, `nl x`, com ou sem `cd pasta &&` na frente. Medido
587
+ num pedido de site (29/09): depois de escrever o script.js, o agente o
588
+ releu em quatro fatias de `sed -n`, uma volta de modelo cada. Devolve o
589
+ caminho lido (absoluto quando houve cd), ou null. */
590
+ function leituraPeloShell(comando) {
591
+ const m = /^\s*(?:cd\s+("[^"]+"|'[^']+'|\S+)\s*&&\s*)?(?:cat|nl|sed\s+-n\s+(?:'[^']*'|"[^"]*"|\S+)|head(?:\s+-n?\s*\d+)?|tail(?:\s+-n?\s*\d+)?)\s+("[^"]+"|'[^']+'|[^\s|;&><]+)\s*$/.exec(String(comando || ''));
592
+ if (!m) return null;
593
+ const tira = (x) => String(x || '').replace(/^["']|["']$/g, '');
594
+ const arquivo = tira(m[2]);
595
+ return m[1] ? require('path').resolve(tira(m[1]), arquivo) : arquivo;
596
+ }
597
+
598
+ /* ── O HISTÓRICO QUE VIAJA ─────────────────────────────────────────────
599
+ * Vinte mensagens INTEIRAS iam em toda volta do agente principal. Medido em
600
+ * 29/09: num pedido de site, 28 mil dos 38 mil caracteres da primeira ida
601
+ * eram respostas de pedidos anteriores — sozinhas, acima do teto por minuto
602
+ * do plano grátis. O que a conversa precisa para continuar é o fio (o que
603
+ * foi pedido, o que ficou pronto), não o texto inteiro: cada mensagem entra
604
+ * até um teto, e o total também, das mais novas para as mais velhas. */
605
+ const HISTORICO_POR_MENSAGEM = 1200;
606
+ const HISTORICO_TOTAL = 6000;
607
+ function historicoEnxuto(history) {
608
+ const fora = [];
609
+ let total = 0;
610
+ for (const m of (history || []).slice(-20).reverse()) {
611
+ if (!m || !m.role || !m.content) continue;
612
+ let t = String(m.content);
613
+ if (t.length > HISTORICO_POR_MENSAGEM) t = t.slice(0, HISTORICO_POR_MENSAGEM) + '…(encurtado)';
614
+ if (total + t.length > HISTORICO_TOTAL && fora.length) break;
615
+ total += t.length;
616
+ fora.unshift({ role: m.role, content: t });
617
+ }
618
+ // A API quer a conversa começando pelo usuário.
619
+ while (fora.length && fora[0].role !== 'user') fora.shift();
620
+ return fora;
621
+ }
622
+
623
+ /* ── O RITMO ─────────────────────────────────────────────────────────
624
+ * "A potência não deve vir do modelo, deve vir do sistema." O prompt do
625
+ * servidor manda "leia antes de editar" e "confira antes de dizer que
626
+ * terminou" — certo para editar código dos outros, e a receita do laço
627
+ * quando o arquivo foi ele mesmo que escreveu um minuto antes. Estas regras
628
+ * vêm do que o opencode e o Codex põem nos prompts deles para o modelo não
629
+ * reler o que fez; entram em TODO pedido, curtas, porque o plano conta token.
630
+ * A conferência de verdade é nossa (a página aberta no Chrome escondido). */
631
+ const RITMO = '[ritmo]\n'
632
+ + '- O resultado de write_file/edit_file é a verdade. Não releia um arquivo que você acabou de escrever para conferir (nem com cat/sed/head): a conferência é do PrimoCode.\n'
633
+ + '- Várias ações independentes vão na MESMA resposta (ex.: escrever index.html, style.css e script.js juntos).\n'
634
+ + '- Leia um arquivo inteiro uma vez, não em pedaços pequenos; não leia de novo o que não mudou.\n'
635
+ + '- Não narre o próximo passo: faça. Termine com finish("o que foi feito").';
636
+
490
637
  /* `pedido` é opcional: com ele, o arquivo citado no pedido é o primeiro a
491
638
  entrar já lido (ver lib/pre-leitura.js). */
492
639
  function buildMemoryContext(projectDir, projectName, pedido = '') {
@@ -505,6 +652,7 @@ function buildMemoryContext(projectDir, projectName, pedido = '') {
505
652
  if (mapa) parts.push(`[${projectName || 'projeto'}/arquivos]\n${mapa}\n\n${preLeitura.JUNTAR_LEITURAS}`);
506
653
  const lidos = preLeitura.bloco(preLeitura.escolher(projectDir, arquivos, pedido));
507
654
  if (lidos) parts.push(lidos);
655
+ parts.push(RITMO);
508
656
  return parts.join('\n\n');
509
657
  }
510
658
 
@@ -584,10 +732,12 @@ async function runAct(userPrompt, opts) {
584
732
  mexeuNaPagina = false;
585
733
  let medida = null;
586
734
  const t = Date.now();
735
+ // Com spinner: eram segundos de tela parada entre o último arquivo e o
736
+ // "pronto", e parecia que tinha travado ali.
737
+ const sp = new Spinner('abrindo a página escondido para conferir').start();
587
738
  try {
588
- fala.pensamento('abrindo a página para conferir antes de entregar', profundidade);
589
739
  medida = await medirPagina(pastaAtual);
590
- } catch { medida = null; }
740
+ } catch { medida = null; } finally { sp.stop(); }
591
741
  msConferindo += Date.now() - t;
592
742
  tempos.somar('conferir página', Date.now() - t);
593
743
  if (!medida || !medida.testou) return null;
@@ -602,6 +752,9 @@ async function runAct(userPrompt, opts) {
602
752
  const jaChamado = repeticao.novo();
603
753
  // O que read_file/list_dir devolveram, pela chave da época (repeticao.js).
604
754
  const leiturasGuardadas = new Map();
755
+ // O que ELE escreveu inteiro nesta tarefa, por caminho absoluto — para
756
+ // não gastar uma volta relendo o próprio texto (ver read_file abaixo).
757
+ const escritosNaTarefa = new Map();
605
758
  /* O livro deste pedido: arquivos, comandos, links, testes e falhas. Sai
606
759
  no fim como balanço (ver lib/registro.js). O subagente tem o seu, que
607
760
  não se mistura com o do principal — mas o que ele fez também precisa
@@ -653,9 +806,7 @@ async function runAct(userPrompt, opts) {
653
806
  if (!subagent) {
654
807
  const memory = buildMemoryContext(projectDir, projectName, userPrompt);
655
808
  if (memory) messages.push({ role: 'system', content: `Memória persistente:\n\n${memory}` });
656
- for (const m of (history || []).slice(-20)) {
657
- if (m && m.role && m.content) messages.push({ role: m.role, content: m.content });
658
- }
809
+ for (const m of historicoEnxuto(history)) messages.push(m);
659
810
  }
660
811
  /* O que VAI para o modelo é `pedido` (com as skills na frente); o que
661
812
  fica guardado na memória e no histórico é `userPrompt`, o que a pessoa
@@ -676,8 +827,14 @@ async function runAct(userPrompt, opts) {
676
827
  * Num subagente não entra: ele já está fazendo uma parte de um plano, e
677
828
  * um diretor dentro do diretor é uma volta a mais para o mesmo lugar. */
678
829
  if (!subagent && profundidade === 0) {
679
- const producao = require('./estudio/diretor.js').precisaDeProducao(pedido);
680
- if (producao.producao) {
830
+ const diretor = require('./estudio/diretor.js');
831
+ const producao = diretor.precisaDeProducao(pedido);
832
+ if (diretor.precisaDeEdicao(pedido)) {
833
+ // A gravação da pessoa: o editor por linha do tempo (lib/video/), pelo run_shell.
834
+ messages.push({ role: 'system', content: 'PARA EDITAR A GRAVAÇÃO DA PESSOA use o editor por linha do tempo via run_shell: '
835
+ + 'rode `primocode video help` UMA vez (lista as operações), depois import → remove_silence/remove_words → add_captions → inspect (olhe o png) → export. '
836
+ + 'Cada edição devolve um delta curto e tem undo: edite sem pedir licença a cada passo.' });
837
+ } else if (producao.producao) {
681
838
  /* "SE ESTIVER NA SUA LISTA" não é hesitação — é a diferença entre
682
839
  a tarefa seguir e a tarefa morrer.
683
840
 
@@ -749,8 +906,13 @@ async function runAct(userPrompt, opts) {
749
906
  const MAX_ESPERAS_REDE = Number(process.env.PRIMOCODE_MAX_ESPERAS_REDE || 6);
750
907
 
751
908
  let voltas = 0;
909
+ if (!subagent) TRABALHANDO = true;
910
+ try {
752
911
  for (let iteration = 0; iteration < MAX_ITERATIONS && !PARAR; iteration++) {
753
912
  voltas++;
913
+ // Recado digitado durante o passo anterior entra AGORA, antes da ida
914
+ // ao modelo — o resultado das ferramentas já está na conversa.
915
+ if (!subagent) entregarRecados(messages, profundidade);
754
916
  const sp = new Spinner(subagent ? 'subagente trabalhando' : PENSANDO).start();
755
917
 
756
918
  const toolCalls = [];
@@ -769,11 +931,19 @@ async function runAct(userPrompt, opts) {
769
931
  emVoo = new AbortController();
770
932
  const tRodada = Date.now();
771
933
  try {
772
- await motor.requestAct(server, { messages: compactar(messages), model, effort, token, projectDir: pastaAtual, projectName, subagent, sinal: emVoo.signal }, (evt) => {
934
+ const enviadas = compactar(messages);
935
+ traco(`volta ${voltas}${subagent ? ' (agente)' : ''} · ${enviadas.length} mensagens · ${JSON.stringify(enviadas).length} caracteres`);
936
+ await motor.requestAct(server, { messages: enviadas, model, effort, token, projectDir: pastaAtual, projectName, subagent, sinal: emVoo.signal }, (evt) => {
773
937
  if (evt.erro) { streamError = evt.erro; return; }
774
- if (evt.tool_call) { toolCalls.push(evt.tool_call); return; }
938
+ if (evt.tool_call) {
939
+ toolCalls.push(evt.tool_call);
940
+ const a = evt.tool_call.arguments || {};
941
+ sp.detalhe = `${toolCalls.length} ${toolCalls.length > 1 ? 'ações prontas' : 'ação pronta'} · ${evt.tool_call.name}${a.path ? ' ' + a.path : ''}`;
942
+ return;
943
+ }
775
944
  if (evt.delta) {
776
945
  assistantText += evt.delta;
946
+ sp.detalhe = assistantText.length > 200 ? `${Math.round(assistantText.length / 100) / 10} mil caracteres` : '';
777
947
  if (!subagent && !toolCalls.length) {
778
948
  if (!fluxo && assistantText.length > fala.LIMIAR_PENSAMENTO) {
779
949
  sp.stop();
@@ -787,6 +957,7 @@ async function runAct(userPrompt, opts) {
787
957
  } catch (e) {
788
958
  sp.stop();
789
959
  emVoo = null;
960
+ traco(`erro na volta ${voltas}: ${String((e && e.message) || e).slice(0, 200)}`);
790
961
  if (fluxo) fluxo.fechar();
791
962
  // Estourou o minuto: encolhe o que já foi feito e CONTINUA, em vez
792
963
  // de devolver erro e jogar fora o trabalho. O usuário não precisa
@@ -814,6 +985,7 @@ async function runAct(userPrompt, opts) {
814
985
  }
815
986
  sp.stop();
816
987
  emVoo = null;
988
+ traco(`volta ${voltas} voltou em ${Math.round((Date.now() - tRodada) / 1000)}s · ${toolCalls.map((t) => t.name).join(', ') || 'texto'}${streamError ? ' · erro: ' + String(streamError).slice(0, 160) : ''}`);
817
989
  tempos.somar(subagent ? 'rodada do subagente' : 'rodada do modelo', Date.now() - tRodada);
818
990
  if (PARAR) { if (fluxo) fluxo.fechar(); break; }
819
991
 
@@ -956,7 +1128,7 @@ async function runAct(userPrompt, opts) {
956
1128
  messages.push({
957
1129
  role: 'user',
958
1130
  content: `AINDA NÃO. Abri a página para conferir e ela tem problema:\n${relato}\n\n`
959
- + 'Conserte (leia o arquivo antes de editar) e finalize de novo.',
1131
+ + 'Conserte SÓ o que está apontado, com edit_file no arquivo do erro (leia só ele, se precisar), e finalize de novo.',
960
1132
  });
961
1133
  finalText = '';
962
1134
  continue;
@@ -967,6 +1139,13 @@ async function runAct(userPrompt, opts) {
967
1139
  if (fluxo) fluxo.fechar();
968
1140
  else fala.resposta(finalText);
969
1141
  }
1142
+ /* Ia terminar, mas chegou recado no meio: a tarefa não acaba com
1143
+ a pessoa esperando resposta. A volta seguinte entrega o recado. */
1144
+ if (!subagent && CAIXA.length && !PARAR) {
1145
+ messages.push({ role: 'assistant', content: finalText });
1146
+ finalText = '';
1147
+ continue;
1148
+ }
970
1149
  break;
971
1150
  }
972
1151
  respostasVazias = 0;
@@ -1037,7 +1216,7 @@ async function runAct(userPrompt, opts) {
1037
1216
  role: 'tool',
1038
1217
  tool_call_id: tc.id,
1039
1218
  content: `NÃO terminou. Estas tools falharam e você não corrigiu: ${falhasPendentes.map((f) => f.texto).join('; ')}. `
1040
- + 'Conserte agora (leia o arquivo antes de editar) ou, se for impossível, diga claramente ao usuário o que falhou.',
1219
+ + 'Conserte agora com edit_file no arquivo que falhou ou, se for impossível, diga claramente ao usuário o que falhou.',
1041
1220
  });
1042
1221
  continue;
1043
1222
  }
@@ -1117,6 +1296,38 @@ async function runAct(userPrompt, opts) {
1117
1296
 
1118
1297
  if (criarPasta && catalogo.criaProjeto(tc.name, tc.arguments)) garantirPasta();
1119
1298
 
1299
+ /* RELER O QUE ACABOU DE ESCREVER. O modelo grátis escreve o
1300
+ index.html e, na volta seguinte, pede para lê-lo "para
1301
+ conferir" — uma ida ao servidor para receber o texto que ele
1302
+ mesmo mandou, e que continua inteiro na chamada write_file dele
1303
+ (o `compactar` só encolhe resultado de ferramenta, nunca o que
1304
+ o modelo escreveu). Se o arquivo está byte a byte como ele
1305
+ deixou, a resposta é essa, sem abrir nada. Depois de um
1306
+ edit_file não vale: aí o texto na conversa está em pedaços. */
1307
+ const relido = tc.name === 'read_file' && tc.arguments && tc.arguments.path
1308
+ ? tc.arguments.path
1309
+ : tc.name === 'run_shell' ? leituraPeloShell(tc.arguments && tc.arguments.command) : null;
1310
+ if (relido && escritosNaTarefa.size) {
1311
+ let igual = false;
1312
+ try {
1313
+ const abs = require('path').isAbsolute(relido) ? relido : tools.resolveInProject(relido, pastaAtual);
1314
+ const e = escritosNaTarefa.get(abs);
1315
+ // Só enquanto a escrita ainda viaja inteira na conversa
1316
+ // (as duas últimas voltas — ver ESCRITAS_INTEIRAS).
1317
+ igual = !!e && voltas - e.volta <= 1 && require('fs').readFileSync(abs, 'utf8') === e.texto;
1318
+ } catch { igual = false; }
1319
+ if (igual) {
1320
+ fala.acao(tc.name, tc.arguments, profundidade);
1321
+ console.log(fala.resultado(profundidade) + c.muted('igual ao que ele escreveu — não precisou ler'));
1322
+ messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify({
1323
+ ok: true, igualAoQueVoceEscreveu: true,
1324
+ aviso: 'Este arquivo está EXATAMENTE como você o escreveu nesta tarefa — o conteúdo é o da sua '
1325
+ + 'chamada write_file acima. Não precisa ler. Para mudar, use edit_file direto; se terminou, siga.',
1326
+ }) });
1327
+ continue;
1328
+ }
1329
+ }
1330
+
1120
1331
  if (tc.name === 'pesquisar_referencia' && pesquisasSecas >= 3) {
1121
1332
  voltasEmFalso++;
1122
1333
  messages.push({
@@ -1138,7 +1349,6 @@ async function runAct(userPrompt, opts) {
1138
1349
  const porta = await permissao.pedir(portaoControle, tc.name, tc.arguments, autorizar);
1139
1350
  if (!porta.ok) {
1140
1351
  fala.acao(tc.name, tc.arguments, profundidade);
1141
- fala.avisar('resultado', { nome: tc.name, ok: false, resumo: 'não autorizado' });
1142
1352
  console.log(fala.resultado(profundidade) + c.warn('não autorizado'));
1143
1353
  registro.anotar(livro, tc.name, tc.arguments, { ok: false, error: porta.error });
1144
1354
  messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify({ ok: false, error: porta.error }) });
@@ -1156,7 +1366,6 @@ async function runAct(userPrompt, opts) {
1156
1366
  });
1157
1367
  if (!risco.ok) {
1158
1368
  fala.acao(tc.name, tc.arguments, profundidade);
1159
- fala.avisar('resultado', { nome: tc.name, ok: false, resumo: `não autorizado — ${risco.motivo}` });
1160
1369
  console.log(fala.resultado(profundidade) + c.warn(`não autorizado — ${risco.motivo}`));
1161
1370
  registro.anotar(livro, tc.name, tc.arguments, { ok: false, error: risco.error });
1162
1371
  messages.push({ role: 'tool', tool_call_id: tc.id, content: JSON.stringify({ ok: false, error: risco.error }) });
@@ -1181,6 +1390,14 @@ async function runAct(userPrompt, opts) {
1181
1390
  leiturasGuardadas.set(eco.chave, summarizeForModel(result));
1182
1391
  }
1183
1392
  if (tc.name === 'write_todos' && result && Array.isArray(result.todos)) todosDaTarefa = result.todos;
1393
+ if (/^(write_file|edit_file|multi_edit)$/.test(tc.name) && tc.arguments && tc.arguments.path) {
1394
+ try {
1395
+ const abs = tools.resolveInProject(tc.arguments.path, pastaAtual);
1396
+ if (tc.name === 'write_file' && result && result.ok !== false && typeof tc.arguments.content === 'string') {
1397
+ escritosNaTarefa.set(abs, { texto: tc.arguments.content, volta: voltas });
1398
+ } else escritosNaTarefa.delete(abs);
1399
+ } catch { /* caminho inválido: a tool já recusou */ }
1400
+ }
1184
1401
  // Além de narrar, ANOTA: no fim do pedido sai o balanço do que
1185
1402
  // nasceu, mudou, rodou e falhou. Narrar sozinho não basta — numa
1186
1403
  // tarefa de trinta ações, a primeira já saiu da tela.
@@ -1270,9 +1487,14 @@ async function runAct(userPrompt, opts) {
1270
1487
  if (finished) {
1271
1488
  finalText = finished;
1272
1489
  if (!subagent) fala.resposta(finished);
1490
+ // Recado chegou enquanto ele fechava: entrega e segue.
1491
+ if (!subagent && CAIXA.length && !PARAR) { finalText = ''; continue; }
1273
1492
  break;
1274
1493
  }
1275
1494
  }
1495
+ } finally {
1496
+ if (!subagent) TRABALHANDO = false;
1497
+ }
1276
1498
 
1277
1499
  // O que o modelo entregou por vontade própria, ANTES dos textos de
1278
1500
  // desistência. É esta a diferença que o plano de passos precisa: um passo
@@ -1480,5 +1702,5 @@ async function rodarPipeline(args, ctx) {
1480
1702
  });
1481
1703
  }
1482
1704
 
1483
- module.exports = { runAct, buildMemoryContext, interromper, compactar, motivoDaParada, pareceParadaNoMeio, ehTetoDeToken, ehTransitorio, esperaDoErro,
1705
+ module.exports = { runAct, buildMemoryContext, leituraPeloShell, historicoEnxuto, interromper, mandarDuranteATarefa, trabalhando, sobrasDaCaixa, entregarRecados, compactar, motivoDaParada, pareceParadaNoMeio, ehTetoDeToken, ehTransitorio, esperaDoErro,
1484
1706
  anotacaoDoQueJaFoi, encolherParaContinuar, MAX_ITERATIONS };
package/lib/browser.js CHANGED
@@ -14,6 +14,7 @@
14
14
 
15
15
  'use strict';
16
16
 
17
+ const snapshot = require('./snapshot.js');
17
18
  const fs = require('fs');
18
19
  const os = require('os');
19
20
  const net = require('net');
@@ -658,7 +659,22 @@ async function candidatosNaPagina() {
658
659
  try { return JSON.parse(await evaluate(js)) || []; } catch { return []; }
659
660
  }
660
661
 
662
+ /* O retrato da página com as refs (lib/snapshot.js), já em texto. */
663
+ async function retrato(max = 80) {
664
+ try {
665
+ const r = await evaluate(snapshot.scriptDoRetrato(max));
666
+ return snapshot.formatar(r, max);
667
+ } catch { return ''; }
668
+ }
669
+
661
670
  async function erroDeSeletor(selector) {
671
+ // Ref que não existe mais: a página mudou depois da leitura. Listar os
672
+ // seletores CSS aqui só confundiria — o caminho é ler de novo.
673
+ if (/data-primo-ref/.test(selector)) {
674
+ const err = new Error('essa ref não existe mais nesta página — ela mudou depois da leitura. '
675
+ + 'Estas são as refs de agora:\n' + await retrato(40));
676
+ return err;
677
+ }
662
678
  const candidatos = await candidatosNaPagina();
663
679
  const err = new Error(
664
680
  `seletor não encontrado: ${selector}`
@@ -1191,6 +1207,7 @@ function fecharRevisor() {
1191
1207
  }
1192
1208
 
1193
1209
  module.exports = {
1210
+ retrato,
1194
1211
  SEM_KEYCHAIN,
1195
1212
  scriptDaSeta, verSeta, findChrome,
1196
1213
  open, click, clickText, clickSelector, type, typeInSelector,
package/lib/casar.js ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * casar.js — acha no arquivo o trecho que o modelo MANDOU trocar, mesmo
3
+ * quando ele não copiou letra por letra.
4
+ *
5
+ * O modelo grátis erra o old_string de jeitos previsíveis: um espaço a mais
6
+ * no fim da linha, a indentação com 2 em vez de 4, o número de linha que o
7
+ * read_file com offset põe na frente ("12\t…"), um "\n" escapado. Cada erro
8
+ * desses virava "trecho não encontrado" → read_file de novo → edit de novo:
9
+ * duas voltas de modelo para um espaço. É o "fica o dia inteiro lendo o que
10
+ * fez". A ideia vem do opencode (MIT), que encadeia vários "casadores" do
11
+ * mais estrito ao mais frouxo; o código é nosso.
12
+ *
13
+ * A regra de ouro: frouxo, mas NUNCA ambíguo. Cada estratégia só vale se
14
+ * achar exatamente UM lugar. Dois lugares parecidos é pior que nenhum —
15
+ * trocar o errado estraga o arquivo calado.
16
+ *
17
+ * achar(conteudo, alvo) → { inicio, fim, como } | { erro: 'nenhum' | 'varios', vezes }
18
+ */
19
+
20
+ 'use strict';
21
+
22
+ // Onde está cada linha no texto: [inicio, fim) de cada uma, sem o \n.
23
+ function linhasComPosicao(texto) {
24
+ const out = [];
25
+ let i = 0;
26
+ for (const l of texto.split('\n')) {
27
+ out.push({ texto: l, inicio: i, fim: i + l.length });
28
+ i += l.length + 1;
29
+ }
30
+ return out;
31
+ }
32
+
33
+ /* Procura um bloco de linhas do alvo no conteúdo comparando cada linha por
34
+ `igual`. Devolve todos os lugares (em caracteres do conteúdo original). */
35
+ function porLinhas(conteudo, alvo, normalizar) {
36
+ const doArquivo = linhasComPosicao(conteudo);
37
+ let doAlvo = alvo.split('\n');
38
+ // Linha vazia no começo ou no fim do alvo é quase sempre sobra do modelo.
39
+ while (doAlvo.length > 1 && !doAlvo[0].trim()) doAlvo = doAlvo.slice(1);
40
+ while (doAlvo.length > 1 && !doAlvo[doAlvo.length - 1].trim()) doAlvo = doAlvo.slice(0, -1);
41
+ const alvoN = doAlvo.map(normalizar);
42
+ if (!alvoN.some(Boolean)) return [];
43
+ const achados = [];
44
+ for (let i = 0; i + alvoN.length <= doArquivo.length; i++) {
45
+ let bate = true;
46
+ for (let j = 0; j < alvoN.length; j++) {
47
+ if (normalizar(doArquivo[i + j].texto) !== alvoN[j]) { bate = false; break; }
48
+ }
49
+ if (bate) achados.push({ inicio: doArquivo[i].inicio, fim: doArquivo[i + alvoN.length - 1].fim });
50
+ }
51
+ return achados;
52
+ }
53
+
54
+ // Tira a indentação que todas as linhas não vazias têm em comum.
55
+ function semIndentacaoComum(linhas) {
56
+ const recuos = linhas.filter((l) => l.trim()).map((l) => l.match(/^[ \t]*/)[0].length);
57
+ const menor = recuos.length ? Math.min(...recuos) : 0;
58
+ return linhas.map((l) => l.slice(Math.min(menor, l.match(/^[ \t]*/)[0].length)));
59
+ }
60
+
61
+ function porIndentacao(conteudo, alvo) {
62
+ const doArquivo = linhasComPosicao(conteudo);
63
+ const doAlvo = semIndentacaoComum(alvo.replace(/^\n+|\n+$/g, '').split('\n')).map((l) => l.trimEnd());
64
+ const n = doAlvo.length;
65
+ if (!doAlvo.some((l) => l.trim())) return [];
66
+ const achados = [];
67
+ for (let i = 0; i + n <= doArquivo.length; i++) {
68
+ const janela = semIndentacaoComum(doArquivo.slice(i, i + n).map((l) => l.texto)).map((l) => l.trimEnd());
69
+ if (janela.every((l, j) => l === doAlvo[j])) {
70
+ achados.push({ inicio: doArquivo[i].inicio, fim: doArquivo[i + n - 1].fim });
71
+ }
72
+ }
73
+ return achados;
74
+ }
75
+
76
+ function ocorrencias(conteudo, alvo) {
77
+ const out = [];
78
+ if (!alvo) return out;
79
+ let i = conteudo.indexOf(alvo);
80
+ while (i !== -1) { out.push({ inicio: i, fim: i + alvo.length }); i = conteudo.indexOf(alvo, i + alvo.length); }
81
+ return out;
82
+ }
83
+
84
+ /* Do mais estrito ao mais frouxo. O primeiro que achar UM lugar vence; o que
85
+ achar vários para a busca ali mesmo (ficar mais frouxo só pioraria). */
86
+ const ESTRATEGIAS = [
87
+ ['exato', (c, a) => ocorrencias(c, a)],
88
+ // "12\t <div>" — o prefixo que o read_file com offset põe em cada linha.
89
+ ['sem número de linha', (c, a) => (/^\s*\d+\t/m.test(a) ? ocorrencias(c, a.replace(/^\s*\d+\t/gm, '')) : [])],
90
+ // "\\n" e "\\t" que vieram escapados de um JSON mal montado.
91
+ ['sem escape', (c, a) => (/\\[nt"]/.test(a) && !/\\[nt"]/.test(c)
92
+ ? ocorrencias(c, a.replace(/\\n/g, '\n').replace(/\\t/g, '\t').replace(/\\"/g, '"')) : [])],
93
+ ['espaço no fim da linha', (c, a) => porLinhas(c, a, (l) => l.trimEnd())],
94
+ ['linha aparada', (c, a) => porLinhas(c, a, (l) => l.trim())],
95
+ ['indentação', (c, a) => porIndentacao(c, a)],
96
+ ['espaço normalizado', (c, a) => porLinhas(c, a, (l) => l.replace(/\s+/g, ' ').trim())],
97
+ ];
98
+
99
+ function achar(conteudo, alvo) {
100
+ for (const [como, f] of ESTRATEGIAS) {
101
+ const achados = f(conteudo, alvo);
102
+ if (achados.length === 1) return { ...achados[0], como };
103
+ if (achados.length > 1) return { erro: 'varios', vezes: achados.length, como };
104
+ }
105
+ return { erro: 'nenhum' };
106
+ }
107
+
108
+ module.exports = { achar, ocorrencias };
package/lib/catalogo.js CHANGED
@@ -87,12 +87,16 @@ const GRUPOS = [
87
87
  args: 'id. PREFIRA roteiro: {marca:{cores:{fundo,destaque,tinta},fontes:{titulo,texto},logo}, proporcao, '
88
88
  + 'partes:[{tipo, fala, …}]} — o PrimoCode faz o layout, a voz com entonação, os ícones, a música e os sons. '
89
89
  + 'Tipos: abertura{titulo,sub} titulo{titulo,sub} numero{valor,rotulo,sub} lista{titulo,itens} passos{titulo,itens} '
90
- + 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link}. '
90
+ + 'contraste{nao,sim} citacao{texto,autor} imagem{busca,titulo,sub} grafico{titulo,barras:[{rotulo,valor}]} cta{titulo,link} '
91
+ + 'logo{titulo} cores{cores:[{nome,hex}]} produto{titulo,texto,destaque,selo,barras}. Em qualquer parte: destaque (a palavra-tese), fonte (a fonte citada). '
92
+ + 'estilo: motion (padrão, 3D contínuo) | vox (explainer editorial: tinta sobre papel, câmera que conta a frase) | '
93
+ + 'marca (brand reveal: brilho, letras em vetor, marca cromada, padrão) | lancamento (produto: cartões, painel, calendário) | impacto | cinema. '
94
+ + 'marca e lancamento aceitam visual:orbe|vetor|estrela|esfera|grade|site|padrao|cromo|cartoes|painel|cores numa parte. marca.nome dá o nome. '
91
95
  + 'voz (thalita|antonio|francisca). Ajuste fino: plano: {formato:{largura,altura,fps}, cenas:[…], audio:{…}} — ou cenas direto. '
92
96
  + 'cena (opcional): o número de UMA cena para trocar só ela, sem reescrever o resto. '
93
97
  + 'motivo (opcional): fica no caderno. voltarPara (opcional): nome de uma versão. '
94
98
  + 'Sem plano nem cenas, LÊ o plano atual. '
95
- + 'Cada elemento é {tipo: texto|forma|imagem|video|icone|legenda|componente} com x, y, cor, tamanho e '
99
+ + 'Cada elemento é {tipo: texto|forma|imagem|video|icone|legenda|componente|kit} com x, y, cor, tamanho e '
96
100
  + '"anima": {propriedade: [{t,v,e}]} — quadros-chave em segundos. A cena tem "camera": {zoom,x,y,rotacao} e '
97
101
  + '"perspectiva" para 3D. NÃO existe layout pronto nem tema — cada propriedade do vídeo é escolha sua, a cada vídeo.' },
98
102
 
package/lib/config.js CHANGED
@@ -74,8 +74,11 @@ function ensureDir(dir) {
74
74
  try { fs.mkdirSync(dir, { recursive: true }); } catch {}
75
75
  }
76
76
 
77
+ // O dia LOCAL: toISOString é UTC, e no Brasil depois das 21h a memória saía
78
+ // datada no dia seguinte.
77
79
  function today() {
78
- return new Date().toISOString().split('T')[0];
80
+ const d = new Date();
81
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
79
82
  }
80
83
 
81
84
  function ensureStructure() {
@@ -99,12 +102,16 @@ function slugify(name) {
99
102
  .toLowerCase()
100
103
  .replace(/[^a-z0-9]+/g, '-')
101
104
  .replace(/^-+|-+$/g, '')
102
- .slice(0, 60);
105
+ .slice(0, 60)
106
+ .replace(/-+$/, ''); // o corte pode deixar hífen no fim
103
107
  }
104
108
 
105
109
  function createProjectDir(projectName) {
106
110
  ensureStructure();
107
- const slug = slugify(projectName);
111
+ /* Nome sem letra nem número ("!!!", só emoji, só japonês) dava slug
112
+ VAZIO — e path.join(ROOT_DIR, '') é a própria raiz: o "projeto" virava
113
+ ~/primocode, com memory.md gravado nela. */
114
+ const slug = slugify(projectName) || `projeto-${today()}`;
108
115
  const projectDir = path.join(ROOT_DIR, slug);
109
116
  ensureDir(projectDir);
110
117
  const memoryPath = path.join(projectDir, 'memory.md');