primocode 8.32.0 → 8.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/index.html CHANGED
@@ -1,5 +1,5 @@
1
1
  <!doctype html>
2
- <html lang="pt-BR">
2
+ <html lang="pt-BR" data-tema="escuro">
3
3
  <head>
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -7,62 +7,139 @@
7
7
  <link rel="stylesheet" href="/app.css">
8
8
  <link rel="icon" href="/icone.svg">
9
9
  </head>
10
+ <!--
11
+ A janela do PrimoCode (/rc).
12
+
13
+ É a MESMA interface da página /code do Conecta Primo — de propósito. Quem
14
+ usa o terminal, abre a janela e depois entra pelo navegador tem de
15
+ reconhecer onde está no primeiro olhar: mesma barra lateral, mesmo botão de
16
+ novo chat, mesmo prompt `❯`, mesmo mascote na tela vazia.
17
+
18
+ O que muda é de onde vem o conteúdo: aqui o histórico vem do servidor local
19
+ (/api/conversas), que usa o lib/nuvem.js — a mesma cifra e a mesma coleção
20
+ que a web lê direto do Firestore.
21
+ -->
10
22
  <body>
11
23
 
12
- <header id="topo">
13
- <div class="marca">
14
- <svg class="logo" viewBox="0 0 24 24" aria-hidden="true">
15
- <path d="M4 6.5 9 12l-5 5.5" fill="none" stroke="currentColor" stroke-width="2.1"
16
- stroke-linecap="round" stroke-linejoin="round"/>
17
- <path d="M12.5 18h7.5" fill="none" stroke="currentColor" stroke-width="2.1"
18
- stroke-linecap="round"/>
19
- </svg>
20
- <span class="nome">PrimoCode</span>
21
- <span class="versao" id="versao"></span>
22
- </div>
23
- <div class="estado">
24
- <span class="chip" id="pasta" title="pasta de trabalho"></span>
25
- <span class="chip" id="modo"></span>
26
- <span class="chip" id="esforco"></span>
27
- </div>
28
- </header>
24
+ <div class="tela">
25
+ <aside class="lado" id="lado" aria-label="Conversas e ajustes">
26
+ <div class="lado-topo">
27
+ <div class="marca-linha">
28
+ <span id="marcaTopo"></span>
29
+ <span class="nome">PrimoCode</span>
30
+ <span class="versao" id="versao"></span>
31
+ </div>
32
+ <button class="novo" id="btnNovo">
33
+ <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
34
+ <path d="M12 5v14M5 12h14" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"/>
35
+ </svg>
36
+ Novo chat
37
+ </button>
38
+ </div>
29
39
 
30
- <main id="rolo">
31
- <div id="conversa">
32
- <div class="boas-vindas">
33
- <h1>Oi. O que a gente vai construir?</h1>
34
- <p>Escreva do jeito que você falaria. Ele lê a pasta, escreve o código,
35
- roda o que precisar e conta o que fez.</p>
36
- <div class="atalhos">
37
- <button data-manda="/tudo">o que ele sabe fazer</button>
38
- <button data-manda="/project">meus projetos</button>
39
- <button data-manda="/effort">esforço</button>
40
- <button data-manda="/help">comandos</button>
40
+ <div class="rotulo">Conversas</div>
41
+ <div class="recentes" id="recentes">
42
+ <div class="vazio-lista" id="listaVazia">
43
+ As conversas aparecem aqui quando você entra na sua conta com <code>/conectar</code>.
41
44
  </div>
42
45
  </div>
43
- </div>
44
- </main>
45
46
 
