primocode 8.22.1 → 8.26.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 (41) hide show
  1. package/app/app.css +230 -0
  2. package/app/app.js +273 -0
  3. package/app/icone.svg +6 -0
  4. package/app/index.html +68 -0
  5. package/bin/primocode.js +52 -0
  6. package/lib/app.js +334 -0
  7. package/lib/catalogo.js +2 -0
  8. package/lib/config.js +53 -2
  9. package/lib/studio.js +125 -11
  10. package/lib/tools.js +35 -1
  11. package/package.json +3 -2
  12. package/studio/__pycache__/voz.cpython-311.pyc +0 -0
  13. package/studio/arquivos/corte-estatico-corte-a5bab.json +150 -0
  14. package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +65 -0
  15. package/studio/arquivos/corte-legado-teste.json +1 -0
  16. package/studio/arquivos/corte-legado2.json +1 -0
  17. package/studio/arquivos/corte-minimo-2a829.json +24 -0
  18. package/studio/arquivos/corte-ok-00a3b.json +18 -0
  19. package/studio/arquivos/corte-ok-1270e.json +22 -0
  20. package/studio/arquivos/corte-sweep-corte-cfb29.json +147 -0
  21. package/studio/arquivos/corte-visual-completo-78f50.json +35 -0
  22. package/studio/arquivos/grade-sweep-grade-0c2f1.json +119 -0
  23. package/studio/arquivos/prisma-estatico-prisma-d8740.json +182 -0
  24. package/studio/arquivos/prisma-sweep-prisma-4c58c.json +166 -0
  25. package/studio/arquivos/prisma-y-b0785.json +52 -0
  26. package/studio/arquivos/prosa-sweep-prosa-f29cf.json +80 -0
  27. package/studio/arquivos/tela-estatico-tela-c7daf.json +90 -0
  28. package/studio/arquivos/tela-sweep-tela-0d2bf.json +84 -0
  29. package/studio/arquivos/traco-estatico-traco-25872.json +113 -0
  30. package/studio/arquivos/traco-sweep-traco-0bcf4.json +102 -0
  31. package/studio/ferramentas/corte.md +159 -12
  32. package/studio/server.py +264 -8
  33. package/studio/web/analise.html +52 -0
  34. package/studio/web/js/analise.js +202 -0
  35. package/studio/web/js/exportar.js +88 -1
  36. package/studio/web/js/player.js +172 -46
  37. package/studio/web/js/render.js +99 -1
  38. package/studio/web/js/video.js +434 -0
  39. package/studio/web/render.css +27 -0
  40. package/studio/web/tool.html +1 -0
  41. package/studio/web/view.html +2 -0
