primocode 9.9.0 → 10.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/README.md +30 -6
  2. package/bin/primocode.js +100 -341
  3. package/lib/act.js +245 -23
  4. package/lib/browser.js +17 -0
  5. package/lib/casar.js +108 -0
  6. package/lib/catalogo.js +6 -2
  7. package/lib/config.js +10 -3
  8. package/lib/cursor.js +10 -1
  9. package/lib/entrada.js +4 -2
  10. package/lib/estudio/diretor.js +9 -1
  11. package/lib/estudio/ende.js +2 -0
  12. package/lib/estudio/estilo.js +51 -0
  13. package/lib/estudio/glifos.js +215 -0
  14. package/lib/estudio/index.js +63 -11
  15. package/lib/estudio/kit/base.jsx +285 -0
  16. package/lib/estudio/kit/index.jsx +11 -0
  17. package/lib/estudio/kit/marca.jsx +741 -0
  18. package/lib/estudio/kit/vox.jsx +357 -0
  19. package/lib/estudio/kit.js +72 -0
  20. package/lib/estudio/lancamento.js +413 -0
  21. package/lib/estudio/morfar.js +191 -0
  22. package/lib/estudio/motion.js +333 -0
  23. package/lib/estudio/plano.js +54 -7
  24. package/lib/estudio/remotion.js +167 -52
  25. package/lib/estudio/ritmo.js +77 -0
  26. package/lib/estudio/sons-leves.js +62 -0
  27. package/lib/estudio/vox.js +385 -0
  28. package/lib/fala.js +1 -94
  29. package/lib/pipeline.js +55 -56
  30. package/lib/repeticao.js +3 -1
  31. package/lib/skills.js +8 -2
  32. package/lib/snapshot.js +111 -0
  33. package/lib/tools.js +79 -20
  34. package/lib/ui.js +13 -6
  35. package/lib/video/analise.js +89 -0
  36. package/lib/video/cli.js +74 -0
  37. package/lib/video/delta.js +90 -0
  38. package/lib/video/edits.js +379 -0
  39. package/lib/video/filters.js +247 -0
  40. package/lib/video/ops.js +322 -0
  41. package/lib/video/projeto.js +133 -0
  42. package/lib/video/render.js +221 -0
  43. package/lib/video/texto.js +142 -0
  44. package/lib/video/timeline.js +310 -0
  45. package/lib/video/transcript.js +124 -0
  46. package/lib/voz.js +75 -223
  47. package/package.json +2 -3
  48. package/skills/motion-de-primeira/SKILL.md +21 -1
  49. package/studio/texto.py +208 -0
  50. package/voz/primo_voz/__main__.py +96 -236
  51. package/voz/primo_voz/transcrever.py +31 -1
  52. package/app/ansi.js +0 -55
  53. package/app/app.css +0 -500
  54. package/app/app.js +0 -1017
  55. package/app/icone.svg +0 -6
  56. package/app/index.html +0 -220
  57. package/app/primo-marca.js +0 -192
  58. package/lib/app.js +0 -615
  59. package/lib/icone.js +0 -263
  60. package/lib/instalar-app.js +0 -231
  61. package/lib/painel.js +0 -291
  62. package/voz/primo_voz/desperta.py +0 -195
  63. package/voz/primo_voz/orbe.py +0 -406
