primocode 8.31.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/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;
@@ -0,0 +1,161 @@
1
+ /**
2
+ * boas-vindas.js — a primeira vez que alguém roda `primo`.
3
+ *
4
+ * "Eu desinstalei e instalei novamente, está na versão nova, e quando mandei
5
+ * ele já abriu. Não veio a parte pra mim fazer login, selecionar qual IA eu
6
+ * quero — se quero usar já o Conecta Primo AI como IA, se quero usar outra
7
+ * IA. Não tem nada disso."
8
+ *
9
+ * Tinha razão: o CLI abria direto no prompt. Quem instalava não descobria que
10
+ * existe conta, não descobria que dá para usar a própria chave, e ia usar o
11
+ * gratuito achando que era só aquilo — até bater na cota e concluir que o
12
+ * produto não presta.
13
+ *
14
+ * ── POR QUE ISTO É UM MÓDULO, E NÃO CÓDIGO SOLTO NO bin/ ─────────────────
15
+ * Porque tem decisão dentro: quando aparecer, o que perguntar, o que gravar,
16
+ * e o que fazer quando a pessoa só aperta Enter. Isso precisa de teste, e
17
+ * teste não digita em terminal. Aqui a entrada e a saída são funções que o
18
+ * teste passa; o bin/ liga nas de verdade.
19
+ *
20
+ * ── APARECE UMA VEZ SÓ, E DÁ PARA PULAR ──────────────────────────────────
21
+ * Um assistente que volta toda vez vira obstáculo. Este grava a marca de que
22
+ * já rodou e não volta — nem se a pessoa escolher "depois". Quem quiser
23
+ * rever chama `/conectar` ou `/chave`, que é onde as duas coisas moram.
24
+ */
25
+
26
+ 'use strict';
27
+
28
+ const fs = require('fs');
29
+ const os = require('os');
30
+ const path = require('path');
31
+
32
+ const MARCA = () => path.join(os.homedir(), '.primocode', 'ja-abriu.json');
33
+
34
+ /**
35
+ * Já passou por aqui?
36
+ *
37
+ * Duas provas, e basta uma: a marca própria, ou um config.json que já existe
38
+ * de uma versão anterior. A segunda evita que quem já usava o PrimoCode há
39
+ * meses veja um "bem-vindo, é sua primeira vez" ao atualizar — o que faria a
40
+ * atualização parecer uma reinstalação, e é o tipo de coisa que assusta.
41
+ */
42
+ function jaAbriu({ arquivoConfig } = {}) {
43
+ try { if (fs.existsSync(MARCA())) return true; } catch {}
44
+ try { if (arquivoConfig && fs.existsSync(arquivoConfig)) return true; } catch {}
45
+ return false;
46
+ }
47
+
48
+ function marcarQueAbriu(dados = {}) {
49
+ try {
50
+ const caminho = MARCA();
51
+ fs.mkdirSync(path.dirname(caminho), { recursive: true });
52
+ fs.writeFileSync(caminho, JSON.stringify({
53
+ em: new Date().toISOString(), ...dados,
54
+ }, null, 2), 'utf8');
55
+ return true;
56
+ } catch { return false; }
57
+ }
58
+
59
+ /** Para o teste e para quem quiser rever: apaga a marca. */
60
+ function esquecer() {
61
+ try { fs.rmSync(MARCA(), { force: true }); return true; } catch { return false; }
62
+ }
63
+
64
+ const ESCOLHAS = [
65
+ {
66
+ id: 'conecta',
67
+ titulo: 'Usar a IA do Conecta Primo AI',
68
+ detalhe: 'A que já vem pronta. Entra na sua conta; Premium ou Super libera o limite.',
69
+ },
70
+ {
71
+ id: 'propria',
72
+ titulo: 'Usar a minha própria IA',
73
+ detalhe: 'Sua chave do Claude ou do Codex, ou o Claude Code / Codex já instalados.',
74
+ },
75
+ {
76
+ id: 'depois',
77
+ titulo: 'Decidir depois',
78
+ detalhe: 'Começa no gratuito. /conectar e /chave estão lá quando você quiser.',
79
+ },
80
+ ];
81
+
82
+ /** O que a pessoa digitou → qual escolha. Aceita número, o id, ou Enter. */
83
+ function lerEscolha(texto) {
84
+ const t = String(texto || '').trim().toLowerCase();
85
+ // Enter puro é a primeira opção: é a recomendada, e quem aperta Enter sem
86
+ // ler está dizendo "faz o que for melhor", não "não quero nada".
87
+ if (!t) return ESCOLHAS[0].id;
88
+ const n = Number(t);
89
+ if (Number.isInteger(n) && n >= 1 && n <= ESCOLHAS.length) return ESCOLHAS[n - 1].id;
90
+ for (const e of ESCOLHAS) if (t === e.id || t.startsWith(e.id.slice(0, 4))) return e.id;
91
+ if (/^(n|nao|não|depois|pular|skip)$/.test(t)) return 'depois';
92
+ if (/claude|codex|chave|propri/.test(t)) return 'propria';
93
+ if (/conecta|primo|conta|entrar/.test(t)) return 'conecta';
94
+ return null; // não entendi: quem chama pergunta de novo
95
+ }
96
+
97
+ /**
98
+ * O assistente.
99
+ *
100
+ * @param {object} op
101
+ * @param {(q:string)=>Promise<string>} op.perguntar lê uma linha
102
+ * @param {(s:string)=>void} op.mostrar escreve na tela
103
+ * @param {object} op.cores o `c` do ui.js
104
+ * @param {()=>Promise<any>} op.conectar roda o login (o /conectar)
105
+ * @param {()=>Promise<any>} op.configurarChave abre o /chave
106
+ * @param {boolean} op.interativo false = não pergunta nada
107
+ */
108
+ async function rodar({ perguntar, mostrar, cores: c, conectar, configurarChave,
109
+ interativo = true } = {}) {
110
+ /* Sem terminal de verdade (pipe, script, CI) NÃO se pergunta nada. Um
111
+ assistente que espera resposta num lugar onde ninguém pode responder
112
+ trava o processo para sempre — e trava calado, que é o pior jeito. */
113
+ if (!interativo) {
114
+ marcarQueAbriu({ escolha: 'sem-terminal' });
115
+ return { ok: true, escolha: 'depois', perguntou: false };
116
+ }
117
+
118
+ mostrar('');
119
+ mostrar(c.bold(c.brand(' Primeira vez por aqui.')));
120
+ mostrar(c.muted(' Escolha qual IA vai pensar por trás do PrimoCode:'));
121
+ mostrar('');
122
+ ESCOLHAS.forEach((e, i) => {
123
+ mostrar(' ' + c.brand(String(i + 1)) + c.muted('. ') + c.white(e.titulo)
124
+ + (i === 0 ? c.dim(' (recomendado)') : ''));
125
+ mostrar(' ' + c.dim(e.detalhe));
126
+ });
127
+ mostrar('');
128
+
129
+ let escolha = null;
130
+ for (let tentativa = 0; tentativa < 3 && !escolha; tentativa++) {
131
+ const dito = await perguntar(c.brand(' ❯ ') + c.muted('1, 2 ou 3 (Enter = 1) '));
132
+ escolha = lerEscolha(dito);
133
+ if (!escolha) mostrar(c.muted(' Não entendi. Digite 1, 2 ou 3.'));
134
+ }
135
+ // Três tentativas sem entender: segue no gratuito em vez de insistir.
136
+ // Ninguém deve ficar preso numa pergunta para usar o programa.
137
+ if (!escolha) escolha = 'depois';
138
+
139
+ let resultado = { escolha };
140
+ if (escolha === 'conecta' && conectar) {
141
+ mostrar('');
142
+ resultado.conexao = await conectar();
143
+ } else if (escolha === 'propria' && configurarChave) {
144
+ mostrar('');
145
+ resultado.chave = await configurarChave();
146
+ } else if (escolha === 'depois') {
147
+ mostrar('');
148
+ mostrar(c.muted(' Tudo bem. Você está no gratuito — ele funciona, mas tem cota.'));
149
+ mostrar(c.dim(' ') + c.brand('/conectar') + c.dim(' entra na sua conta · ')
150
+ + c.brand('/chave') + c.dim(' usa a sua própria IA'));
151
+ }
152
+
153
+ // A marca é gravada DEPOIS de tudo, mas grava mesmo se o login falhar:
154
+ // insistir no assistente a cada abertura porque a rede caiu uma vez
155
+ // seria punir a pessoa por um problema que não é dela.
156
+ marcarQueAbriu({ escolha });
157
+ mostrar('');
158
+ return { ok: true, perguntou: true, ...resultado };
159
+ }
160
+
161
+ module.exports = { jaAbriu, marcarQueAbriu, esquecer, lerEscolha, rodar, ESCOLHAS, MARCA };
package/lib/chaves.js ADDED
@@ -0,0 +1,149 @@
1
+ /**
2
+ * chaves.js — a chave de API da própria pessoa.
3
+ *
4
+ * "Você vai usar com a API que já está ali, e pode configurar sua própria
5
+ * API, por exemplo do Claude, do Codex, etc."
6
+ *
7
+ * ── ONDE ELAS MORAM ──────────────────────────────────────────────────────
8
+ * Em `~/.primocode/chaves.json`, com permissão 600 — o mesmo lugar e o mesmo
9
+ * cuidado da credencial da conta, e pela mesma razão: o `config.json` é o
10
+ * arquivo que alguém abre para conferir um ajuste, cola num chamado de
11
+ * suporte ou copia para outra máquina. Chave junto de preferência é chave que
12
+ * vaza por hábito, não por ataque.
13
+ *
14
+ * ── COMO ELAS CHEGAM AO MOTOR ────────────────────────────────────────────
15
+ * Como VARIÁVEL DE AMBIENTE do processo filho, que é o jeito que o Claude
16
+ * Code e o Codex já leem chave. Não se escreve em arquivo de configuração
17
+ * deles, não se mexe no login que a pessoa já fez: quem não configurar nada
18
+ * aqui continua usando a conta em que entrou nas ferramentas, e quem
19
+ * configurar passa a chave só naquela chamada.
20
+ *
21
+ * ── O QUE NUNCA APARECE ──────────────────────────────────────────────────
22
+ * A chave inteira. Nem ao listar, nem ao salvar, nem numa mensagem de erro. O
23
+ * que se mostra é o suficiente para a pessoa reconhecer qual é (o começo e o
24
+ * fim), porque quem tem três chaves precisa saber qual está ali — e é isso
25
+ * que evita ela colar a chave na tela para conferir.
26
+ */
27
+
28
+ 'use strict';
29
+
30
+ const fs = require('fs');
31
+ const os = require('os');
32
+ const path = require('path');
33
+
34
+ const ARQUIVO = () => path.join(os.homedir(), '.primocode', 'chaves.json');
35
+
36
+ /**
37
+ * Os provedores que o PrimoCode sabe usar, e a variável que cada um lê.
38
+ *
39
+ * O nome da variável é o do provedor, não um inventado aqui: é o que as
40
+ * ferramentas já procuram, e inventar um significaria a chave certa no lugar
41
+ * errado — configurada, aceita, e ignorada na hora de valer.
42
+ */
43
+ const PROVEDORES = {
44
+ claude: {
45
+ nome: 'Claude',
46
+ variavel: 'ANTHROPIC_API_KEY',
47
+ prefixo: 'sk-ant-',
48
+ onde: 'https://console.anthropic.com/settings/keys',
49
+ motor: 'claude-code',
50
+ },
51
+ codex: {
52
+ nome: 'Codex',
53
+ variavel: 'OPENAI_API_KEY',
54
+ prefixo: 'sk-',
55
+ onde: 'https://platform.openai.com/api-keys',
56
+ motor: 'codex',
57
+ },
58
+ };
59
+
60
+ function ler() {
61
+ try { return JSON.parse(fs.readFileSync(ARQUIVO(), 'utf8')); }
62
+ catch { return {}; }
63
+ }
64
+
65
+ function gravar(dados) {
66
+ const caminho = ARQUIVO();
67
+ fs.mkdirSync(path.dirname(caminho), { recursive: true });
68
+ fs.writeFileSync(caminho, JSON.stringify(dados, null, 2), 'utf8');
69
+ // 600 = só o dono lê. No Windows o modo é ignorado pelo sistema de
70
+ // arquivos e a chamada não falha — lá quem protege é a pasta do perfil.
71
+ try { fs.chmodSync(caminho, 0o600); } catch {}
72
+ }
73
+
74
+ /** O bastante para reconhecer, nunca o bastante para usar. */
75
+ function disfarcar(chave) {
76
+ const s = String(chave || '');
77
+ if (s.length <= 12) return '•'.repeat(s.length);
78
+ return `${s.slice(0, 7)}…${s.slice(-4)}`;
79
+ }
80
+
81
+ /**
82
+ * Guarda uma chave. Recusa a que claramente não é do provedor pedido — o
83
+ * erro mais comum é colar a do Claude no Codex, e sem esta conferência isso
84
+ * só apareceria como "não autorizado" no meio de uma tarefa, longe da causa.
85
+ */
86
+ function guardar(provedor, chave) {
87
+ const p = PROVEDORES[provedor];
88
+ if (!p) return { ok: false, error: `não conheço "${provedor}". Use: ${Object.keys(PROVEDORES).join(', ')}` };
89
+ const limpa = String(chave || '').trim();
90
+ if (!limpa) return { ok: false, error: 'a chave veio vazia' };
91
+ if (limpa.length < 20) return { ok: false, error: 'isso é curto demais para ser uma chave' };
92
+ if (!limpa.startsWith(p.prefixo)) {
93
+ return { ok: false,
94
+ error: `uma chave do ${p.nome} começa com "${p.prefixo}", e essa não. Colou a do outro provedor?` };
95
+ }
96
+ // A do Claude também começa com "sk-", então o Codex precisa da recusa
97
+ // explícita: sem ela, `sk-ant-…` passaria como chave da OpenAI.
98
+ if (provedor === 'codex' && limpa.startsWith('sk-ant-')) {
99
+ return { ok: false, error: `essa é uma chave do Claude. Use /chave claude para ela.` };
100
+ }
101
+ const tudo = ler();
102
+ tudo[provedor] = limpa;
103
+ gravar(tudo);
104
+ return { ok: true, provedor, nome: p.nome, disfarce: disfarcar(limpa) };
105
+ }
106
+
107
+ function esquecer(provedor) {
108
+ const tudo = ler();
109
+ if (!(provedor in tudo)) return { ok: false, error: `não havia chave do ${provedor}` };
110
+ delete tudo[provedor];
111
+ gravar(tudo);
112
+ return { ok: true };
113
+ }
114
+
115
+ /** O que está configurado, disfarçado. */
116
+ function listar() {
117
+ const tudo = ler();
118
+ return Object.entries(PROVEDORES).map(([id, p]) => ({
119
+ id, nome: p.nome, variavel: p.variavel, onde: p.onde,
120
+ tem: typeof tudo[id] === 'string' && tudo[id].length > 0,
121
+ disfarce: tudo[id] ? disfarcar(tudo[id]) : null,
122
+ }));
123
+ }
124
+
125
+ /**
126
+ * O ambiente para rodar o motor de um provedor: o de agora, mais a chave.
127
+ *
128
+ * Sem chave configurada devolve o ambiente INTACTO — nem a variável vazia,
129
+ * que apagaria a chave que a pessoa já tem exportada no shell e faria a
130
+ * ferramenta parar de funcionar por ter sido "configurada".
131
+ */
132
+ function ambienteDe(provedor, base = process.env) {
133
+ const p = PROVEDORES[provedor];
134
+ if (!p) return base;
135
+ const chave = ler()[provedor];
136
+ if (!chave) return base;
137
+ return { ...base, [p.variavel]: chave };
138
+ }
139
+
140
+ /** Qual provedor um motor usa. */
141
+ function provedorDoMotor(motor) {
142
+ for (const [id, p] of Object.entries(PROVEDORES)) if (p.motor === motor) return id;
143
+ return null;
144
+ }
145
+
146
+ module.exports = {
147
+ PROVEDORES, guardar, esquecer, listar, ambienteDe, disfarcar,
148
+ provedorDoMotor, ARQUIVO,
149
+ };
@@ -17,6 +17,29 @@ const { execSync, execFileSync } = require('child_process');
17
17
  const MARCA_INICIO = '<<<PRIMOCODE>>>';
