primocode 9.8.0-beta.2 → 9.8.0-beta.21

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 (74) hide show
  1. package/README.md +33 -6
  2. package/app/ansi.js +55 -0
  3. package/app/app.css +482 -281
  4. package/app/app.js +888 -315
  5. package/app/index.html +176 -101
  6. package/app/primo-marca.js +58 -1
  7. package/bin/primocode.js +697 -118
  8. package/comandos/explicar.md +14 -0
  9. package/comandos/publicar.md +10 -0
  10. package/comandos/revisar.md +13 -0
  11. package/lib/act.js +130 -26
  12. package/lib/api.js +8 -1
  13. package/lib/app.js +317 -74
  14. package/lib/browser.js +79 -3
  15. package/lib/catalogo.js +11 -3
  16. package/lib/checkpoint-git.js +218 -0
  17. package/lib/checkpoint.js +249 -0
  18. package/lib/claude-engine.js +25 -3
  19. package/lib/comandos-extra.js +481 -0
  20. package/lib/comandos.js +104 -0
  21. package/lib/config.js +21 -4
  22. package/lib/estudio/acervo-site.js +157 -0
  23. package/lib/estudio/assets.js +77 -3
  24. package/lib/estudio/cinema.js +354 -0
  25. package/lib/estudio/composicao.js +470 -0
  26. package/lib/estudio/edicao.js +450 -0
  27. package/lib/estudio/impacto.js +263 -0
  28. package/lib/estudio/index.js +769 -5
  29. package/lib/estudio/plano.js +5 -2
  30. package/lib/estudio/remotion.js +228 -23
  31. package/lib/estudio/slides.js +34 -30
  32. package/lib/fala.js +95 -1
  33. package/lib/local/cerebro.json +361 -180
  34. package/lib/local/entrada.js +18 -8
  35. package/lib/local/motor-cli.js +1 -1
  36. package/lib/mencoes.js +98 -0
  37. package/lib/offline.js +168 -0
  38. package/lib/painel.js +291 -0
  39. package/lib/perigo.js +256 -0
  40. package/lib/pre-leitura.js +118 -0
  41. package/lib/repeticao.js +4 -2
  42. package/lib/skills.js +6 -1
  43. package/lib/tempos.js +62 -0
  44. package/lib/ui.js +171 -17
  45. package/package.json +3 -2
  46. package/skills/apresentacao-pptx/SKILL.md +52 -0
  47. package/skills/cartaz-de-arte/LICENSE.txt +202 -0
  48. package/skills/cartaz-de-arte/SKILL.md +47 -0
  49. package/skills/criar-skill/LICENSE.txt +202 -0
  50. package/skills/criar-skill/SKILL.md +49 -0
  51. package/skills/depurar-de-verdade/LICENSE.txt +21 -0
  52. package/skills/depurar-de-verdade/SKILL.md +14 -5
  53. package/skills/design-de-verdade/LICENSE.txt +177 -0
  54. package/skills/design-de-verdade/SKILL.md +42 -0
  55. package/skills/documento-word/SKILL.md +57 -0
  56. package/skills/interface-revisada/SKILL.md +47 -0
  57. package/skills/me-ensina/LICENSE.txt +21 -0
  58. package/skills/me-ensina/SKILL.md +27 -0
  59. package/skills/me-entrevista/LICENSE.txt +21 -0
  60. package/skills/me-entrevista/SKILL.md +27 -0
  61. package/skills/motion-de-primeira/SKILL.md +1 -1
  62. package/skills/pdf-bonito/SKILL.md +48 -0
  63. package/skills/planilha-excel/SKILL.md +47 -0
  64. package/skills/react-sem-dor/SKILL.md +48 -0
  65. package/skills/revisao-antes-de-entregar/LICENSE.txt +21 -0
  66. package/skills/revisao-antes-de-entregar/SKILL.md +6 -1
  67. package/skills/site-com-motion/SKILL.md +39 -0
  68. package/skills/temas-prontos/LICENSE.txt +202 -0
  69. package/skills/temas-prontos/SKILL.md +50 -0
  70. package/skills/testar-o-site/LICENSE.txt +202 -0
  71. package/skills/testar-o-site/SKILL.md +46 -0
  72. package/skills/video-no-remotion/SKILL.md +48 -0
  73. package/studio/narrar.py +111 -10
  74. package/studio/rostos.py +52 -0
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,15 +98,87 @@ 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
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
101
182
  */
