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/app/app.css +175 -106
- package/app/app.js +171 -4
- package/app/index.html +125 -48
- package/app/primo-marca.js +113 -0
- package/bin/primocode.js +133 -18
- package/lib/api.js +67 -3
- package/lib/app.js +39 -1
- package/lib/boas-vindas.js +161 -0
- package/lib/chaves.js +149 -0
- package/lib/claude-engine.js +71 -13
- package/lib/codex-engine.js +201 -0
- package/lib/config.js +26 -1
- package/lib/modo.js +43 -3
- package/package.json +2 -2
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
|
-
|
|
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
|
+
};
|
package/lib/claude-engine.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|