package/lib/app.js DELETED
@@ -1,615 +0,0 @@
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
- * ── O LAYOUT: CHAT, NÃO TERMINAL ─────────────────────────────────────────
27
- * "Remove a IDE ou deixa idêntico ao Codex." A janela é um chat de app de
28
- * mesa: barra lateral (novo chat, projetos, recentes), a pergunta no meio da
29
- * tela vazia e o compositor embaixo com projeto, anexo, permissão, esforço e
30
- * voz. A conversa aparece como chat — o pedido num balão, a fala do Primo em
31
- * texto e cada ação dele numa linha cinza ("Editou style.css +12 −3").
32
- *
33
- * Essas linhas NÃO saem de reler a saída colorida do terminal: vêm do canal
34
- * estruturado de lib/fala.js, que é quem desenha o terminal. Uma verdade só,
35
- * duas telas. O terminal continua espelhado (`process.stdout.write`
36
- * duplicado) e fica atrás do botão "Terminal" — quem quiser o detalhe cru
37
- * ainda tem.
38
- *
39
- * ── SEGURANÇA ────────────────────────────────────────────────────────────
40
- * Este servidor MANDA NO AGENTE: quem fala com ele executa comando, escreve
41
- * arquivo e gasta a cota do usuário. Por isso ele só escuta em 127.0.0.1 e
42
- * exige uma chave sorteada a cada abertura — sem ela, uma página qualquer
43
- * aberta no mesmo navegador dispararia pedidos para cá.
44
- */
45
-
46
- 'use strict';
47
-
48
- const http = require('http');
49
- const fs = require('fs');
50
- const os = require('os');
51
- const path = require('path');
52
- const crypto = require('crypto');
53
- const { execFile } = require('child_process');
54
- const fala = require('./fala.js');
55
- const cfg = require('./config.js');
56
- const perigo = require('./perigo.js');
57
- const effort = require('./effort.js');
58
- const extra = require('./comandos-extra.js');
59
-
60
- const WEB = path.join(__dirname, '..', 'app');
61
- const PORTA_PADRAO = Number(process.env.PRIMOCODE_APP_PORT) || 7788;
62
-
63
- // ── espelho da saída ─────────────────────────────────────────────────────
64
-
65
- /* Duplica tudo que vai para o terminal, sem tirar nada de lá.
66
- *
67
- * O `write` original continua sendo chamado com os mesmos argumentos e o mesmo
68
- * retorno: se este espelho mudasse o valor de volta, o Node passaria a achar
69
- * que o buffer encheu (ou que não encheu) e a saída sairia picada. */
70
- function espelhar(aoEscrever) {
71
- const real = process.stdout.write.bind(process.stdout);
72
- process.stdout.write = function (pedaco, enc, cb) {
73
- try {
74
- aoEscrever(typeof pedaco === 'string' ? pedaco : Buffer.from(pedaco).toString('utf8'));
75
- } catch { /* o espelho nunca pode derrubar a saída */ }
76
- return real(pedaco, enc, cb);
77
- };
78
- return function parar() { process.stdout.write = real; };
79
- }
80
-
81
- /* O prompt do readline e os quadros do rodinha não são conteúdo: são a
82
- interface do TERMINAL se redesenhando. Na página eles viravam lixo piscando
83
- no meio da conversa. O jeito de reconhecê-los é o que sobra depois de tirar
84
- o ANSI: pedaço curto e sem quebra de linha é desenho, não texto. */
85
- function ehRuido(texto) {
86
- const limpo = texto.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').replace(/\r/g, '');
87
- if (limpo.includes('\n')) return false;
88
- return limpo.trim().length < 12;
89
- }
90
-
91
- // ── o servidor ───────────────────────────────────────────────────────────
92
-
93
- function tipoDe(arquivo) {
94
- if (arquivo.endsWith('.html')) return 'text/html; charset=utf-8';
95
- if (arquivo.endsWith('.css')) return 'text/css; charset=utf-8';
96
- if (arquivo.endsWith('.js')) return 'application/javascript; charset=utf-8';
97
- if (arquivo.endsWith('.svg')) return 'image/svg+xml';
98
- return 'application/octet-stream';
99
- }
100
-
101
- /* O anexo que chega pela janela vira `state.attachedFiles`, igual ao
102
- /attach — e o /attach lê como TEXTO. Um PNG lido como texto entraria no
103
- pedido como lixo e gastaria a cota inteira do modelo, então binário é
104
- recusado aqui, com o motivo. 2 MB é o teto: acima disso o texto sozinho já
105
- estoura o contexto de qualquer modelo grátis. */
106
- const MAX_ANEXO = 2 * 1024 * 1024;
107
-
108
- /* "Recentes" sem conta: os últimos pedidos do history.jsonl, o mesmo arquivo
109
- que o /historico procura. Lê só o fim do arquivo — ele cresce para sempre,
110
- e a barra lateral só mostra uns vinte. */
111
- function recentesLocais(arquivo, limite = 20) {
112
- let texto = '';
113
- try {
114
- const fd = fs.openSync(arquivo, 'r');
115
- try {
116
- const tam = fs.fstatSync(fd).size;
117
- const ler = Math.min(tam, 128 * 1024);
118
- const buf = Buffer.alloc(ler);
119
- fs.readSync(fd, buf, 0, ler, tam - ler);
120
- texto = buf.toString('utf8');
121
- } finally { fs.closeSync(fd); }
122
- } catch { return []; }
123
- const vistos = new Set();
124
- const fora = [];
125
- const linhas = texto.split('\n').filter(Boolean).reverse();
126
- for (const l of linhas) {
127
- let j;
128
- try { j = JSON.parse(l); } catch { continue; } // a 1ª linha pode vir cortada
129
- const titulo = String(j.prompt || '').replace(/\s+/g, ' ').trim();
130
- if (!titulo || titulo.startsWith('/') || vistos.has(titulo)) continue;
131
- vistos.add(titulo);
132
- fora.push({ titulo: titulo.slice(0, 200), quando: j.ts || null, projeto: j.project || null });
133
- if (fora.length >= limite) break;
134
- }
135
- return fora;
136
- }
137
-
138
- function lerJson(req, max = 64 * 1024) {
139
- return new Promise((ok) => {
140
- let corpo = '';
141
- let grande = false;
142
- req.on('data', (c) => {
143
- if (grande) return;
144
- corpo += c;
145
- if (corpo.length > max) { grande = true; }
146
- });
147
- req.on('end', () => {
148
- if (grande) return ok({ __grande: true });
149
- try { ok(JSON.parse(corpo || '{}') || {}); } catch { ok(null); }
150
- });
151
- req.on('error', () => ok(null));
152
- });
153
- }
154
-
155
- /**
156
- * Sobe a janela do PrimoCode.
157
- *
158
- * As ações da barra lateral e do compositor (projeto, permissão, esforço,
159
- * anexo, voz, novo chat) chegam por funções próprias em vez de virar texto de
160
- * comando: um "/permissoes livre" digitado pela janela entraria na conversa
161
- * como se a pessoa tivesse escrito. Quem não passar a função cai no comando
162
- * equivalente via `op.barra` — o teste e o `primo --app` antigo seguem de pé.
163
- *
164
- * @param {object} op
165
- * @param {(texto:string)=>Promise} op.enviar manda um pedido ao agente
166
- * @param {(texto:string)=>Promise} op.barra trata um comando /barra
167
- * @param {()=>void} op.interromper para o que estiver rodando
168
- * @param {()=>object} op.estado { pasta, projeto, modo, esforco, permissoes, voz, usuario, mascote, anexos }
169
- * @param {string} op.versao
170
- * @param {()=>string} [op.pastaProjeto] liga o painel (Prévia/Arquivos) nesta pasta
171
- * @param {()=>Array} [op.projetos] a lista de ~/primocode (padrão: listarProjetos)
172
- * @param {(dir:string)=>any} [op.entrarProjeto]
173
- * @param {()=>any} [op.novoChat]
174
- * @param {(m:string)=>any} [op.mudarPermissoes]
175
- * @param {(n:string)=>any} [op.mudarEsforco]
176
- * @param {(f:{path,content})=>any} [op.anexar]
177
- * @param {(nome?:string)=>any} [op.desanexar]
178
- * @param {(ligar:boolean)=>any} [op.voz]
179
- * @param {(c:{id,titulo})=>Promise} [op.continuarConversa]
180
- * @param {'rc'|'ide'} [op.abrirEm] 'ide' abre com o painel da prévia aberto
181
- * @param {boolean} [op.semJanela] só o servidor, sem abrir navegador
182
- */
183
- async function abrir(op) {
184
- const chave = crypto.randomBytes(16).toString('hex');
185
- const ouvintes = new Set();
186
- let ocupado = false;
187
- const fila = [];
188
-
189
- // O histórico do TERMINAL para quem abrir a janela depois — é o que o
190
- // botão "Terminal" mostra.
191
- const memoria = [];
192
- const MEMORIA_MAX = 400;
193
- // O histórico do CHAT: o mesmo tipo de coisa, mas já estruturado (pedido,
194
- // fala, ação, resultado). Sem ele, um F5 no meio de uma tarefa apagava a
195
- // conversa da tela.
196
- let conversa = [];
197
- const CONVERSA_MAX = 800;
198
-
199
- function emitir(evento, dados) {
200
- const linha = `event: ${evento}\ndata: ${JSON.stringify(dados)}\n\n`;
201
- for (const res of ouvintes) {
202
- try { res.write(linha); } catch { ouvintes.delete(res); }
203
- }
204
- }
205
- function emitirChat(evento, dados) {
206
- conversa.push({ evento, dados });
207
- if (conversa.length > CONVERSA_MAX) conversa.shift();
208
- emitir(evento, dados);
209
- }
210
- const estadoAgora = () => Object.assign({ versao: op.versao, ocupado }, op.estado());
211
-
212
- const painel = op.pastaProjeto
213
- ? require('./painel.js').montar({ pasta: op.pastaProjeto, emitir: (e, d) => emitir(e, d) })
214
- : null;
215
-
216
- /* O que o agente faz chega estruturado por lib/fala.js. `falouNesta`
217
- diz se o pedido atual produziu alguma coisa por ali: um /help, um
218
- /status, não produz — e aí a janela mostra o texto que ele imprimiu. */
219
- let falouNesta = false;
220
- const aoFalar = (e) => { falouNesta = true; emitirChat('agente', e); };
221
- fala.eventos.on('evento', aoFalar);
222
-
223
- let capturando = null;
224
- const pararEspelho = espelhar((pedaco) => {
225
- if (capturando) capturando.push(pedaco);
226
- if (ehRuido(pedaco)) return;
227
- memoria.push(pedaco);
228
- if (memoria.length > MEMORIA_MAX) memoria.shift();
229
- emitir('saida', pedaco);
230
- });
231
-
232
- async function processar(texto) {
233
- fila.push(texto);
234
- if (ocupado) return;
235
- ocupado = true;
236
- emitir('ocupado', true);
237
- while (fila.length) {
238
- const t = String(fila.shift() || '').trim();
239
- if (!t) continue;
240
- emitirChat('voce', t);
241
- falouNesta = false;
242
- capturando = [];
243
- try {
244
- if (t.startsWith('/')) await op.barra(t);
245
- else await op.enviar(t);
246
- } catch (e) {
247
- emitirChat('erro', (e && e.message) || String(e));
248
- }
249
- const impresso = capturando.join('');
250
- capturando = null;
251
- if (!falouNesta && impresso.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '').trim()) {
252
- emitirChat('comando', impresso.slice(-20000));
253
- }
254
- }
255
- ocupado = false;
256
- emitir('ocupado', false);
257
- emitir('estado', estadoAgora());
258
- }
259
-
260
- const servidor = http.createServer((req, res) => {
261
- atender(req, res).catch((e) => {
262
- // Uma exceção solta aqui vira unhandledRejection e derruba o CLI
263
- // INTEIRO junto com a janela — o trabalho da pessoa some porque
264
- // uma listagem tropeçou. Aqui ela vira um 500 e a janela segue.
265
- try {
266
- if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' });
267
- res.end(JSON.stringify({ ok: false, error: e.message }));
268
- } catch { /* socket já foi */ }
269
- });
270
- });
271
-
272
- function json(res, status, corpo) {
273
- res.writeHead(status, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
274
- res.end(JSON.stringify(corpo));
275
- }
276
-
277
- async function atender(req, res) {
278
- const url = new URL(req.url, 'http://127.0.0.1');
279
- const rota = url.pathname;
280
- const POST = req.method === 'POST';
281
-
282
- /* A chave protege a API, que é onde está tudo o que importa: a conversa
283
- do usuário e a capacidade de dar ordens ao agente. A página em si —
284
- HTML, CSS, JS — não guarda segredo nenhum e não faz nada sem a
285
- chave, então ela é servida solta (exigir chave nos estáticos
286
- quebrava a janela: o navegador pede /app.css sem parâmetro). */
287
- if (rota.startsWith('/api/')) {
288
- // A prévia leva a chave no CAMINHO (ver lib/painel.js): os
289
- // arquivos que a página do projeto pede por endereço relativo não
290
- // carregam query nenhuma.
291
- const naPrevia = rota.match(/^\/api\/previa\/([0-9a-f]+)/);
292
- const dada = (naPrevia && naPrevia[1]) || url.searchParams.get('k') || req.headers['x-primo-chave'];
293
- if (dada !== chave) {
294
- res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
295
- return res.end('chave inválida — abra a janela com o comando /rc');
296
- }
297
- }
298
- /* CSRF: uma página de terceiro consegue disparar POST simples para
299
- 127.0.0.1 sem preflight. Ela não saberia a chave, mas o cinto e o
300
- suspensório custam três linhas. A exceção é a prévia: ela roda num
301
- iframe sandbox de origem opaca (`Origin: null`), só GET e só nas
302
- rotas que LEEM o projeto. */
303
- const origem = req.headers.origin;
304
- const previaOpaca = origem === 'null' && req.method === 'GET' && rota.startsWith('/api/previa/');
305
- if (origem && !previaOpaca && !origem.startsWith(`http://127.0.0.1:${servidor.__porta}`)
306
- && !origem.startsWith(`http://localhost:${servidor.__porta}`)) {
307
- res.writeHead(403).end();
308
- return;
309
- }
310
-
311
- if (rota === '/api/fluxo') {
312
- res.writeHead(200, {
313
- 'Content-Type': 'text/event-stream; charset=utf-8',
314
- 'Cache-Control': 'no-cache',
315
- Connection: 'keep-alive',
316
- // Sem isto um proxy no meio segura os eventos até fechar.
317
- 'X-Accel-Buffering': 'no',
318
- });
319
- res.write(': ligado\n\n');
320
- ouvintes.add(res);
321
- res.write(`event: estado\ndata: ${JSON.stringify(estadoAgora())}\n\n`);
322
- res.write(`event: terminal\ndata: ${JSON.stringify(memoria.join(''))}\n\n`);
323
- res.write(`event: historia\ndata: ${JSON.stringify(conversa)}\n\n`);
324
- res.write(`event: ocupado\ndata: ${JSON.stringify(ocupado)}\n\n`);
325
- // Batida a cada 20s: proxy e sistema derrubam conexão parada.
326
- const batida = setInterval(() => { try { res.write(': ok\n\n'); } catch {} }, 20000);
327
- req.on('close', () => { clearInterval(batida); ouvintes.delete(res); });
328
- return;
329
- }
330
-
331
- if (rota === '/api/enviar' && POST) {
332
- const d = await lerJson(req, 200000);
333
- const texto = d && typeof d.texto === 'string' ? d.texto : '';
334
- json(res, 200, { ok: true });
335
- if (texto.trim()) processar(texto);
336
- return;
337
- }
338
-
339
- if (rota === '/api/parar' && POST) {
340
- try { op.interromper(); } catch {}
341
- return json(res, 200, { ok: true });
342
- }
343
-
344
- if (rota === '/api/estado') return json(res, 200, estadoAgora());
345
-
346
- // ── projetos ────────────────────────────────────────────────────
347
- const listar = () => (op.projetos ? op.projetos() : extra.listarProjetos(cfg.ROOT_DIR));
348
- if (rota === '/api/projetos') {
349
- const atual = (op.estado() || {}).projeto;
350
- const projetos = listar().map((p) => ({
351
- nome: p.nome, modificado: p.modificado || null, arquivos: p.arquivos || 0, atual: p.nome === atual,
352
- }));
353
- return json(res, 200, { ok: true, projetos });
354
- }
355
- if (rota === '/api/projeto' && POST) {
356
- if (ocupado) return json(res, 409, { ok: false, error: 'o Primo está trabalhando — pare ou espere para trocar de projeto' });
357
- const d = await lerJson(req);
358
- if (!d) return json(res, 400, { ok: false, error: 'pedido ilegível' });
359
- let dir = null;
360
- /* Só entra em projeto que ESTÁ na lista de ~/primocode, por nome
361
- exato. Aceitar um caminho aqui daria à página o poder de apontar
362
- o agente para qualquer pasta do disco. */
363
- if (typeof d.nome === 'string') {
364
- const achado = listar().find((p) => p.nome === d.nome);
365
- if (!achado) return json(res, 404, { ok: false, error: 'não achei esse projeto em ~/primocode' });
366
- dir = achado.dir;
367
- } else if (typeof d.novo === 'string') {
368
- const nome = d.novo.trim().slice(0, 60);
369
- if (!cfg.slugify(nome)) return json(res, 400, { ok: false, error: 'dê um nome com letras ou números' });
370
- dir = cfg.createProjectDir(nome);
371
- } else return json(res, 400, { ok: false, error: 'faltou o nome do projeto' });
372
- if (op.entrarProjeto) await op.entrarProjeto(dir);
373
- else await op.barra('/dir ' + dir);
374
- emitir('estado', estadoAgora());
375
- return json(res, 200, { ok: true, estado: estadoAgora() });
376
- }
377
-
378
- // ── conversa ────────────────────────────────────────────────────
379
- if (rota === '/api/novo-chat' && POST) {
380
- if (ocupado) return json(res, 409, { ok: false, error: 'o Primo está trabalhando — pare antes de começar outro chat' });
381
- if (op.novoChat) await op.novoChat();
382
- else await op.barra('/clear');
383
- conversa = [];
384
- emitir('limpar', true);
385
- emitir('estado', estadoAgora());
386
- return json(res, 200, { ok: true });
387
- }
388
-
389
- /* "Recentes": as conversas da conta quando há conta (as mesmas do
390
- /chats, do app e do celular); sem conta, os últimos pedidos
391
- deste computador. A janela não fala com o Firestore por conta
392
- própria — uma segunda implementação da cifra divergiria. */
393
- if (rota === '/api/recentes') {
394
- const nuvem = require('./nuvem.js');
395
- const r = await nuvem.listarConversas({ limite: 30 }).catch(() => ({ ok: false }));
396
- if (r && r.ok) {
397
- return json(res, 200, { ok: true, fonte: 'nuvem', itens: r.conversas.map((c) => ({
398
- id: c.id, titulo: c.titulo, quando: c.atualizada, mensagens: c.mensagens })) });
399
- }
400
- return json(res, 200, { ok: true, fonte: 'local', itens: recentesLocais(cfg.HISTORY_FILE) });
401
- }
402
-
403
- if (rota === '/api/conversa/abrir' && POST) {
404
- if (ocupado) return json(res, 409, { ok: false, error: 'o Primo está trabalhando' });
405
- const d = await lerJson(req);
406
- if (!d || typeof d.id !== 'string' || !d.id) return json(res, 400, { ok: false, error: 'faltou a conversa' });
407
- const r = op.continuarConversa
408
- ? await op.continuarConversa({ id: d.id, titulo: String(d.titulo || '') })
409
- : await require('./nuvem.js').lerConversa(d.id);
410
- if (!r || !r.ok) return json(res, 200, { ok: false, error: (r && r.error) || 'não abriu' });
411
- // A conversa aberta vira a da tela: pedido como balão, resposta
412
- // como fala — e um F5 depois continua mostrando ela.
413
- conversa = (r.mensagens || []).map((m) => (m.papel === 'user'
414
- ? { evento: 'voce', dados: String(m.texto || '') }
415
- : { evento: 'agente', dados: { tipo: 'resposta', texto: String(m.texto || '') } }));
416
- emitir('historia', conversa);
417
- return json(res, 200, { ok: true, mensagens: (r.mensagens || []).length });
418
- }
419
-
420
- // ── o compositor ────────────────────────────────────────────────
421
- if (rota === '/api/permissoes' && POST) {
422
- const d = await lerJson(req);
423
- const m = d && d.modo;
424
- if (!perigo.MODOS.includes(m)) return json(res, 400, { ok: false, error: 'use auto, perguntar ou livre' });
425
- if (op.mudarPermissoes) await op.mudarPermissoes(m);
426
- else await op.barra('/permissoes ' + m);
427
- emitir('estado', estadoAgora());
428
- return json(res, 200, { ok: true, estado: estadoAgora() });
429
- }
430
-
431
- if (rota === '/api/esforco' && POST) {
432
- const d = await lerJson(req);
433
- const n = d && d.nivel;
434
- if (!effort.NOMES.includes(n)) return json(res, 400, { ok: false, error: 'esforço desconhecido' });
435
- if (op.mudarEsforco) await op.mudarEsforco(n);
436
- else await op.barra('/effort ' + n);
437
- emitir('estado', estadoAgora());
438
- return json(res, 200, { ok: true, estado: estadoAgora() });
439
- }
440
-
441
- if (rota === '/api/anexar' && POST) {
442
- const d = await lerJson(req, Math.ceil(MAX_ANEXO * 1.4) + 4096);
443
- if (d && d.__grande) return json(res, 413, { ok: false, error: 'maior que 2 MB' });
444
- if (!d || typeof d.nome !== 'string' || typeof d.base64 !== 'string') {
445
- return json(res, 400, { ok: false, error: 'faltou o arquivo' });
446
- }
447
- // Só o NOME: o que vem da página não escolhe caminho nenhum.
448
- const nome = path.basename(d.nome.replace(/\\/g, '/')).replace(/[\x00-\x1f]/g, '').slice(0, 120);
449
- const buf = Buffer.from(d.base64, 'base64');
450
- if (!nome) return json(res, 400, { ok: false, error: 'nome inválido' });
451
- if (buf.length > MAX_ANEXO) return json(res, 413, { ok: false, error: 'maior que 2 MB' });
452
- if (require('./painel.js').ehBinario(buf)) {
453
- return json(res, 415, { ok: false, error: `${nome} não é texto — anexe código, texto ou dados (imagem e vídeo, peça pelo caminho)` });
454
- }
455
- if (!op.anexar) return json(res, 501, { ok: false, error: 'anexo indisponível aqui' });
456
- await op.anexar({ path: nome, content: buf.toString('utf8') });
457
- emitir('estado', estadoAgora());
458
- return json(res, 200, { ok: true, estado: estadoAgora() });
459
- }
460
-
461
- if (rota === '/api/desanexar' && POST) {
462
- const d = await lerJson(req);
463
- if (op.desanexar) await op.desanexar(d && typeof d.nome === 'string' ? d.nome : undefined);
464
- else await op.barra('/detach');
465
- emitir('estado', estadoAgora());
466
- return json(res, 200, { ok: true, estado: estadoAgora() });
467
- }
468
-
469
- if (rota === '/api/voz' && POST) {
470
- const d = await lerJson(req);
471
- const ligar = !!(d && d.ligar);
472
- if (op.voz) await op.voz(ligar);
473
- else await op.barra(ligar ? '/voice' : '/voice parar');
474
- emitir('estado', estadoAgora());
475
- return json(res, 200, { ok: true, estado: estadoAgora() });
476
- }
477
-
478
- if (rota.startsWith('/api/conversa/') && req.method === 'GET') {
479
- const id = decodeURIComponent(rota.slice('/api/conversa/'.length));
480
- const r = await require('./nuvem.js').lerConversa(id).catch((e) => ({ ok: false, error: e.message }));
481
- return json(res, 200, r);
482
- }
483
-
484
- if (painel && await painel.atender(req, res, url)) return;
485
- if (rota.startsWith('/api/')) return json(res, 404, { ok: false, error: 'rota desconhecida' });
486
-
487
- // Estáticos. `basename` corta qualquer "../" antes de virar caminho:
488
- // este servidor fala com o disco do usuário e não pode servir o que
489
- // estiver fora da pasta do aplicativo. /ide é a mesma janela: a IDE
490
- // antiga virou o painel da direita.
491
- const nome = (rota === '/' || rota === '/ide') ? 'index.html' : path.basename(rota);
492
- const arquivo = path.join(WEB, nome);
493
- fs.readFile(arquivo, (erro, dados) => {
494
- if (erro) { res.writeHead(404).end('não encontrado'); return; }
495
- res.writeHead(200, { 'Content-Type': tipoDe(nome), 'Cache-Control': 'no-store' });
496
- res.end(dados);
497
- });
498
- }
499
-
500
- const porta = await escutar(servidor, PORTA_PADRAO);
501
- servidor.__porta = porta;
502
- const url = `http://127.0.0.1:${porta}/?k=${chave}`;
503
- // A mesma janela, abrindo com a prévia à direita — o que o /ide virou.
504
- const urlPainel = `${url}&painel=previa`;
505
-
506
- // `semJanela` é para o teste (e para quem só quer o endereço): sem ele,
507
- // rodar a suíte abriria uma janela de verdade na tela de quem roda.
508
- const janela = op.semJanela ? { modo: 'nenhum' }
509
- : await abrirJanela(op.abrirEm === 'ide' ? urlPainel : url);
510
- return {
511
- url, urlPainel, porta, chave, servidor, janela,
512
- /** Um pedido digitado no TERMINAL, para aparecer como balão na janela. */
513
- pedidoDoTerminal(texto) { if (String(texto || '').trim()) emitirChat('voce', String(texto).trim()); },
514
- fechar() {
515
- try { pararEspelho(); } catch {}
516
- try { fala.eventos.removeListener('evento', aoFalar); } catch {}
517
- try { if (painel) painel.fechar(); } catch {}
518
- try { servidor.close(); } catch {}
519
- // SSE abertos seguram o close() para sempre; a janela é que
520
- // fechou, então as conexões dela também.
521
- for (const r of ouvintes) { try { r.end(); } catch {} }
522
- },
523
- };
524
- }
525
-
526
- /* Porta ocupada não é erro: é outra janela do PrimoCode, ou qualquer coisa
527
- que pegou a 7788 primeiro. Anda dez casas e desiste — se dez portas
528
- seguidas estão tomadas, o problema é outro. */
529
- function escutar(servidor, inicial) {
530
- return new Promise((ok, falha) => {
531
- let porta = inicial;
532
- const tentar = () => {
533
- servidor.once('error', (e) => {
534
- if (e.code === 'EADDRINUSE' && porta < inicial + 10) { porta++; tentar(); }
535
- else falha(e);
536
- });
537
- servidor.listen(porta, '127.0.0.1', () => ok(porta));
538
- };
539
- tentar();
540
- });
541
- }
542
-
543
- /* ── a janela ────────────────────────────────────────────────────────────
544
- `--app=` abre sem barra de endereço, sem abas e com entrada própria na
545
- barra de tarefas: é o que faz parecer aplicativo em vez de site. Chrome,
546
- Edge, Brave e Vivaldi entendem, porque todos são Chromium.
547
-
548
- `--user-data-dir` aponta para uma pasta do PrimoCode de propósito: sem ele,
549
- o Chrome que JÁ ESTÁ ABERTO recebe o pedido e devolve uma aba comum — o
550
- modo app é ignorado quando há um perfil em uso. Com perfil próprio nasce
551
- uma janela de verdade, e o histórico do usuário fica fora disto. */
552
- function candidatosDeNavegador() {
553
- const p = os.platform();
554
- if (p === 'darwin') {
555
- return [
556
- '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
557
- '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
558
- '/Applications/Brave Browser.app/Contents/MacOS/Brave Browser',
559
- '/Applications/Chromium.app/Contents/MacOS/Chromium',
560
- '/Applications/Arc.app/Contents/MacOS/Arc',
561
- ];
562
- }
563
- if (p === 'win32') {
564
- const bases = [process.env['PROGRAMFILES'], process.env['PROGRAMFILES(X86)'],
565
- process.env.LOCALAPPDATA].filter(Boolean);
566
- const relativos = [
567
- 'Google\\Chrome\\Application\\chrome.exe',
568
- 'Microsoft\\Edge\\Application\\msedge.exe',
569
- 'BraveSoftware\\Brave-Browser\\Application\\brave.exe',
570
- ];
571
- const fora = [];
572
- for (const b of bases) for (const r of relativos) fora.push(path.join(b, r));
573
- return fora;
574
- }
575
- return ['/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium',
576
- '/usr/bin/chromium-browser', '/usr/bin/microsoft-edge', '/usr/bin/brave-browser'];
577
- }
578
-
579
- function abrirJanela(url) {
580
- const perfil = path.join(os.homedir(), '.primocode', 'janela');
581
- try { fs.mkdirSync(perfil, { recursive: true }); } catch {}
582
-
583
- for (const exe of candidatosDeNavegador()) {
584
- if (!fs.existsSync(exe)) continue;
585
- try {
586
- const filho = execFile(exe, [
587
- `--app=${url}`,
588
- `--user-data-dir=${perfil}`,
589
- // Sem Keychain: ver SEM_KEYCHAIN em lib/browser.js.
590
- ...require('./browser.js').SEM_KEYCHAIN,
591
- '--window-size=1180,820',
592
- '--no-first-run',
593
- '--no-default-browser-check',
594
- ], () => {});
595
- // A janela é do usuário: se o PrimoCode do terminal terminar, ela
596
- // continua de pé.
597
- filho.unref();
598
- return Promise.resolve({ modo: 'janela', navegador: path.basename(exe) });
599
- } catch { /* tenta o próximo */ }
600
- }
601
-
602
- // Nenhum Chromium instalado: aba comum no navegador padrão. Funciona
603
- // igual — só não fica com cara de aplicativo.
604
- const p = os.platform();
605
- const [cmd, args] = p === 'darwin' ? ['open', [url]]
606
- : p === 'win32' ? ['cmd.exe', ['/c', 'start', '', url]]
607
- : ['xdg-open', [url]];
608
- return new Promise((ok) => {
609
- execFile(cmd, args, { timeout: 10000 }, (erro) => {
610
- ok(erro ? { modo: 'nenhum', erro: erro.message } : { modo: 'aba' });
611
- });
612
- });
613
- }
614
-
615
- module.exports = { abrir, abrirJanela, espelhar, ehRuido, candidatosDeNavegador, recentesLocais, MAX_ANEXO };