18
18
  const MARCA_FIM = '<<<FIM>>>';
19
19
 
20
+ /**
21
+ * O modelo que o /claude pede.
22
+ *
23
+ * "Ele usa o modelo Opus 5 [no Claude]."
24
+ *
25
+ * Vai o APELIDO (`opus`), não o identificador completo: o apelido é o que o
26
+ * Claude Code resolve para o Opus do dia, e um identificador fixo aqui
27
+ * envelheceria dentro de um pacote publicado no npm — a pessoa atualizaria o
28
+ * Claude Code, o modelo sairia de catálogo, e o PrimoCode pediria um modelo
29
+ * que não existe mais.
30
+ *
31
+ * Quem quiser outro põe PRIMOCODE_CLAUDE_MODELO. Vazio manda sem `--model`, e
32
+ * aí vale o padrão da conta da pessoa.
33
+ */
34
+ const MODELO_CLAUDE = process.env.PRIMOCODE_CLAUDE_MODELO !== undefined
35
+ ? process.env.PRIMOCODE_CLAUDE_MODELO
36
+ : 'opus';
37
+
38
+ /** Os argumentos do modelo, ou nenhum quando a escolha é do dono da conta. */
39
+ function argsDoModelo() {
40
+ return MODELO_CLAUDE ? ['--model', MODELO_CLAUDE] : [];
41
+ }
42
+
20
43
  /**
21
44
  * Acha o ÚLTIMO bloco marcado no texto (pode vir ruído de terminal — prompt
22
45
  * ANSI, texto solto — antes e depois) e devolve o JSON de dentro dele.
@@ -101,6 +124,7 @@ function lerJSON(bruto) {
101
124
  }
102
125
 
103
126
  const catalogo = require('./catalogo');
127
+ const chaves = require('./chaves');
104
128
 
105
129
  /** Serializa lib/catalogo.js em texto — única fonte de verdade, sem
106
130
  * duplicar a lista de ferramentas num arquivo separado. */
