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.
- package/README.md +30 -6
- package/bin/primocode.js +100 -341
- package/lib/act.js +245 -23
- package/lib/browser.js +17 -0
- package/lib/casar.js +108 -0
- package/lib/catalogo.js +6 -2
- package/lib/config.js +10 -3
- package/lib/cursor.js +10 -1
- package/lib/entrada.js +4 -2
- package/lib/estudio/diretor.js +9 -1
- package/lib/estudio/ende.js +2 -0
- package/lib/estudio/estilo.js +51 -0
- package/lib/estudio/glifos.js +215 -0
- package/lib/estudio/index.js +63 -11
- package/lib/estudio/kit/base.jsx +285 -0
- package/lib/estudio/kit/index.jsx +11 -0
- package/lib/estudio/kit/marca.jsx +741 -0
- package/lib/estudio/kit/vox.jsx +357 -0
- package/lib/estudio/kit.js +72 -0
- package/lib/estudio/lancamento.js +413 -0
- package/lib/estudio/morfar.js +191 -0
- package/lib/estudio/motion.js +333 -0
- package/lib/estudio/plano.js +54 -7
- package/lib/estudio/remotion.js +167 -52
- package/lib/estudio/ritmo.js +77 -0
- package/lib/estudio/sons-leves.js +62 -0
- package/lib/estudio/vox.js +385 -0
- package/lib/fala.js +1 -94
- package/lib/pipeline.js +55 -56
- package/lib/repeticao.js +3 -1
- package/lib/skills.js +8 -2
- package/lib/snapshot.js +111 -0
- package/lib/tools.js +79 -20
- package/lib/ui.js +13 -6
- package/lib/video/analise.js +89 -0
- package/lib/video/cli.js +74 -0
- package/lib/video/delta.js +90 -0
- package/lib/video/edits.js +379 -0
- package/lib/video/filters.js +247 -0
- package/lib/video/ops.js +322 -0
- package/lib/video/projeto.js +133 -0
- package/lib/video/render.js +221 -0
- package/lib/video/texto.js +142 -0
- package/lib/video/timeline.js +310 -0
- package/lib/video/transcript.js +124 -0
- package/lib/voz.js +75 -223
- package/package.json +2 -3
- package/skills/motion-de-primeira/SKILL.md +21 -1
- package/studio/texto.py +208 -0
- package/voz/primo_voz/__main__.py +96 -236
- package/voz/primo_voz/transcrever.py +31 -1
- package/app/ansi.js +0 -55
- package/app/app.css +0 -500
- package/app/app.js +0 -1017
- package/app/icone.svg +0 -6
- package/app/index.html +0 -220
- package/app/primo-marca.js +0 -192
- package/lib/app.js +0 -615
- package/lib/icone.js +0 -263
- package/lib/instalar-app.js +0 -231
- package/lib/painel.js +0 -291
- package/voz/primo_voz/desperta.py +0 -195
- 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 };
|