102
183
  async function abrir(op) {
103
184
  const chave = crypto.randomBytes(16).toString('hex');
@@ -105,10 +186,15 @@ async function abrir(op) {
105
186
  let ocupado = false;
106
187
  const fila = [];
107
188
 
108
- // O histórico de saída para quem abrir a janela DEPOIS de o agente já ter
109
- // começado — sem isto a página nasceria vazia no meio de uma tarefa.
189
+ // O histórico do TERMINAL para quem abrir a janela depois — é o que o
190
+ // botão "Terminal" mostra.
110
191
  const memoria = [];
111
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;
112
198
 
113
199
  function emitir(evento, dados) {
114
200
  const linha = `event: ${evento}\ndata: ${JSON.stringify(dados)}\n\n`;
@@ -116,8 +202,27 @@ async function abrir(op) {
116
202
  try { res.write(linha); } catch { ouvintes.delete(res); }
117
203
  }
118
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);
119
222
 
223
+ let capturando = null;
120
224
  const pararEspelho = espelhar((pedaco) => {
225
+ if (capturando) capturando.push(pedaco);
121
226
  if (ehRuido(pedaco)) return;
122
227
  memoria.push(pedaco);
123
228
  if (memoria.length > MEMORIA_MAX) memoria.shift();
@@ -132,28 +237,31 @@ async function abrir(op) {
132
237
  while (fila.length) {
133
238
  const t = String(fila.shift() || '').trim();
134
239
  if (!t) continue;
135
- emitir('voce', t);
240
+ emitirChat('voce', t);
241
+ falouNesta = false;
242
+ capturando = [];
136
243
  try {
137
244
  if (t.startsWith('/')) await op.barra(t);
138
245
  else await op.enviar(t);
139
246
  } catch (e) {
140
- 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));
141
253
  }
142
254
  }
143
255
  ocupado = false;
144
256
  emitir('ocupado', false);
145
- emitir('estado', op.estado());
257
+ emitir('estado', estadoAgora());
146
258
  }
147
259
 
148
- // `async` por causa das rotas de histórico, que esperam a nuvem. Uma
149
- // exceção aqui viraria unhandledRejection e derrubaria o CLI inteiro
150
- // junto com a janela, então o corpo fica dentro de um try/catch.
151
260
  const servidor = http.createServer((req, res) => {
152
261
  atender(req, res).catch((e) => {
153
262
  // Uma exceção solta aqui vira unhandledRejection e derruba o CLI
154
263
  // INTEIRO junto com a janela — o trabalho da pessoa some porque
155
- // uma listagem de conversa tropeçou. Aqui ela vira um 500 e a
156
- // janela segue de pé.
264
+ // uma listagem tropeçou. Aqui ela vira um 500 e a janela segue.
157
265
  try {
158
266
  if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' });
159
267
  res.end(JSON.stringify({ ok: false, error: e.message }));
@@ -161,31 +269,40 @@ async function abrir(op) {
161
269
  });
162
270
  });
163
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
+
164
277
  async function atender(req, res) {
165
278
  const url = new URL(req.url, 'http://127.0.0.1');
166
279
  const rota = url.pathname;
280
+ const POST = req.method === 'POST';
167
281
 
168
282
  /* A chave protege a API, que é onde está tudo o que importa: a conversa
169
283
  do usuário e a capacidade de dar ordens ao agente. A página em si —
170
284
  HTML, CSS, JS — não guarda segredo nenhum e não faz nada sem a
171
- chave, então ela é servida solta.
172
-
173
- Não é frouxidão, é o contrário: exigir chave nos estáticos também
174
- quebrava a própria janela, porque o navegador pede /app.css e
175
- /app.js sem parâmetro nenhum. A página vinha, ficava sem estilo e
176
- 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). */
177
287
  if (rota.startsWith('/api/')) {
178
- const dada = url.searchParams.get('k') || req.headers['x-primo-chave'];
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'];
179
293
  if (dada !== chave) {
180
294
  res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
181
- 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');
182
296
  }
183
297
  }
184
298
  /* CSRF: uma página de terceiro consegue disparar POST simples para
185
299
  127.0.0.1 sem preflight. Ela não saberia a chave, mas o cinto e o
186
- 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. */
187
303
  const origem = req.headers.origin;
188
- if (origem && !origem.startsWith(`http://127.0.0.1:${servidor.__porta}`)
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}`)
189
306
  && !origem.startsWith(`http://localhost:${servidor.__porta}`)) {
190
307
  res.writeHead(403).end();
191
308
  return;
@@ -201,72 +318,181 @@ async function abrir(op) {
201
318
  });
202
319
  res.write(': ligado\n\n');
203
320
  ouvintes.add(res);
204
- res.write(`event: estado\ndata: ${JSON.stringify(op.estado())}\n\n`);
205
- for (const pedaco of memoria) {
206
- res.write(`event: saida\ndata: ${JSON.stringify(pedaco)}\n\n`);
207
- }
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`);
208
324
  res.write(`event: ocupado\ndata: ${JSON.stringify(ocupado)}\n\n`);
209
- // Batida a cada 20s: proxy e sistema derrubam conexão parada, e
210
- // uma janela que "morre sozinha depois de um tempo" seria um bug
211
- // impossível de achar depois.
325
+ // Batida a cada 20s: proxy e sistema derrubam conexão parada.
212
326
  const batida = setInterval(() => { try { res.write(': ok\n\n'); } catch {} }, 20000);
213
327
  req.on('close', () => { clearInterval(batida); ouvintes.delete(res); });
214
328
  return;
215
329
  }
216
330
 
217
- if (rota === '/api/enviar' && req.method === 'POST') {
218
- let corpo = '';
219
- req.on('data', (c) => { corpo += c; if (corpo.length > 200000) req.destroy(); });
220
- req.on('end', () => {
221
- let texto = '';
222
- try { texto = JSON.parse(corpo).texto || ''; } catch { /* vazio */ }
223
- res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
224
- if (texto.trim()) processar(texto);
225
- });
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);
226
336
  return;
227
337
  }
228
338
 
229
- if (rota === '/api/parar' && req.method === 'POST') {
339
+ if (rota === '/api/parar' && POST) {
230
340
  try { op.interromper(); } catch {}
231
- res.writeHead(200, { 'Content-Type': 'application/json' }).end('{"ok":true}');
232
- return;
341
+ return json(res, 200, { ok: true });
233
342
  }
234
343
 
235
- /* O HISTÓRICO da janela vem do MESMO lugar que o do terminal:
236
- lib/nuvem.js, coleção `primocode`, cifrado. A janela não fala com o
237
- Firestore por conta própria — ela pergunta ao servidor dela, que já
238
- tem a sessão e a cifra. Um segundo caminho até o banco significaria
239
- uma segunda implementação da cifra, e a segunda divergiria da
240
- primeira sem ninguém ver. */
241
- if (rota === '/api/conversas') {
242
- const nuvem = require('./nuvem.js');
243
- const r = await nuvem.listarConversas({ limite: 40 })
244
- .catch((e) => ({ ok: false, error: e.message }));
245
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
246
- 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 });
247
387
  }
248
388
 
249
- 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') {
250
394
  const nuvem = require('./nuvem.js');
251
- const id = decodeURIComponent(rota.slice('/api/conversa/'.length));
252
- const r = await nuvem.lerConversa(id).catch((e) => ({ ok: false, error: e.message }));
253
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
254
- 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) });
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() });
255
459
  }
256
460
 
257
- if (rota === '/api/estado') {
258
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
259
- return res.end(JSON.stringify(Object.assign({ versao: op.versao }, op.estado())));
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() });
260
467
  }
261
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
+
262
487
  // Estáticos. `basename` corta qualquer "../" antes de virar caminho:
263
488
  // este servidor fala com o disco do usuário e não pode servir o que
264
- // estiver fora da pasta do aplicativo.
265
- const nome = rota === '/' ? 'index.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);
266
492
  const arquivo = path.join(WEB, nome);
267
493
  fs.readFile(arquivo, (erro, dados) => {
268
494
  if (erro) { res.writeHead(404).end('não encontrado'); return; }
269
- res.writeHead(200, { 'Content-Type': tipoDe(nome) });
495
+ res.writeHead(200, { 'Content-Type': tipoDe(nome), 'Cache-Control': 'no-store' });
270
496
  res.end(dados);
271
497
  });
272
498
  }
@@ -274,11 +500,26 @@ async function abrir(op) {
274
500
  const porta = await escutar(servidor, PORTA_PADRAO);
275
501
  servidor.__porta = porta;
276
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`;
277
505
 
278
- const janela = await abrirJanela(url);
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);
279
510
  return {
280
- url, porta, chave, servidor, janela,
281
- fechar() { try { pararEspelho(); } catch {} try { servidor.close(); } catch {} },
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
+ },
282
523
  };
283
524
  }
284
525
 
@@ -345,6 +586,8 @@ function abrirJanela(url) {
345
586
  const filho = execFile(exe, [
346
587
  `--app=${url}`,
347
588
  `--user-data-dir=${perfil}`,
589
+ // Sem Keychain: ver SEM_KEYCHAIN em lib/browser.js.
590
+ ...require('./browser.js').SEM_KEYCHAIN,
348
591
  '--window-size=1180,820',
349
592
  '--no-first-run',
350
593
  '--no-default-browser-check',
@@ -369,4 +612,4 @@ function abrirJanela(url) {
369
612
  });
370
613
  }
371
614
 
372
- module.exports = { abrir, abrirJanela, espelhar, ehRuido, candidatosDeNavegador };
615
+ module.exports = { abrir, abrirJanela, espelhar, ehRuido, candidatosDeNavegador, recentesLocais, MAX_ANEXO };