46
- <footer id="baixo">
47
- <div id="pensando" hidden><span class="pulso"></span><span id="pensando-txt">trabalhando</span></div>
48
- <form id="forma">
49
- <textarea id="campo" rows="1" placeholder="Peça alguma coisa… (Enter manda, Shift+Enter quebra linha)"
50
- autocomplete="off" spellcheck="false"></textarea>
51
- <button type="submit" id="mandar" title="Enviar (Enter)" aria-label="Enviar">
52
- <svg viewBox="0 0 24 24" aria-hidden="true">
53
- <path d="M4 12h13m0 0-5-5m5 5-5 5" fill="none" stroke="currentColor"
54
- stroke-width="2.1" stroke-linecap="round" stroke-linejoin="round"/>
55
- </svg>
56
- </button>
57
- <button type="button" id="parar" title="Parar (Esc)" hidden aria-label="Parar">
58
- <svg viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg>
59
- </button>
60
- </form>
61
- <div class="dica" id="dica">arraste um vídeo ou arquivo aqui para o PrimoCode usar</div>
62
- </footer>
47
+ <div class="lado-pe">
48
+ <button class="pe-btn" id="btnConfig">
49
+ <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true">
50
+ <circle cx="12" cy="12" r="3.1" fill="none" stroke="currentColor" stroke-width="1.9"/>
51
+ <path d="M12 2.8v2.4M12 18.8v2.4M21.2 12h-2.4M5.2 12H2.8M18.5 5.5l-1.7 1.7M7.2 16.8l-1.7 1.7M18.5 18.5l-1.7-1.7M7.2 7.2 5.5 5.5"
52
+ fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"/>
53
+ </svg>
54
+ Configurações
55
+ </button>
56
+ <div class="chips">
57
+ <span class="chip" id="pasta" title="pasta de trabalho"></span>
58
+ <span class="chip" id="modo"></span>
59
+ <span class="chip" id="esforco"></span>
60
+ </div>
61
+ </div>
62
+ </aside>
63
+
64
+ <main class="conversa">
65
+ <div class="rolo" id="rolo">
66
+ <div class="vazio" id="vazio">
67
+ <span id="marcaGrande"></span>
68
+ <h1>O que a gente vai construir?</h1>
69
+ <p>Escreva do jeito que você falaria. Ele lê a pasta, escreve o código,
70
+ roda o que precisar e conta o que fez.</p>
71
+ <div class="sugestoes">
72
+ <button class="sugestao" data-manda="/tudo">o que ele sabe fazer</button>
73
+ <button class="sugestao" data-manda="/project">meus projetos</button>
74
+ <button class="sugestao" data-manda="/effort">esforço</button>
75
+ <button class="sugestao" data-manda="/help">comandos</button>
76
+ </div>
77
+ </div>
78
+ <div id="conversa" class="tira" hidden></div>
79
+ </div>
80
+
81
+ <div class="rodape">
82
+ <div id="pensando" hidden><span class="pulso"></span><span id="pensando-txt">trabalhando</span></div>
83
+ <form id="forma" class="caixa">
84
+ <div class="campo-volta">
85
+ <span class="chevron">❯</span>
86
+ <textarea id="campo" rows="1" placeholder="Peça alguma coisa…"
87
+ autocomplete="off" spellcheck="false"></textarea>
88
+ </div>
89
+ <button type="submit" id="mandar" class="enviar" title="Enviar (Enter)" aria-label="Enviar">
90
+ <svg width="17" height="17" viewBox="0 0 24 24" aria-hidden="true">
91
+ <path d="M5 12h13M12.5 6l6 6-6 6" fill="none" stroke="currentColor"
92
+ stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/>
93
+ </svg>
94
+ </button>
95
+ <button type="button" id="parar" class="enviar parar" title="Parar (Esc)" hidden aria-label="Parar">
96
+ <svg width="15" height="15" viewBox="0 0 24 24" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2" fill="currentColor"/></svg>
97
+ </button>
98
+ </form>
99
+ <div class="dica" id="dica">Enter manda · Shift+Enter quebra linha · arraste um arquivo aqui</div>
100
+ </div>
101
+ </main>
102
+ </div>
103
+
104
+ <dialog id="dlgConfig">
105
+ <div class="dlg-topo">
106
+ <h2>Configurações</h2>
107
+ <button class="copiar" id="fecharConfig">Fechar</button>
108
+ </div>
109
+ <div class="dlg-corpo">
110
+ <div class="grupo">
111
+ <h3>Tema</h3>
112
+ <div class="opcoes" id="temas">
113
+ <button class="opcao" data-tema="escuro">Escuro</button>
114
+ <button class="opcao" data-tema="claro">Claro</button>
115
+ <button class="opcao" data-tema="sistema">Do sistema</button>
116
+ </div>
117
+ </div>
118
+ <div class="grupo">
119
+ <h3>Cor</h3>
120
+ <div class="cores" id="cores">
121
+ <button class="cor" data-cor="ciano" style="background:#22d3ee" aria-label="Ciano"></button>
122
+ <button class="cor" data-cor="verde" style="background:#34d399" aria-label="Verde"></button>
123
+ <button class="cor" data-cor="roxo" style="background:#a78bfa" aria-label="Roxo"></button>
124
+ <button class="cor" data-cor="ambar" style="background:#fbbf24" aria-label="Âmbar"></button>
125
+ <button class="cor" data-cor="rosa" style="background:#fb7185" aria-label="Rosa"></button>
126
+ </div>
127
+ </div>
128
+ <div class="grupo">
129
+ <h3>Esta janela</h3>
130
+ <div class="linha-info"><span>Pasta</span><b id="pastaConfig">—</b></div>
131
+ <div class="linha-info"><span>Modo</span><b id="modoConfig">—</b></div>
132
+ <div class="linha-info"><span>Esforço</span><b id="esforcoConfig">—</b></div>
133
+ <p style="margin:10px 0 0;font-size:13px;color:var(--fraco)">
134
+ Estes três se mudam no terminal: <code>/dir</code>, <code>/mode</code> e <code>/effort</code>.
135
+ </p>
136
+ </div>
137
+ </div>
138
+ </dialog>
63
139
 
