primocode 9.8.0-beta.7 → 9.8.0-beta.9

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/lib/app.js CHANGED
@@ -23,14 +23,18 @@
23
23
  * navegador em modo app. Se não houver nenhum navegador de Chromium, abre
24
24
  * numa aba normal, que continua funcionando.
25
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.
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").
30
32
  *
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.
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.
34
38
  *
35
39
  * ── SEGURANÇA ────────────────────────────────────────────────────────────
36
40
  * Este servidor MANDA NO AGENTE: quem fala com ele executa comando, escreve
@@ -47,6 +51,11 @@ const os = require('os');
47
51
  const path = require('path');
48
52
  const crypto = require('crypto');
49
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');
50
59
 
51
60
  const WEB = path.join(__dirname, '..', 'app');
52
61
  const PORTA_PADRAO = Number(process.env.PRIMOCODE_APP_PORT) || 7788;
@@ -89,17 +98,86 @@ function tipoDe(arquivo) {
89
98
  return 'application/octet-stream';
90
99
  }
91
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
+
92
155
  /**
93
156
  * Sobe a janela do PrimoCode.
94
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
+ *
95
164
  * @param {object} op
96
165
  * @param {(texto:string)=>Promise} op.enviar manda um pedido ao agente
97
166
  * @param {(texto:string)=>Promise} op.barra trata um comando /barra
98
167
  * @param {()=>void} op.interromper para o que estiver rodando
99
- * @param {()=>object} op.estado { pasta, modo, esforco, projeto }
168
+ * @param {()=>object} op.estado { pasta, projeto, modo, esforco, permissoes, voz, usuario, mascote, anexos }
100
169
  * @param {string} op.versao
101
- * @param {()=>string} [op.pastaProjeto] liga a IDE (/ide) nesta pasta
102
- * @param {'rc'|'ide'} [op.abrirEm] qual tela a janela abre
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
103
181
  * @param {boolean} [op.semJanela] só o servidor, sem abrir navegador
104
182
  */