@@ -473,21 +497,44 @@ function linhaDoHistorico(m) {
473
497
  return `${m.role}: ${m.content}`;
474
498
  }
475
499
 
476
- async function requestActViaHeadless(payload, onEvent) {
500
+ /**
501
+ * O protocolo INTEIRO, mais a conversa, num prompt só.
502
+ *
503
+ * A versão anterior dizia "use a skill primocode-engine" e confiava que o
504
+ * headless a carregaria — quando não carregava, o Claude respondia sem bloco
505
+ * nenhum e o turno morria. Prompt maior, mas o motor tem contexto de sobra e
506
+ * o formato SEMPRE chega.
507
+ *
508
+ * SEM o cabeçalho YAML da skill: ele começa com "---", e um prompt que começa
509
+ * com traço o parser de argumentos lê como OPÇÃO — "error: unknown option
510
+ * '---'" na cara do usuário, com o protocolo inteiro despejado no terminal.
511
+ * Foi exatamente o que aconteceu.
512
+ *
513
+ * Isto é o que o /claude e o /codex têm em comum, e é de propósito que more
514
+ * num lugar só: são dois motores, e tem de ser UM protocolo.
515
+ */
516
+ function corpoDoProtocolo() {
517
+ return textoBootstrap().replace(/^---[\s\S]*?---\s*/, '');
518
+ }
519
+
520
+ /** O pedido de conserto: o protocolo de novo, mais o que saiu errado. Sem a
521
+ * conversa — o assunto aqui é a FORMA, e repetir o histórico inteiro só
522
+ * gastaria contexto no que não é o problema. */
523
+ function promptDeCorrecao(aviso) {
524
+ return corpoDoProtocolo() + '\n\n' + aviso;
525
+ }
526
+
527
+ function promptDoProtocolo(payload) {
477
528
  const historico = (payload.messages || []).map(linhaDoHistorico).join('\n\n');
478
- // O protocolo vai INTEIRO no prompt. A versão anterior dizia "use a skill
479
- // primocode-engine" e confiava que o headless a carregaria — quando não
480
- // carregava, o Claude respondia sem bloco nenhum e o turno morria. Prompt
481
- // maior, mas o Claude Code tem contexto de sobra, e o formato SEMPRE chega.
482
- // SEM o cabeçalho YAML da skill: ele começa com "---", e um prompt que
483
- // começa com traço o parser de argumentos do claude lê como OPÇÃO —
484
- // "error: unknown option '---'" na cara do usuário, com o protocolo
485
- // inteiro despejado no terminal. Foi exatamente o que aconteceu.
486
- const corpoBootstrap = textoBootstrap().replace(/^---[\s\S]*?---\s*/, '');
487
- const prompt = corpoBootstrap
529
+ return corpoDoProtocolo()
488
530
  + '\n\nIMPORTANTE: no JSON do bloco, escreva quebras de linha como \\n (escapadas), nunca linha de verdade.'
489
531
  + '\n\n# A conversa até aqui\n\n' + historico
490
532
  + '\n\nResponda com exatamente UM bloco marcado.';
533
+ }
534
+
535
+ async function requestActViaHeadless(payload, onEvent) {
536
+ const corpoBootstrap = corpoDoProtocolo();
537
+ const prompt = promptDoProtocolo(payload);
491
538
  // --dangerously-skip-permissions: mesma razão do caminho ORCA acima —
492
539
  // a skill já proíbe o Claude Code de usar suas próprias ferramentas
493
540
  // nativas; a flag só evita que ele pare pedindo permissão pra si mesmo
@@ -499,7 +546,9 @@ async function requestActViaHeadless(payload, onEvent) {
499
546
  '-p',
500
547
  '--output-format', 'json',
501
548
  '--dangerously-skip-permissions',
502
- ], { encoding: 'utf-8', timeout: 180000, input: prompt, maxBuffer: 32 * 1024 * 1024 });
549
+ ...argsDoModelo(),
550
+ ], { encoding: 'utf-8', timeout: 180000, input: prompt, maxBuffer: 32 * 1024 * 1024,
551
+ env: chaves.ambienteDe('claude') });
503
552
  const r = JSON.parse(saida);