64
140
  <div id="alvo-solto"><div><strong>Solte aqui</strong><span>o caminho entra no pedido</span></div></div>
65
141
 
142
+ <script src="/primo-marca.js"></script>
66
143
  <script src="/app.js"></script>
67
144
  </body>
68
145
  </html>
@@ -0,0 +1,113 @@
1
+ /**
2
+ * primo-marca.js — o mascote do PrimoCode, desenhado em pixel.
3
+ *
4
+ * CÓPIA de conecta-primo-ai/frontend/primo-marca.js. São repositórios
5
+ * diferentes e não há como importar de um para o outro; o que dá para fazer é
6
+ * manter os dois IDÊNTICOS e o teste conferir isso — um mascote que muda num
7
+ * lado e não no outro é a marca do produto se partindo em duas sem ninguém
8
+ * ver.
9
+ *
10
+ * ── POR QUE MATRIZ, E NÃO O PNG ──────────────────────────────────────────
11
+ * O logo veio como imagem. Guardar o PNG funcionaria, mas ele já é pixel art:
12
+ * redesenhá-lo como MATRIZ dá o mesmo desenho nítido em qualquer tamanho —
13
+ * favicon de 16px, cabeçalho de 28px, tela vazia de 96px — sem borrar, sem
14
+ * três arquivos para manter, e pesando algumas centenas de bytes em vez de
15
+ * dezenas de kB.
16
+ *
17
+ * E deixa a cor viver no TEMA: o mesmo bicho no claro e no escuro, sem PNG
18
+ * com fundo grudado.
19
+ *
20
+ * Se um dia o desenho mudar, muda a matriz — é uma linha por fileira, e dá
21
+ * para ler o bicho olhando o código.
22
+ *
23
+ * C = corpo (ciano claro) · D = detalhe (azul escuro): orelhas,
24
+ * braços e pés · . = vazio
25
+ */
26
+ (function (raiz) {
27
+ "use strict";
28
+
29
+ var MATRIZ = [
30
+ "...DD....DD...",
31
+ "...DD....DD...",
32
+ "..CCCCCCCCCC..",
33
+ "..CCCCCCCCCC..",
34
+ "DDCCCCCCCCCCDD",
35
+ "DDCCCCCCCCCCDD",
36
+ "..CCCCCCCCCC..",
37
+ "..CCCCCCCCCC..",
38
+ "..CCCCCCCCCC..",
39
+ "...DD....DD...",
40
+ "...DD....DD...",
41
+ ];
42
+
43
+ var CORPO = "#5ec8ef";
44
+ var DETALHE = "#2b7fa8";
45
+
46
+ /**
47
+ * O mascote como SVG.
48
+ *
49
+ * Retângulos colados, sem `shape-rendering: crispEdges`: em pixel art
50
+ * ampliado, o antialiasing do navegador deixa fios claros entre um quadrado
51
+ * e outro. Colar meio pixel a mais em cada lado (o `+0.02`) some com a
52
+ * costura sem mudar o desenho.
53
+ *
54
+ * @param {object} op
55
+ * @param {number} [op.tamanho] lado em px (o SVG é quadrado)
56
+ * @param {string} [op.corpo] cor do corpo
57
+ * @param {string} [op.detalhe] cor das orelhas, braços e pés
58
+ * @param {string} [op.fundo] cor do quadrado de trás (vazio = sem fundo)
59
+ * @param {string} [op.rotulo] texto alternativo; vazio = decorativo
60
+ */
61
+ function svg(op) {
62
+ op = op || {};
63
+ var lado = op.tamanho || 32;
64
+ var corpo = op.corpo || CORPO;
65
+ var detalhe = op.detalhe || DETALHE;
66
+ var linhas = MATRIZ.length;
67
+ var colunas = MATRIZ[0].length;
68
+ // O desenho é mais largo que alto; a caixa é quadrada e ele fica centrado.
69
+ var caixa = Math.max(linhas, colunas);
70
+ var dx = (caixa - colunas) / 2;
71
+ var dy = (caixa - linhas) / 2;
72
+
73
+ /* UM caminho por cor, e não um retângulo por célula.
74
+ *
75
+ * Com retângulos separados, o navegador antialiasa a borda de cada um
76
+ * contra a do vizinho e o desenho ganha uma grade de fios claros no meio
77
+ * do corpo — visível já em 96px, feio em 200px. Num caminho só, as
78
+ * células vizinhas viram uma forma única e não há borda interna nenhuma
79
+ * para suavizar. */
80
+ var caminho = { C: [], D: [] };
81
+ for (var y = 0; y < linhas; y++) {
82
+ var fileira = MATRIZ[y];
83
+ for (var x = 0; x < colunas; x++) {
84
+ var ch = fileira[x];
85
+ if (ch !== "C" && ch !== "D") continue;
86
+ caminho[ch].push("M" + (x + dx) + " " + (y + dy) + "h1v1h-1z");
87
+ }
88
+ }
89
+
90
+ var partes = [];
91
+ if (op.fundo) {
92
+ partes.push('<rect width="' + caixa + '" height="' + caixa
93
+ + '" rx="' + (caixa * 0.18).toFixed(2) + '" fill="' + op.fundo + '"/>');
94
+ }
95
+ if (caminho.C.length) partes.push('<path d="' + caminho.C.join("") + '" fill="' + corpo + '"/>');
96
+ if (caminho.D.length) partes.push('<path d="' + caminho.D.join("") + '" fill="' + detalhe + '"/>');
97
+
98
+ var acessivel = op.rotulo
99
+ ? ' role="img" aria-label="' + String(op.rotulo).replace(/"/g, "&quot;") + '"'
100
+ : ' aria-hidden="true"';
101
+
102
+ return '<svg viewBox="0 0 ' + caixa + " " + caixa + '" width="' + lado
103
+ + '" height="' + lado + '"' + acessivel + ">" + partes.join("") + "</svg>";
104
+ }
105
+
106
+ /** O mesmo bicho como favicon, sem pedir arquivo ao servidor. */
107
+ function favicon() {
108
+ var s = svg({ tamanho: 32, fundo: "#0a0f16" });
109
+ return "data:image/svg+xml," + encodeURIComponent(s);
110
+ }
111
+
112
+ raiz.PrimoMarca = { svg: svg, favicon: favicon, MATRIZ: MATRIZ, CORPO: CORPO, DETALHE: DETALHE };
113
+ })(typeof window !== "undefined" ? window : globalThis);
package/bin/primocode.js CHANGED
@@ -23,6 +23,7 @@ const { runAct, interromper } = require('../lib/act');
23
23
  const claudeEngine = require('../lib/claude-engine');
24
24
  const codexEngine = require('../lib/codex-engine');
25
25
  const chaves = require('../lib/chaves');
26
+ const boasVindas = require('../lib/boas-vindas');
26
27
  const pasta = require('../lib/pasta');
27
28
  const catalogo = require('../lib/catalogo');
28
29
  const effort = require('../lib/effort');
@@ -69,7 +70,7 @@ const state = {
69
70
  engine: 'server', // 'server' (OpenRouter/Groq) | 'claude-code' (/claude) | 'codex' (/codex)
70
71
  };
71
72
 
72
- // A janela do /app, quando aberta. Uma por sessão: o segundo /app traz a que
73
+ // A janela do /rc, quando aberta. Uma por sessão: o segundo /rc traz a que
73
74
  // existe para a frente em vez de subir outro servidor ao lado.
74
75
  let janelaAberta = null;
75
76
 
@@ -87,11 +88,11 @@ ${c.bold('Comandos')}
87
88
  ${c.brand('/read')} <arquivo> mostra um arquivo
88
89
  ${c.brand('/run')} <comando> executa no shell do projeto
89
90
  ${c.brand('/open')} <arquivo|url> abre no navegador
90
- ${c.brand('/app')} abre a janela do PrimoCode (chat, sem terminal)
91
+ ${c.brand('/rc')} abre a janela do PrimoCode (chat, sem terminal)
91
92
  ${c.brand('/claude')} usa o Claude Code da SUA conta como motor (Opus)
92
93
  ${c.brand('/codex')} usa o Codex da SUA conta como motor (modelo barato)
93
94
  ${c.brand('/chave')} <provedor> sua própria chave de API (Claude, Codex)
94
- ${c.brand('/entrar')} entra na sua conta Conecta Primo · ${c.brand('/conta')} mostra o plano
95
+ ${c.brand('/conectar')} entra na sua conta Conecta Primo · ${c.brand('/conta')} mostra o plano
95
96
  ${c.brand('/conversas')} [n] suas conversas salvas — as mesmas do app e do celular
96
97
  ${c.brand('/sair')} desconecta este computador
97
98
  ${c.brand('/status')} checa o servidor
@@ -134,11 +135,11 @@ const SLASH_COMANDOS = [
134
135
  ['/read', 'mostra um arquivo'],
135
136
  ['/run', 'roda um comando'],
136
137
  ['/open', 'abre no navegador'],
137
- ['/app', 'abre a janela do PrimoCode'],
138
+ ['/rc', 'abre a janela do PrimoCode'],
138
139
  ['/claude', 'usa o Claude Code da sua conta como motor'],
139
140
  ['/codex', 'usa o Codex da sua conta como motor'],
140
141
  ['/chave', 'sua própria chave de API (Claude, Codex)'],
141
- ['/entrar', 'entra na sua conta Conecta Primo'],
142
+ ['/conectar', 'entra na sua conta Conecta Primo'],
142
143
  ['/conta', 'sua conta, plano e computadores conectados'],
143
144
  ['/conversas', 'suas conversas salvas, as mesmas do app e do celular'],
144
145
  ['/sair', 'desconecta este computador'],
@@ -1039,14 +1040,22 @@ async function handleSlash(text) {
1039
1040
  break;
1040
1041
  }
1041
1042
 
1042
- /* A janela. "Se mandar /app pra ele, ele abre o aplicativo — para o
1043
+ /* A janela. "Se mandar /rc pra ele, ele abre o aplicativo — para o
1043
1044
  Mac e o Windows, já instalado junto com o npm, com as cores do
1044
1045
  PrimoCode. Um chat estilo terminal para quem não quer o terminal."
1045
1046
 
1046
- Uma janela por sessão: mandar /app de novo traz a que já existe
1047
+ Uma janela por sessão: mandar /rc de novo traz a que já existe
1047
1048
  para a frente em vez de abrir outra do lado. */
1048
- case 'app': {
1049
- // `/app --instalar` põe o PrimoCode no Dock, no menu Iniciar ou no
1049
+ /* O nome antigo some da ajuda e do menu, mas continua respondendo —
1050
+ avisando. Some de vez e quem tinha o dedo viciado bate num "comando
1051
+ não existe" e acha que a atualização quebrou alguma coisa; o aviso
1052
+ custa uma linha e ensina o nome novo na hora em que ele é
1053
+ procurado, que é a única hora em que alguém lê. */
1054
+ case 'rc': case 'app': {
1055
+ if (cmd.toLowerCase() === 'app') {
1056
+ console.log(c.muted(' Agora é ') + c.brand('/rc') + c.muted('. Abrindo…'));
1057
+ }
1058
+ // `/rc --instalar` põe o PrimoCode no Dock, no menu Iniciar ou no
1050
1059
  // menu de aplicativos, com o ícone do mascote. Uma vez só.
1051
1060
  if (/^--?(instalar|install)$/.test(rest)) {
1052
1061
  const r = require('../lib/instalar-app.js').instalar(VERSION);
@@ -1093,7 +1102,7 @@ async function handleSlash(text) {
1093
1102
  }
1094
1103
  console.log(c.muted(' O terminal continua funcionando: os dois mostram a mesma coisa.'));
1095
1104
  // Oferecido UMA vez, e só a quem ainda não tem: repetir todo
1096
- // /app viraria ruído para quem já instalou de propósito.
1105
+ // /rc viraria ruído para quem já instalou de propósito.
1097
1106
  if (!require('../lib/instalar-app.js').instalado()) {
1098
1107
  console.log(c.dim(' Quer o ícone no ') + c.dim(os.platform() === 'darwin' ? 'Dock' : 'menu Iniciar')
1099
1108
  + c.dim('? ') + c.brand('/app --instalar'));
@@ -1107,12 +1116,15 @@ async function handleSlash(text) {
1107
1116
 
1108
1117
  /* A conta do Conecta Primo. O login inteiro mora em lib/conta.js;
1109
1118
  aqui é a tela dele. */
1110
- case 'entrar': case 'login': {
1119
+ case 'conectar': case 'entrar': case 'login': {
1120
+ if (/^(entrar|login)$/.test(cmd.toLowerCase())) {
1121
+ console.log(c.muted(' Agora é ') + c.brand('/conectar') + c.muted('.'));
1122
+ }
1111
1123
  const jaEsta = conta.atual();
1112
1124
  if (jaEsta && rest !== 'trocar') {
1113
1125
  console.log(c.muted(' Já conectado como ') + c.brand(jaEsta.uid.slice(0, 12) + '…')
1114
1126
  + c.muted(` · plano ${jaEsta.plano}`));
1115
- console.log(c.dim(' Para entrar com outra conta: ') + c.brand('/entrar trocar'));
1127
+ console.log(c.dim(' Para entrar com outra conta: ') + c.brand('/conectar trocar'));
1116
1128
  break;
1117
1129
  }
1118
1130
  if (rest === 'trocar') conta.sair();
@@ -1148,7 +1160,7 @@ async function handleSlash(text) {
1148
1160
  mostram. Sem número, lista; com número, abre e continua dali. */
1149
1161
  case 'conversas': case 'historico': {
1150
1162
  if (!conta.atual()) {
1151
- console.log(c.muted(' Não conectado. ') + c.brand('/entrar')
1163
+ console.log(c.muted(' Não conectado. ') + c.brand('/conectar')
1152
1164
  + c.muted(' para ver suas conversas aqui, no app e no celular.'));
1153
1165
  break;
1154
1166
  }
@@ -1197,14 +1209,14 @@ async function handleSlash(text) {
1197
1209
  if (!conta.atual()) { console.log(c.muted(' Você não está conectado.')); break; }
1198
1210
  conta.sair();
1199
1211
  console.log(c.ok(' Desconectado deste computador.')
1200
- + c.dim(' A conta continua intacta; entre de novo com /entrar.'));
1212
+ + c.dim(' A conta continua intacta; entre de novo com /conectar.'));
1201
1213
  break;
1202
1214
  }
1203
1215
 
1204
1216
  case 'conta': {
1205
1217
  const eu = conta.atual();
1206
1218
  if (!eu) {
1207
- console.log(c.muted(' Não conectado. ') + c.brand('/entrar') + c.muted(' para entrar.'));
1219
+ console.log(c.muted(' Não conectado. ') + c.brand('/conectar') + c.muted(' para entrar.'));
1208
1220
  break;
1209
1221
  }
1210
1222
  const sp = new Spinner('conferindo').start();
@@ -1390,6 +1402,23 @@ async function main() {
1390
1402
  pastaPadrao: state.workDirOrigem === 'padrao',
1391
1403
  }));
1392
1404
  rl = readline.createInterface({ input: process.stdin, output: process.stdout, prompt: makePrompt(), completer: completarSlash });
1405
+
1406
+ /* A primeira vez. Antes o CLI abria direto no prompt e quem instalava não
1407
+ descobria que existe conta, nem que dá para usar a própria chave — ia
1408
+ usar o gratuito achando que era só aquilo, até bater na cota e concluir
1409
+ que o produto não presta. Aparece UMA vez; depois disso, /conectar e
1410
+ /chave é que são o caminho. */
1411
+ if (!boasVindas.jaAbriu({ arquivoConfig: cfg.CONFIG_FILE })) {
1412
+ await boasVindas.rodar({
1413
+ perguntar: ask,
1414
+ mostrar: (t) => console.log(t),
1415
+ cores: c,
1416
+ interativo: !!(process.stdin.isTTY && process.stdout.isTTY),
1417
+ conectar: () => handleSlash('/conectar'),
1418
+ configurarChave: () => handleSlash('/chave'),
1419
+ });
1420
+ }
1421
+
1393
1422
  rl.prompt();
1394
1423
 
1395
1424
  // Fila: o readline continua emitindo 'line' enquanto um pedido está sendo
package/lib/api.js CHANGED
@@ -35,6 +35,39 @@ function buildOptions(url, method, extraHeaders, bodyLength, token) {
35
35
  };
36
36
  }
37
37
 
38
+ /* ── OS PRAZOS ────────────────────────────────────────────────────────────
39
+ * Sem prazo, um servidor que aceita a conexão e fica MUDO pendura o CLI para
40
+ * sempre: sem mensagem, sem spinner que pare, sem nada — e a pessoa só sai
41
+ * com Ctrl-C, sem saber o que houve. Aconteceu num teste real e custou um
42
+ * bom tempo de investigação para descobrir que não era lentidão, era trava.
43
+ *
44
+ * ── POR QUE NÃO UM PRAZO CURTO PARA CONECTAR ────────────────────────────
45
+ * A tentação é cortar em 20s "porque conectar é rápido". Aqui não é: o
46
+ * servidor roda no plano gratuito do Render, que DORME. A primeira chamada
47
+ * depois de um tempo parado espera a máquina subir — 30, 40, às vezes 60
48
+ * segundos — com o socket já aceito e nenhum byte voltando. Um teto curto
49
+ * trocaria uma trava rara por uma quebra TODO DIA, na primeira pergunta da
50
+ * manhã, e a pessoa leria "timeout" achando que está sem internet.
51
+ *
52
+ * Então os prazos são generosos e medem a coisa certa:
53
+ *
54
+ * · Nas chamadas curtas (refinar, plano, resumo), um teto duro na resposta
55
+ * inteira. Elas são pequenas; se passou disso, travou.
56
+ *
57
+ * · No stream, o prazo é de SILÊNCIO e reinicia a cada pedaço que chega.
58
+ * Um modelo pensando três minutos e escrevendo nunca é cortado; quem é
59
+ * cortado é quem para de falar sem fechar a conexão — a trava que não
60
+ * dava sinal nenhum.
61
+ */
62
+ const PRAZO_RESPOSTA = Number(process.env.PRIMOCODE_TIMEOUT_RESPOSTA || 180000);
63
+ const PRAZO_SILENCIO = Number(process.env.PRIMOCODE_TIMEOUT_SILENCIO || 240000);
64
+
65
+ function erroDeTempo(oque, ms) {
66
+ return new Error(
67
+ `O servidor não ${oque} em ${Math.round(ms / 1000)}s (timeout). `
68
+ + 'Pode ser a rede, ou o servidor fora do ar — tente de novo.');
69
+ }
70
+
38
71
  function request(serverUrl, endpoint, payload, token) {
39
72
  return new Promise((resolve, reject) => {
40
73
  const url = new URL(endpoint, serverUrl);
@@ -51,7 +84,17 @@ function request(serverUrl, endpoint, payload, token) {
51
84
  else resolve(json);
52
85
  });
53
86
  });
54
- req.on('error', reject);
87
+ // Um relógio só, na resposta inteira. Nada de prazo de socket: ele
88
+ // mediria o silêncio da máquina subindo no Render e mataria a
89
+ // primeira chamada do dia.
90
+ const relogio = setTimeout(() => {
91
+ req.destroy(erroDeTempo('terminou de responder', PRAZO_RESPOSTA));
92
+ }, PRAZO_RESPOSTA);
93
+ if (relogio.unref) relogio.unref();
94
+ const parar = () => clearTimeout(relogio);
95
+ req.on('close', parar);
96
+
97
+ req.on('error', (e) => { parar(); reject(e); });
55
98
  req.write(data);
56
99
  req.end();
57
100
  });
@@ -73,7 +116,23 @@ function requestStream(serverUrl, endpoint, payload, onEvent, token, sinal) {
73
116
  const client = url.protocol === 'https:' ? https : http;
74
117
  const options = buildOptions(url, 'POST', { Accept: 'text/event-stream' }, Buffer.byteLength(data), token);
75
118
 
119
+ /* O relógio do SILÊNCIO. Reiniciado a cada pedaço que chega, então um
120
+ modelo que pensa três minutos e vai escrevendo nunca é cortado — só
121
+ é cortado quem para de falar e não fecha a conexão, que é
122
+ exatamente a trava que não dava sinal nenhum. */
123
+ let relogio = null;
124
+ const morrerSeCalar = () => {
125
+ clearTimeout(relogio);
126
+ relogio = setTimeout(() => {
127
+ req.destroy(erroDeTempo('mandou nada por', PRAZO_SILENCIO));
128
+ }, PRAZO_SILENCIO);
129
+ if (relogio.unref) relogio.unref();
130
+ };
131
+ const calarORelogio = () => clearTimeout(relogio);
132
+
76
133
  const req = client.request(options, (res) => {
134
+ morrerSeCalar();
135
+ res.on('data', morrerSeCalar);
77
136
  // Erro HTTP não vem como SSE — o corpo é um JSON de erro.
78
137
  if (res.statusCode >= 400) {
79
138
  let body = '';
@@ -87,7 +146,7 @@ function requestStream(serverUrl, endpoint, payload, onEvent, token, sinal) {
87
146
  }
88
147
 
89
148
  let settled = false;
90
- const finish = () => { if (!settled) { settled = true; resolve(); } };
149
+ const finish = () => { calarORelogio(); if (!settled) { settled = true; resolve(); } };
91
150
 
92
151
  let buffer = '';
93
152
  res.setEncoding('utf8');
@@ -125,13 +184,18 @@ function requestStream(serverUrl, endpoint, payload, onEvent, token, sinal) {
125
184
  const cortar = () => {
126
185
  if (cortado) return;
127
186
  cortado = true;
187
+ calarORelogio();
128
188
  try { req.destroy(); } catch {}
129
189
  resolve();
130
190
  };
131
191
  if (sinal) sinal.addEventListener('abort', cortar, { once: true });
132
192
 
133
- req.on('error', (e) => { if (!cortado) reject(e); });
193
+ req.on('error', (e) => { calarORelogio(); if (!cortado) reject(e); });
134
194
  req.on('close', () => { if (sinal) sinal.removeEventListener('abort', cortar); });
195
+ // O relógio começa AQUI, não na primeira resposta: se o servidor
196
+ // aceitar a conexão e nunca mandar nada, `morrerSeCalar` nunca teria
197
+ // sido chamado e a trava voltaria inteira.
198
+ morrerSeCalar();
135
199
  req.write(data);
136
200
  req.end();
137
201
  });
package/lib/app.js CHANGED
@@ -145,7 +145,23 @@ async function abrir(op) {
145
145
  emitir('estado', op.estado());
146
146
  }
147
147
 
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.
148
151
  const servidor = http.createServer((req, res) => {
152
+ atender(req, res).catch((e) => {
153
+ // Uma exceção solta aqui vira unhandledRejection e derruba o CLI
154
+ // 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é.
157
+ try {
158
+ if (!res.headersSent) res.writeHead(500, { 'Content-Type': 'application/json' });
159
+ res.end(JSON.stringify({ ok: false, error: e.message }));
160
+ } catch { /* socket já foi */ }
161
+ });
162
+ });
163
+
164
+ async function atender(req, res) {
149
165
  const url = new URL(req.url, 'http://127.0.0.1');
150
166
  const rota = url.pathname;
151
167
 
@@ -216,6 +232,28 @@ async function abrir(op) {
216
232
  return;
217
233
  }
218
234
 
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));
247
+ }
248
+
249
+ if (rota.startsWith('/api/conversa/')) {
250
+ 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));
255
+ }
256
+
219
257
  if (rota === '/api/estado') {
220
258
  res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
221
259
  return res.end(JSON.stringify(Object.assign({ versao: op.versao }, op.estado())));
@@ -231,7 +269,7 @@ async function abrir(op) {
231
269
  res.writeHead(200, { 'Content-Type': tipoDe(nome) });
232
270
  res.end(dados);
233
271
  });
234
- });
272
+ }
235
273
 
236
274
  const porta = await escutar(servidor, PORTA_PADRAO);
237
275
  servidor.__porta = porta;