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.
- package/app/app.css +230 -0
- package/app/app.js +273 -0
- package/app/icone.svg +6 -0
- package/app/index.html +68 -0
- package/bin/primocode.js +52 -0
- package/lib/app.js +334 -0
- package/lib/catalogo.js +2 -0
- package/lib/config.js +53 -2
- package/lib/studio.js +125 -11
- package/lib/tools.js +35 -1
- package/package.json +3 -2
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
- package/studio/arquivos/corte-estatico-corte-a5bab.json +150 -0
- package/studio/arquivos/corte-founders-ai-teste-do-player-2de1d.json +65 -0
- package/studio/arquivos/corte-legado-teste.json +1 -0
- package/studio/arquivos/corte-legado2.json +1 -0
- package/studio/arquivos/corte-minimo-2a829.json +24 -0
- package/studio/arquivos/corte-ok-00a3b.json +18 -0
- package/studio/arquivos/corte-ok-1270e.json +22 -0
- package/studio/arquivos/corte-sweep-corte-cfb29.json +147 -0
- package/studio/arquivos/corte-visual-completo-78f50.json +35 -0
- package/studio/arquivos/grade-sweep-grade-0c2f1.json +119 -0
- package/studio/arquivos/prisma-estatico-prisma-d8740.json +182 -0
- package/studio/arquivos/prisma-sweep-prisma-4c58c.json +166 -0
- package/studio/arquivos/prisma-y-b0785.json +52 -0
- package/studio/arquivos/prosa-sweep-prosa-f29cf.json +80 -0
- package/studio/arquivos/tela-estatico-tela-c7daf.json +90 -0
- package/studio/arquivos/tela-sweep-tela-0d2bf.json +84 -0
- package/studio/arquivos/traco-estatico-traco-25872.json +113 -0
- package/studio/arquivos/traco-sweep-traco-0bcf4.json +102 -0
- package/studio/ferramentas/corte.md +159 -12
- package/studio/server.py +264 -8
- package/studio/web/analise.html +52 -0
- package/studio/web/js/analise.js +202 -0
- package/studio/web/js/exportar.js +88 -1
- package/studio/web/js/player.js +172 -46
- package/studio/web/js/render.js +99 -1
- package/studio/web/js/video.js +434 -0
- package/studio/web/render.css +27 -0
- package/studio/web/tool.html +1 -0
- 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
|
-
|
|
139
|
-
|
|
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
|
-
|
|
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
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
442
|
-
|
|
443
|
-
return
|
|
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
|
-
|
|
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);
|