105
183
  async function abrir(op) {
@@ -108,10 +186,15 @@ async function abrir(op) {
108
186
  let ocupado = false;
109
187
  const fila = [];
110
188
 
111
- // O histórico de saída para quem abrir a janela DEPOIS de o agente já ter
112
- // começado — sem isto a página nasceria vazia no meio de uma tarefa.
189
+ // O histórico do TERMINAL para quem abrir a janela depois — é o que o
190
+ // botão "Terminal" mostra.
113
191
  const memoria = [];
114
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;
115
198
 
116
199
  function emitir(evento, dados) {
117
200
  const linha = `event: ${evento}\ndata: ${JSON.stringify(dados)}\n\n`;
@@ -119,15 +202,27 @@ async function abrir(op) {
119
202
  try { res.write(linha); } catch { ouvintes.delete(res); }
120
203
  }
121
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());
122
211
 
123
- /* A IDE (lib/ide.js) mora neste mesmo servidor e fala pelo mesmo fluxo:
124
- "um app ao invés de CLI, aí fica uma IDE e uma CLI". Só sobe quando
125
- quem abriu diz qual é a pasta do projeto. */
126
- const ide = op.pastaProjeto
127
- ? require('./ide.js').montar({ pasta: op.pastaProjeto, emitir: (e, d) => emitir(e, d) })
212
+ const painel = op.pastaProjeto
213
+ ? require('./painel.js').montar({ pasta: op.pastaProjeto, emitir: (e, d) => emitir(e, d) })
128
214
  : null;
129
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;
130
224
  const pararEspelho = espelhar((pedaco) => {
225
+ if (capturando) capturando.push(pedaco);
131
226
  if (ehRuido(pedaco)) return;
132
227
  memoria.push(pedaco);
133
228
  if (memoria.length > MEMORIA_MAX) memoria.shift();
@@ -142,28 +237,31 @@ async function abrir(op) {
142
237
  while (fila.length) {
143
238
  const t = String(fila.shift() || '').trim();
144
239
  if (!t) continue;
145
- emitir('voce', t);
240
+ emitirChat('voce', t);
241
+ falouNesta = false;
242
+ capturando = [];
146
243
  try {
147
244
  if (t.startsWith('/')) await op.barra(t);
148
245
  else await op.enviar(t);
149
246
  } catch (e) {
150
- emitir('erro', (e && e.message) || String(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));
151
253
  }
152
254
  }
153
255
  ocupado = false;
154
256
  emitir('ocupado', false);
155
- emitir('estado', op.estado());
257
+ emitir('estado', estadoAgora());
156
258
  }
157
259
 
158
- // `async` por causa das rotas de histórico, que esperam a nuvem. Uma
159
- // exceção aqui viraria unhandledRejection e derrubaria o CLI inteiro
160
- // junto com a janela, então o corpo fica dentro de um try/catch.
161
260
  const servidor = http.createServer((req, res) => {
162
261
  atender(req, res).catch((e) => {
163
262
  // Uma exceção solta aqui vira unhandledRejection e derruba o CLI
164
263
  // INTEIRO junto com a janela — o trabalho da pessoa some porque
165
- // uma listagem de conversa tropeçou. Aqui ela vira um 500 e a
166
- // janela segue de pé.
264
+ // uma listagem tropeçou. Aqui ela vira um 500 e a janela segue.
167
265
  try {
168
266
  if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' });
169
267
  res.end(JSON.stringify({ ok: false, error: e.message }));
@@ -171,40 +269,39 @@ async function abrir(op) {
171
269
  });
172
270
  });
173
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
+
174
277
  async function atender(req, res) {
175
278
  const url = new URL(req.url, 'http://127.0.0.1');
176
279
  const rota = url.pathname;
280
+ const POST = req.method === 'POST';
177
281
 
178
282
  /* A chave protege a API, que é onde está tudo o que importa: a conversa
179
283
  do usuário e a capacidade de dar ordens ao agente. A página em si —
180
284
  HTML, CSS, JS — não guarda segredo nenhum e não faz nada sem a
181
- chave, então ela é servida solta.
182
-
183
- Não é frouxidão, é o contrário: exigir chave nos estáticos também
184
- quebrava a própria janela, porque o navegador pede /app.css e
185
- /app.js sem parâmetro nenhum. A página vinha, ficava sem estilo e
186
- sem script, e o "app bonito" era um formulário branco. */
285
+ chave, então ela é servida solta (exigir chave nos estáticos
286
+ quebrava a janela: o navegador pede /app.css sem parâmetro). */
187
287
  if (rota.startsWith('/api/')) {
188
- // A prévia da IDE leva a chave no CAMINHO (ver lib/ide.js): os
288
+ // A prévia leva a chave no CAMINHO (ver lib/painel.js): os
189
289
  // arquivos que a página do projeto pede por endereço relativo não
190
290
  // carregam query nenhuma.
191
- const naPrevia = rota.match(/^\/api\/ide\/previa\/([0-9a-f]+)/);
291
+ const naPrevia = rota.match(/^\/api\/previa\/([0-9a-f]+)/);
192
292
  const dada = (naPrevia && naPrevia[1]) || url.searchParams.get('k') || req.headers['x-primo-chave'];
193
293
  if (dada !== chave) {
194
294
  res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
195
- return res.end('chave inválida — abra a janela com o comando /app');
295
+ return res.end('chave inválida — abra a janela com o comando /rc');
196
296
  }
197
297
  }
198
298
  /* CSRF: uma página de terceiro consegue disparar POST simples para
199
299
  127.0.0.1 sem preflight. Ela não saberia a chave, mas o cinto e o
200
- suspensório custam três linhas. */
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. */
201
303
  const origem = req.headers.origin;
202
- /* A exceção é a prévia da IDE: ela roda num iframe sandbox de origem
203
- opaca, e o navegador manda `Origin: null` até num <script
204
- type="module">. Só GET e só nas rotas da prévia, que apenas LEEM o
205
- projeto — nenhuma delas dá ordem ao agente. */
206
- const previaOpaca = origem === 'null' && req.method === 'GET'
207
- && rota.startsWith('/api/ide/previa/');
304
+ const previaOpaca = origem === 'null' && req.method === 'GET' && rota.startsWith('/api/previa/');
208
305
  if (origem && !previaOpaca && !origem.startsWith(`http://127.0.0.1:${servidor.__porta}`)
209
306
  && !origem.startsWith(`http://localhost:${servidor.__porta}`)) {
210
307
  res.writeHead(403).end();
@@ -221,74 +318,181 @@ async function abrir(op) {
221
318
  });
222
319
  res.write(': ligado\n\n');
223
320
  ouvintes.add(res);
224
- res.write(`event: estado\ndata: ${JSON.stringify(op.estado())}\n\n`);
225
- for (const pedaco of memoria) {
226
- res.write(`event: saida\ndata: ${JSON.stringify(pedaco)}\n\n`);
227
- }
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`);
228
324
  res.write(`event: ocupado\ndata: ${JSON.stringify(ocupado)}\n\n`);
229
- // Batida a cada 20s: proxy e sistema derrubam conexão parada, e
230
- // uma janela que "morre sozinha depois de um tempo" seria um bug
231
- // impossível de achar depois.
325
+ // Batida a cada 20s: proxy e sistema derrubam conexão parada.
232
326
  const batida = setInterval(() => { try { res.write(': ok\n\n'); } catch {} }, 20000);
233
327
  req.on('close', () => { clearInterval(batida); ouvintes.delete(res); });
234
328
  return;
235
329
  }
236
330
 
237
- if (rota === '/api/enviar' && req.method === 'POST') {
238
- let corpo = '';
239
- req.on('data', (c) => { corpo += c; if (corpo.length > 200000) req.destroy(); });
240
- req.on('end', () => {
241
- let texto = '';
242
- try { texto = JSON.parse(corpo).texto || ''; } catch { /* vazio */ }
243
- res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
244
- if (texto.trim()) processar(texto);
245
- });
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);
246
336
  return;
247
337
  }
248
338
 
249
- if (rota === '/api/parar' && req.method === 'POST') {
339
+ if (rota === '/api/parar' && POST) {
250
340
  try { op.interromper(); } catch {}
251
- res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
252
- return;
341
+ return json(res, 200, { ok: true });
253
342
  }
254
343
 
255
- /* O HISTÓRICO da janela vem do MESMO lugar que o do terminal:
256
- lib/nuvem.js, coleção `primocode`, cifrado. A janela não fala com o
257
- Firestore por conta própria — ela pergunta ao servidor dela, que já
258
- tem a sessão e a cifra. Um segundo caminho até o banco significaria
259
- uma segunda implementação da cifra, e a segunda divergiria da
260
- primeira sem ninguém ver. */
261
- if (rota === '/api/conversas') {
262
- const nuvem = require('./nuvem.js');
263
- const r = await nuvem.listarConversas({ limite: 40 })
264
- .catch((e) => ({ ok: false, error: e.message }));
265
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
266
- return res.end(JSON.stringify(r));
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 });
267
387
  }
268
388
 
269
- if (rota.startsWith('/api/conversa/')) {
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') {
270
394
  const nuvem = require('./nuvem.js');
271
- const id = decodeURIComponent(rota.slice('/api/conversa/'.length));
272
- const r = await nuvem.lerConversa(id).catch((e) => ({ ok: false, error: e.message }));
273
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
274
- return res.end(JSON.stringify(r));
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) });
275
401
  }
276
402
 
277
- if (ide && await ide.atender(req, res, url)) return;
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
+ }
278
430
 
279
- if (rota === '/api/estado') {
280
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
281
- return res.end(JSON.stringify(Object.assign({ versao: op.versao }, op.estado())));
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() });
282
439
  }
283
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
+
284
487
  // Estáticos. `basename` corta qualquer "../" antes de virar caminho:
285
488
  // este servidor fala com o disco do usuário e não pode servir o que
286
- // estiver fora da pasta do aplicativo.
287
- const nome = rota === '/' ? 'index.html' : rota === '/ide' ? 'ide.html' : path.basename(rota);
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);
288
492
  const arquivo = path.join(WEB, nome);
289
493
  fs.readFile(arquivo, (erro, dados) => {
290
494
  if (erro) { res.writeHead(404).end('não encontrado'); return; }
291
- res.writeHead(200, { 'Content-Type': tipoDe(nome) });
495
+ res.writeHead(200, { 'Content-Type': tipoDe(nome), 'Cache-Control': 'no-store' });
292
496
  res.end(dados);
293
497
  });
294
498
  }
@@ -296,17 +500,21 @@ async function abrir(op) {
296
500
  const porta = await escutar(servidor, PORTA_PADRAO);
297
501
  servidor.__porta = porta;
298
502
  const url = `http://127.0.0.1:${porta}/?k=${chave}`;
299
- const urlIde = `http://127.0.0.1:${porta}/ide?k=${chave}`;
503
+ // A mesma janela, abrindo com a prévia à direita — o que o /ide virou.
504
+ const urlPainel = `${url}&painel=previa`;
300
505
 
301
506
  // `semJanela` é para o teste (e para quem só quer o endereço): sem ele,
302
507
  // rodar a suíte abriria uma janela de verdade na tela de quem roda.
303
508
  const janela = op.semJanela ? { modo: 'nenhum' }
304
- : await abrirJanela(op.abrirEm === 'ide' && ide ? urlIde : url);
509
+ : await abrirJanela(op.abrirEm === 'ide' ? urlPainel : url);
305
510
  return {
306
- url, urlIde: ide ? urlIde : null, porta, chave, servidor, janela,
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()); },
307
514
  fechar() {
308
515
  try { pararEspelho(); } catch {}
309
- try { if (ide) ide.fechar(); } catch {}
516
+ try { fala.eventos.removeListener('evento', aoFalar); } catch {}
517
+ try { if (painel) painel.fechar(); } catch {}
310
518
  try { servidor.close(); } catch {}
311
519
  // SSE abertos seguram o close() para sempre; a janela é que
312
520
  // fechou, então as conexões dela também.
@@ -404,4 +612,4 @@ function abrirJanela(url) {
404
612
  });
405
613
  }
406
614
 
407
- module.exports = { abrir, abrirJanela, espelhar, ehRuido, candidatosDeNavegador };
615
+ module.exports = { abrir, abrirJanela, espelhar, ehRuido, candidatosDeNavegador, recentesLocais, MAX_ANEXO };