package/lib/app.js ADDED
@@ -0,0 +1,334 @@
1
+ /**
2
+ * app.js — o PrimoCode como aplicativo de janela.
3
+ *
4
+ * "Se mandar /app pra ele, ele abre o aplicativo que você vai desenvolver
5
+ * para o Mac e o Windows, já instala junto com o npm. Vai ser um aplicativo
6
+ * desktop bem bonito com as cores do PrimoCode, com aquele azul. Bem estilo
7
+ * Codex. Vai ter o chat em localhost. Um chat estilo terminal para quem não
8
+ * quer o terminal."
9
+ *
10
+ * ── POR QUE NÃO É ELECTRON ───────────────────────────────────────────────
11
+ * O PrimoCode tem ZERO dependência de npm, e isso não é purismo: é o que faz
12
+ * `npm i -g primocode` levar segundos em vez de baixar 200 MB de navegador
13
+ * empacotado. Electron levaria os dois embora — a promessa de "já instala
14
+ * junto com o npm" e a instalação leve.
15
+ *
16
+ * O que Electron entrega, no fundo, é uma janela sem barra de endereço
17
+ * mostrando uma página local. Chrome, Edge e Brave fazem exatamente isso com
18
+ * `--app=<url>`: janela limpa, ícone próprio na barra de tarefas, sem abas.
19
+ * O navegador já está na máquina do usuário — no Windows o Edge vem com o
20
+ * sistema, no Mac o Safari (e quase sempre o Chrome).
21
+ *
22
+ * Então: servidor HTTP local (http do Node, nada instalado) + janela do
23
+ * navegador em modo app. Se não houver nenhum navegador de Chromium, abre
24
+ * numa aba normal, que continua funcionando.
25
+ *
26
+ * ── POR QUE ELE ESPELHA O TERMINAL ───────────────────────────────────────
27
+ * O pedido foi "um chat estilo terminal para quem não quer o terminal" — e o
28
+ * agente já conta o que faz, linha por linha, com cor. Reescrever essa saída
29
+ * para a web daria duas verdades para manter, e uma delas ficaria velha.
30
+ *
31
+ * Aqui a saída é a MESMA: o `process.stdout.write` é espelhado, os códigos
32
+ * ANSI viram cor de verdade na página, e o terminal continua imprimindo
33
+ * normalmente. Quem abrir os dois vê a mesma coisa nos dois.
34
+ *
35
+ * ── SEGURANÇA ────────────────────────────────────────────────────────────
36
+ * Este servidor MANDA NO AGENTE: quem fala com ele executa comando, escreve
37
+ * arquivo e gasta a cota do usuário. Por isso ele só escuta em 127.0.0.1 e
38
+ * exige uma chave sorteada a cada abertura — sem ela, uma página qualquer
39
+ * aberta no mesmo navegador dispararia pedidos para cá.
40
+ */
41
+
42
+ 'use strict';
43
+
44
+ const http = require('http');
45
+ const fs = require('fs');
46
+ const os = require('os');
47
+ const path = require('path');
48
+ const crypto = require('crypto');
49
+ const { execFile } = require('child_process');
50
+
51
+ const WEB = path.join(__dirname, '..', 'app');
52
+ const PORTA_PADRAO = Number(process.env.PRIMOCODE_APP_PORT) || 7788;
53
+
54
+ // ── espelho da saída ─────────────────────────────────────────────────────
55
+
56
+ /* Duplica tudo que vai para o terminal, sem tirar nada de lá.
57
+ *
58
+ * O `write` original continua sendo chamado com os mesmos argumentos e o mesmo
59
+ * retorno: se este espelho mudasse o valor de volta, o Node passaria a achar
60
+ * que o buffer encheu (ou que não encheu) e a saída sairia picada. */
61
+ function espelhar(aoEscrever) {
62
+ const real = process.stdout.write.bind(process.stdout);
63
+ process.stdout.write = function (pedaco, enc, cb) {
64
+ try {
65
+ aoEscrever(typeof pedaco === 'string' ? pedaco : Buffer.from(pedaco).toString('utf8'));
66
+ } catch { /* o espelho nunca pode derrubar a saída */ }
67
+ return real(pedaco, enc, cb);
68
+ };
69
+ return function parar() { process.stdout.write = real; };
70
+ }
71
+
72
+ /* O prompt do readline e os quadros do rodinha não são conteúdo: são a
73
+ interface do TERMINAL se redesenhando. Na página eles viravam lixo piscando
74
+ no meio da conversa. O jeito de reconhecê-los é o que sobra depois de tirar
75
+ o ANSI: pedaço curto e sem quebra de linha é desenho, não texto. */
76
+ function ehRuido(texto) {
77
+ const limpo = texto.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\r/g, '');
78
+ if (limpo.includes('\n')) return false;
79
+ return limpo.trim().length < 12;
80
+ }
81
+
82
+ // ── o servidor ───────────────────────────────────────────────────────────
83
+
84
+ function tipoDe(arquivo) {
85
+ if (arquivo.endsWith('.html')) return 'text/html; charset=utf-8';
86
+ if (arquivo.endsWith('.css')) return 'text/css; charset=utf-8';
87
+ if (arquivo.endsWith('.js')) return 'application/javascript; charset=utf-8';
88
+ if (arquivo.endsWith('.svg')) return 'image/svg+xml';
89
+ return 'application/octet-stream';
90
+ }
91
+
92
+ /**
93
+ * Sobe a janela do PrimoCode.
94
+ *
95
+ * @param {object} op
96
+ * @param {(texto:string)=>Promise} op.enviar manda um pedido ao agente
97
+ * @param {(texto:string)=>Promise} op.barra trata um comando /barra
98
+ * @param {()=>void} op.interromper para o que estiver rodando
99
+ * @param {()=>object} op.estado { pasta, modo, esforco, projeto }
100
+ * @param {string} op.versao
101
+ */
102
+ async function abrir(op) {
103
+ const chave = crypto.randomBytes(16).toString('hex');
104
+ const ouvintes = new Set();
105
+ let ocupado = false;
106
+ const fila = [];
107
+
108
+ // O histórico de saída para quem abrir a janela DEPOIS de o agente já ter
109
+ // começado — sem isto a página nasceria vazia no meio de uma tarefa.
110
+ const memoria = [];
111
+ const MEMORIA_MAX = 400;
112
+
113
+ function emitir(evento, dados) {
114
+ const linha = `event: ${evento}\ndata: ${JSON.stringify(dados)}\n\n`;
115
+ for (const res of ouvintes) {
116
+ try { res.write(linha); } catch { ouvintes.delete(res); }
117
+ }
118
+ }
119
+
120
+ const pararEspelho = espelhar((pedaco) => {
121
+ if (ehRuido(pedaco)) return;
122
+ memoria.push(pedaco);
123
+ if (memoria.length > MEMORIA_MAX) memoria.shift();
124
+ emitir('saida', pedaco);
125
+ });
126
+
127
+ async function processar(texto) {
128
+ fila.push(texto);
129
+ if (ocupado) return;
130
+ ocupado = true;
131
+ emitir('ocupado', true);
132
+ while (fila.length) {
133
+ const t = String(fila.shift() || '').trim();
134
+ if (!t) continue;
135
+ emitir('voce', t);
136
+ try {
137
+ if (t.startsWith('/')) await op.barra(t);
138
+ else await op.enviar(t);
139
+ } catch (e) {
140
+ emitir('erro', (e && e.message) || String(e));
141
+ }
142
+ }
143
+ ocupado = false;
144
+ emitir('ocupado', false);
145
+ emitir('estado', op.estado());
146
+ }
147
+
148
+ const servidor = http.createServer((req, res) => {
149
+ const url = new URL(req.url, 'http://127.0.0.1');
150
+ const rota = url.pathname;
151
+
152
+ /* A chave protege a API, que é onde está tudo o que importa: a conversa
153
+ do usuário e a capacidade de dar ordens ao agente. A página em si —
154
+ HTML, CSS, JS — não guarda segredo nenhum e não faz nada sem a
155
+ chave, então ela é servida solta.
156
+
157
+ Não é frouxidão, é o contrário: exigir chave nos estáticos também
158
+ quebrava a própria janela, porque o navegador pede /app.css e
159
+ /app.js sem parâmetro nenhum. A página vinha, ficava sem estilo e
160
+ sem script, e o "app bonito" era um formulário branco. */
161
+ if (rota.startsWith('/api/')) {
162
+ const dada = url.searchParams.get('k') || req.headers['x-primo-chave'];
163
+ if (dada !== chave) {
164
+ res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
165
+ return res.end('chave inválida — abra a janela com o comando /app');
166
+ }
167
+ }
168
+ /* CSRF: uma página de terceiro consegue disparar POST simples para
169
+ 127.0.0.1 sem preflight. Ela não saberia a chave, mas o cinto e o
170
+ suspensório custam três linhas. */
171
+ const origem = req.headers.origin;
172
+ if (origem && !origem.startsWith(`http://127.0.0.1:${servidor.__porta}`)
173
+ && !origem.startsWith(`http://localhost:${servidor.__porta}`)) {
174
+ res.writeHead(403).end();
175
+ return;
176
+ }
177
+
178
+ if (rota === '/api/fluxo') {
179
+ res.writeHead(200, {
180
+ 'Content-Type': 'text/event-stream; charset=utf-8',
181
+ 'Cache-Control': 'no-cache',
182
+ Connection: 'keep-alive',
183
+ // Sem isto um proxy no meio segura os eventos até fechar.
184
+ 'X-Accel-Buffering': 'no',
185
+ });
186
+ res.write(': ligado\n\n');
187
+ ouvintes.add(res);
188
+ res.write(`event: estado\ndata: ${JSON.stringify(op.estado())}\n\n`);
189
+ for (const pedaco of memoria) {
190
+ res.write(`event: saida\ndata: ${JSON.stringify(pedaco)}\n\n`);
191
+ }
192
+ res.write(`event: ocupado\ndata: ${JSON.stringify(ocupado)}\n\n`);
193
+ // Batida a cada 20s: proxy e sistema derrubam conexão parada, e
194
+ // uma janela que "morre sozinha depois de um tempo" seria um bug
195
+ // impossível de achar depois.
196
+ const batida = setInterval(() => { try { res.write(': ok\n\n'); } catch {} }, 20000);
197
+ req.on('close', () => { clearInterval(batida); ouvintes.delete(res); });
198
+ return;
199
+ }
200
+
201
+ if (rota === '/api/enviar' && req.method === 'POST') {
202
+ let corpo = '';
203
+ req.on('data', (c) => { corpo += c; if (corpo.length > 200000) req.destroy(); });
204
+ req.on('end', () => {
205
+ let texto = '';
206
+ try { texto = JSON.parse(corpo).texto || ''; } catch { /* vazio */ }
207
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
208
+ if (texto.trim()) processar(texto);
209
+ });
210
+ return;
211
+ }
212
+
213
+ if (rota === '/api/parar' && req.method === 'POST') {
214
+ try { op.interromper(); } catch {}
215
+ res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
216
+ return;
217
+ }
218
+
219
+ if (rota === '/api/estado') {
220
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
221
+ return res.end(JSON.stringify(Object.assign({ versao: op.versao }, op.estado())));
222
+ }
223
+
224
+ // Estáticos. `basename` corta qualquer "../" antes de virar caminho:
225
+ // este servidor fala com o disco do usuário e não pode servir o que
226
+ // estiver fora da pasta do aplicativo.
227
+ const nome = rota === '/' ? 'index.html' : path.basename(rota);
228
+ const arquivo = path.join(WEB, nome);
229
+ fs.readFile(arquivo, (erro, dados) => {
230
+ if (erro) { res.writeHead(404).end('não encontrado'); return; }
231
+ res.writeHead(200, { 'Content-Type': tipoDe(nome) });
232
+ res.end(dados);
233
+ });
234
+ });
235
+
236
+ const porta = await escutar(servidor, PORTA_PADRAO);
237
+ servidor.__porta = porta;
238
+ const url = `http://127.0.0.1:${porta}/?k=${chave}`;
239
+
240
+ const janela = await abrirJanela(url);
241
+ return {
242
+ url, porta, chave, servidor, janela,
243
+ fechar() { try { pararEspelho(); } catch {} try { servidor.close(); } catch {} },
244
+ };
245
+ }
246
+
247
+ /* Porta ocupada não é erro: é outra janela do PrimoCode, ou qualquer coisa
248
+ que pegou a 7788 primeiro. Anda dez casas e desiste — se dez portas
249
+ seguidas estão tomadas, o problema é outro. */
250
+ function escutar(servidor, inicial) {
251
+ return new Promise((ok, falha) => {
252
+ let porta = inicial;
253
+ const tentar = () => {
254
+ servidor.once('error', (e) => {
255
+ if (e.code === 'EADDRINUSE' && porta < inicial + 10) { porta++; tentar(); }
256
+ else falha(e);
257
+ });
258
+ servidor.listen(porta, '127.0.0.1', () => ok(porta));
259
+ };
260
+ tentar();
261
+ });
262
+ }
263
+
264
+ /* ── a janela ────────────────────────────────────────────────────────────
265
+ `--app=` abre sem barra de endereço, sem abas e com entrada própria na
266
+ barra de tarefas: é o que faz parecer aplicativo em vez de site. Chrome,
267
+ Edge, Brave e Vivaldi entendem, porque todos são Chromium.
268
+
269
+ `--user-data-dir` aponta para uma pasta do PrimoCode de propósito: sem ele,
270
+ o Chrome que JÁ ESTÁ ABERTO recebe o pedido e devolve uma aba comum — o
271
+ modo app é ignorado quando há um perfil em uso. Com perfil próprio nasce
272
+ uma janela de verdade, e o histórico do usuário fica fora disto. */
273
+ function candidatosDeNavegador() {
274
+ const p = os.platform();
275
+ if (p === 'darwin') {
276
+ return [
277
+ '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
278
+ '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
279
+ '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
280
+ '/Applications/Chromium.app/Contents/MacOS/Chromium',
281
+ '/Applications/Arc.app/Contents/MacOS/Arc',
282
+ ];
283
+ }
284
+ if (p === 'win32') {
285
+ const bases = [process.env['PROGRAMFILES'], process.env['PROGRAMFILES(X86)'],
286
+ process.env.LOCALAPPDATA].filter(Boolean);
287
+ const relativos = [
288
+ 'Google\\Chrome\\Application\\chrome.exe',
289
+ 'Microsoft\\Edge\\Application\\msedge.exe',
290
+ 'BraveSoftware\\Brave-Browser\\Application\\brave.exe',
291
+ ];
292
+ const fora = [];
293
+ for (const b of bases) for (const r of relativos) fora.push(path.join(b, r));
294
+ return fora;
295
+ }
296
+ return ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium',
297
+ '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge', '/usr/bin/brave-browser'];
298
+ }
299
+
300
+ function abrirJanela(url) {
301
+ const perfil = path.join(os.homedir(), '.primocode', 'janela');
302
+ try { fs.mkdirSync(perfil, { recursive: true }); } catch {}
303
+
304
+ for (const exe of candidatosDeNavegador()) {
305
+ if (!fs.existsSync(exe)) continue;
306
+ try {
307
+ const filho = execFile(exe, [
308
+ `--app=${url}`,
309
+ `--user-data-dir=${perfil}`,
310
+ '--window-size=1180,820',
311
+ '--no-first-run',
312
+ '--no-default-browser-check',
313
+ ], () => {});
314
+ // A janela é do usuário: se o PrimoCode do terminal terminar, ela
315
+ // continua de pé.
316
+ filho.unref();
317
+ return Promise.resolve({ modo: 'janela', navegador: path.basename(exe) });
318
+ } catch { /* tenta o próximo */ }
319
+ }
320
+
321
+ // Nenhum Chromium instalado: aba comum no navegador padrão. Funciona
322
+ // igual — só não fica com cara de aplicativo.
323
+ const p = os.platform();
324
+ const [cmd, args] = p === 'darwin' ? ['open', [url]]
325
+ : p === 'win32' ? ['cmd.exe', ['/c', 'start', '', url]]
326
+ : ['xdg-open', [url]];
327
+ return new Promise((ok) => {
328
+ execFile(cmd, args, { timeout: 10000 }, (erro) => {
329
+ ok(erro ? { modo: 'nenhum', erro: erro.message } : { modo: 'aba' });
330
+ });
331
+ });
332
+ }
333
+
334
+ module.exports = { abrir, abrirJanela, espelhar, ehRuido, candidatosDeNavegador };
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/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);