504
553
  // A correção é uma segunda ida ao motor, com o mesmo protocolo e o
505
554
  // diagnóstico do que saiu errado. Barata: o prompt é curto, e evita que
@@ -507,9 +556,11 @@ async function requestActViaHeadless(payload, onEvent) {
507
556
  const pedirCorrecao = (aviso) => {
508
557
  const s = execFileSync('claude', [
509
558
  '-p', '--output-format', 'json', '--dangerously-skip-permissions',
559
+ ...argsDoModelo(),
510
560
  ], {
511
561
  encoding: 'utf-8', timeout: 120000, maxBuffer: 32 * 1024 * 1024,
512
- input: corpoBootstrap + '\n\n' + aviso,
562
+ env: chaves.ambienteDe('claude'),
563
+ input: promptDeCorrecao(aviso),
513
564
  });
514
565
  return JSON.parse(s).result || '';
515
566
  };
@@ -543,6 +594,13 @@ async function requestAct(server, payload, onEvent) {
543
594
  module.exports = {
544
595
  MARCA_INICIO, MARCA_FIM, extrairDecisao,
545
596
  catalogoParaTexto, textoBootstrap,
597
+ // O protocolo sai daqui para o lib/codex-engine.js usar o MESMO. Ele é o
598
+ // que faz um motor de fora virar cérebro do PrimoCode: o texto que
599
+ // ensina o formato, a leitura do bloco marcado e a volta para consertar
600
+ // o que veio torto. Reimplementar aquilo no outro motor seria manter dois
601
+ // protocolos que divergem na primeira correção de um deles.
602
+ linhaDoHistorico, nomesDasTools, promptDoProtocolo, promptDeCorrecao,
603
+ MODELO_CLAUDE, argsDoModelo,
546
604
  SKILL_DIR, SKILL_PATH, garantirSkill,
547
605
  temClaudeCode, temOrca, orcaAlcancavel, escolherCaminho,
548
606
  garantirEngine, requestAct, emitirDecisao,