primocode 8.30.0 → 8.32.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/bin/primocode.js +167 -5
- 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/nuvem.js +407 -0
- package/package.json +2 -2
- package/studio/__pycache__/voz.cpython-311.pyc +0 -0
package/bin/primocode.js
CHANGED
|
@@ -21,12 +21,15 @@ const tools = require('../lib/tools');
|
|
|
21
21
|
const cfg = require('../lib/config');
|
|
22
22
|
const { runAct, interromper } = require('../lib/act');
|
|
23
23
|
const claudeEngine = require('../lib/claude-engine');
|
|
24
|
+
const codexEngine = require('../lib/codex-engine');
|
|
25
|
+
const chaves = require('../lib/chaves');
|
|
24
26
|
const pasta = require('../lib/pasta');
|
|
25
27
|
const catalogo = require('../lib/catalogo');
|
|
26
28
|
const effort = require('../lib/effort');
|
|
27
29
|
const memoria = require('../lib/memoria-conversa');
|
|
28
30
|
const appLib = require('../lib/app');
|
|
29
31
|
const conta = require('../lib/conta');
|
|
32
|
+
const nuvem = require('../lib/nuvem');
|
|
30
33
|
|
|
31
34
|
// Rede de segurança de último recurso: sem isto, uma exceção fora de um
|
|
32
35
|
// try/catch (ex.: dentro de um callback de socket, como o parser de SSE) não
|
|
@@ -56,11 +59,14 @@ const state = {
|
|
|
56
59
|
// O resumo incremental da conversa — um /compact automático. O porquê
|
|
57
60
|
// está em lib/memoria-conversa.js.
|
|
58
61
|
memoria: memoria.nova(),
|
|
62
|
+
// O histórico que o app desktop e o celular enxergam. Cifrado antes de
|
|
63
|
+
// sair daqui; o porquê de cada peça está em lib/nuvem.js.
|
|
64
|
+
gravador: nuvem.gravador(),
|
|
59
65
|
lastResponse: '',
|
|
60
66
|
currentProject: null,
|
|
61
67
|
workDir: null, // pasta onde o agente trabalha nesta sessão
|
|
62
68
|
workDirOrigem: null, // 'padrao' | '/dir' | 'detectada' | 'projeto' — para o /dir explicar
|
|
63
|
-
engine: 'server', // 'server' (OpenRouter/Groq) | 'claude-code' (/claude)
|
|
69
|
+
engine: 'server', // 'server' (OpenRouter/Groq) | 'claude-code' (/claude) | 'codex' (/codex)
|
|
64
70
|
};
|
|
65
71
|
|
|
66
72
|
// A janela do /app, quando aberta. Uma por sessão: o segundo /app traz a que
|
|
@@ -82,7 +88,11 @@ ${c.bold('Comandos')}
|
|
|
82
88
|
${c.brand('/run')} <comando> executa no shell do projeto
|
|
83
89
|
${c.brand('/open')} <arquivo|url> abre no navegador
|
|
84
90
|
${c.brand('/app')} abre a janela do PrimoCode (chat, sem terminal)
|
|
91
|
+
${c.brand('/claude')} usa o Claude Code da SUA conta como motor (Opus)
|
|
92
|
+
${c.brand('/codex')} usa o Codex da SUA conta como motor (modelo barato)
|
|
93
|
+
${c.brand('/chave')} <provedor> sua própria chave de API (Claude, Codex)
|
|
85
94
|
${c.brand('/entrar')} entra na sua conta Conecta Primo · ${c.brand('/conta')} mostra o plano
|
|
95
|
+
${c.brand('/conversas')} [n] suas conversas salvas — as mesmas do app e do celular
|
|
86
96
|
${c.brand('/sair')} desconecta este computador
|
|
87
97
|
${c.brand('/status')} checa o servidor
|
|
88
98
|
${c.brand('/name')} <nome> como o Primo te chama
|
|
@@ -125,8 +135,12 @@ const SLASH_COMANDOS = [
|
|
|
125
135
|
['/run', 'roda um comando'],
|
|
126
136
|
['/open', 'abre no navegador'],
|
|
127
137
|
['/app', 'abre a janela do PrimoCode'],
|
|
138
|
+
['/claude', 'usa o Claude Code da sua conta como motor'],
|
|
139
|
+
['/codex', 'usa o Codex da sua conta como motor'],
|
|
140
|
+
['/chave', 'sua própria chave de API (Claude, Codex)'],
|
|
128
141
|
['/entrar', 'entra na sua conta Conecta Primo'],
|
|
129
142
|
['/conta', 'sua conta, plano e computadores conectados'],
|
|
143
|
+
['/conversas', 'suas conversas salvas, as mesmas do app e do celular'],
|
|
130
144
|
['/sair', 'desconecta este computador'],
|
|
131
145
|
['/status', 'checa o servidor'],
|
|
132
146
|
['/name', 'como ele te chama'],
|
|
@@ -477,7 +491,7 @@ async function sendPrompt(userText) {
|
|
|
477
491
|
// Só chamado se o agente for mesmo MEXER EM ARQUIVO — abrir o Chrome
|
|
478
492
|
// não é motivo para criar pasta de projeto.
|
|
479
493
|
ensureWorkDir: plan.pending ? () => createPendingProject(plan) : null,
|
|
480
|
-
motor:
|
|
494
|
+
motor: motorDaVez(),
|
|
481
495
|
});
|
|
482
496
|
|
|
483
497
|
r = await rodarPasso();
|
|
@@ -498,6 +512,10 @@ async function sendPrompt(userText) {
|
|
|
498
512
|
state.history.push({ role: 'assistant', content: r.assistantMessage.content });
|
|
499
513
|
state.lastResponse = r.assistantMessage.content;
|
|
500
514
|
cfg.appendHistory({ mode: 'primocode', project: state.currentProject, prompt: userText });
|
|
515
|
+
// Sobe cifrado, atrás, sem segurar o prompt. Se não houver conta ou
|
|
516
|
+
// rede, não acontece nada — a conversa segue aqui do mesmo jeito.
|
|
517
|
+
state.gravador.turno(userText, r.assistantMessage.content,
|
|
518
|
+
{ modo: 'primocode', projeto: state.currentProject || null });
|
|
501
519
|
} else {
|
|
502
520
|
await comprimirConversa();
|
|
503
521
|
await runChat(prompt, userText);
|
|
@@ -640,11 +658,23 @@ async function runChat(promptForModel, userTextForMemory) {
|
|
|
640
658
|
state.history.push({ role: 'assistant', content: text });
|
|
641
659
|
state.lastResponse = text;
|
|
642
660
|
cfg.appendHistory({ mode: 'chat', prompt: userTextForMemory });
|
|
661
|
+
state.gravador.turno(userTextForMemory, text, { modo: 'chat' });
|
|
643
662
|
}
|
|
644
663
|
}
|
|
645
664
|
|
|
646
665
|
// ── Slash commands ─────────────────────────────────────────────────────
|
|
647
666
|
|
|
667
|
+
/* Qual motor executa este turno.
|
|
668
|
+
*
|
|
669
|
+
* Um `if` a mais aqui e um `if` a menos em qualquer outro lugar seria o
|
|
670
|
+
* caminho para o /codex funcionar no modo agente e não no chat, ou ao
|
|
671
|
+
* contrário. Um lugar só decide, e os dois modos chamam este. */
|
|
672
|
+
function motorDaVez() {
|
|
673
|
+
if (state.engine === 'claude-code') return claudeEngine;
|
|
674
|
+
if (state.engine === 'codex') return codexEngine;
|
|
675
|
+
return api;
|
|
676
|
+
}
|
|
677
|
+
|
|
648
678
|
function isSlash(text) { return text.trim().startsWith('/'); }
|
|
649
679
|
|
|
650
680
|
async function handleSlash(text) {
|
|
@@ -744,6 +774,72 @@ async function handleSlash(text) {
|
|
|
744
774
|
break;
|
|
745
775
|
}
|
|
746
776
|
|
|
777
|
+
/* A chave de API da própria pessoa. Guardada em ~/.primocode/
|
|
778
|
+
chaves.json com permissão 600 — nunca no config.json, que é o
|
|
779
|
+
arquivo que se cola num chamado de suporte. */
|
|
780
|
+
case 'chave': case 'chaves': {
|
|
781
|
+
const [qual, ...resto] = rest.split(/\s+/).filter(Boolean);
|
|
782
|
+
const valor = resto.join(' ');
|
|
783
|
+
|
|
784
|
+
if (!qual) {
|
|
785
|
+
console.log(c.white('\n Suas chaves de API'));
|
|
786
|
+
for (const p of chaves.listar()) {
|
|
787
|
+
console.log(' ' + c.white(p.nome.padEnd(8))
|
|
788
|
+
+ (p.tem ? c.ok(p.disfarce) : c.dim('não configurada'))
|
|
789
|
+
+ c.dim(` ${p.variavel}`));
|
|
790
|
+
}
|
|
791
|
+
console.log(c.dim('\n /chave claude sk-ant-… guarda a chave'));
|
|
792
|
+
console.log(c.dim(' /chave codex remover apaga a que estiver lá'));
|
|
793
|
+
console.log(c.muted('\n Sem chave própria, cada motor usa a conta em que você já entrou nele.'));
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
796
|
+
if (!chaves.PROVEDORES[qual]) {
|
|
797
|
+
console.log(c.err(` Não conheço "${qual}". Use: ${Object.keys(chaves.PROVEDORES).join(', ')}`));
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
800
|
+
if (valor === 'remover' || valor === 'apagar') {
|
|
801
|
+
const r = chaves.esquecer(qual);
|
|
802
|
+
console.log(r.ok ? c.ok(` Chave do ${chaves.PROVEDORES[qual].nome} apagada.`)
|
|
803
|
+
: c.muted(` ${r.error}`));
|
|
804
|
+
break;
|
|
805
|
+
}
|
|
806
|
+
if (!valor) {
|
|
807
|
+
const p = chaves.PROVEDORES[qual];
|
|
808
|
+
console.log(c.muted(` Uso: `) + c.brand(`/chave ${qual} ${p.prefixo}…`));
|
|
809
|
+
console.log(c.dim(` Pegue a sua em ${p.onde}`));
|
|
810
|
+
break;
|
|
811
|
+
}
|
|
812
|
+
const r = chaves.guardar(qual, valor);
|
|
813
|
+
if (!r.ok) { console.log(c.err(` ${r.error}`)); break; }
|
|
814
|
+
// O que se mostra de volta é o disfarce, nunca a chave: a linha
|
|
815
|
+
// fica no scrollback do terminal e no print que a pessoa manda.
|
|
816
|
+
console.log(c.ok(` Chave do ${r.nome} guardada.`) + c.muted(` ${r.disfarce}`));
|
|
817
|
+
console.log(c.dim(` Ela vai como ${chaves.PROVEDORES[qual].variavel} só quando o motor rodar.`));
|
|
818
|
+
break;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
/* O Codex da conta da pessoa, com o modelo barato.
|
|
822
|
+
O protocolo é o MESMO do /claude — ver lib/codex-engine.js. */
|
|
823
|
+
case 'codex': {
|
|
824
|
+
if (rest.toLowerCase() === 'off') {
|
|
825
|
+
if (state.engine !== 'codex') { console.log(c.muted(' Já estava no motor padrão.')); break; }
|
|
826
|
+
state.engine = 'server';
|
|
827
|
+
console.log(c.ok(' Motor: OpenRouter/Groq (padrão)'));
|
|
828
|
+
break;
|
|
829
|
+
}
|
|
830
|
+
console.log(c.dim(' Checando o Codex…'));
|
|
831
|
+
const r = codexEngine.garantirEngine();
|
|
832
|
+
if (!r.ok) {
|
|
833
|
+
console.log(c.err(` ${r.error}`));
|
|
834
|
+
if (r.comoResolver) console.log(c.muted(` ${r.comoResolver}`));
|
|
835
|
+
break;
|
|
836
|
+
}
|
|
837
|
+
state.engine = 'codex';
|
|
838
|
+
console.log(c.ok(` Motor: Codex (${r.modelo})`) + c.muted(' — na sua conta, não na minha'));
|
|
839
|
+
console.log(c.muted(' /codex off volta pro motor padrão.'));
|
|
840
|
+
break;
|
|
841
|
+
}
|
|
842
|
+
|
|
747
843
|
case 'claude': {
|
|
748
844
|
if (rest.toLowerCase() === 'off') {
|
|
749
845
|
if (state.engine !== 'claude-code') { console.log(c.muted(' Já estava no motor padrão.')); break; }
|
|
@@ -760,7 +856,8 @@ async function handleSlash(text) {
|
|
|
760
856
|
}
|
|
761
857
|
state.engine = 'claude-code';
|
|
762
858
|
const onde = r.caminho === 'A' ? 'sessão contínua via ORCA' : 'chamada avulsa (claude -p, sem ORCA)';
|
|
763
|
-
console.log(c.ok(` Motor: Claude Code (${onde})`)
|
|
859
|
+
console.log(c.ok(` Motor: Claude Code (${onde})`)
|
|
860
|
+
+ c.muted(` — ${claudeEngine.MODELO_CLAUDE || 'o padrão da sua conta'}, na sua conta`));
|
|
764
861
|
console.log(c.muted(' /claude off volta pro motor padrão.'));
|
|
765
862
|
break;
|
|
766
863
|
}
|
|
@@ -909,7 +1006,11 @@ async function handleSlash(text) {
|
|
|
909
1006
|
break;
|
|
910
1007
|
}
|
|
911
1008
|
|
|
912
|
-
|
|
1009
|
+
// Limpar aqui não apaga o que já subiu: encerra ESTA conversa e a
|
|
1010
|
+
// próxima pergunta abre outra. Apagar do histórico é decisão da
|
|
1011
|
+
// pessoa, e ela toma no app — não de raspão num comando de limpar tela.
|
|
1012
|
+
case 'clear': state.history = []; state.memoria = memoria.nova();
|
|
1013
|
+
state.gravador.zerar(); console.log(c.ok('Conversa limpa.')); break;
|
|
913
1014
|
case 'cls': process.stdout.write('\x1Bc'); break;
|
|
914
1015
|
|
|
915
1016
|
case 'status': {
|
|
@@ -1043,6 +1144,55 @@ async function handleSlash(text) {
|
|
|
1043
1144
|
break;
|
|
1044
1145
|
}
|
|
1045
1146
|
|
|
1147
|
+
/* O histórico da conta — o MESMO que o app desktop e o celular
|
|
1148
|
+
mostram. Sem número, lista; com número, abre e continua dali. */
|
|
1149
|
+
case 'conversas': case 'historico': {
|
|
1150
|
+
if (!conta.atual()) {
|
|
1151
|
+
console.log(c.muted(' Não conectado. ') + c.brand('/entrar')
|
|
1152
|
+
+ c.muted(' para ver suas conversas aqui, no app e no celular.'));
|
|
1153
|
+
break;
|
|
1154
|
+
}
|
|
1155
|
+
const sp = new Spinner('lendo suas conversas').start();
|
|
1156
|
+
const l = await nuvem.listarConversas({ limite: 20 });
|
|
1157
|
+
sp.stop();
|
|
1158
|
+
if (!l.ok) { console.log(c.err(` ${l.error}`)); break; }
|
|
1159
|
+
if (!l.conversas.length) {
|
|
1160
|
+
console.log(c.muted(' Nenhuma conversa salva ainda. A próxima já fica.'));
|
|
1161
|
+
break;
|
|
1162
|
+
}
|
|
1163
|
+
if (!rest) {
|
|
1164
|
+
console.log(c.white('\n Suas conversas'));
|
|
1165
|
+
l.conversas.forEach((x, i) => {
|
|
1166
|
+
const quando = x.atualizada
|
|
1167
|
+
? new Date(x.atualizada).toLocaleDateString('pt-BR') : '—';
|
|
1168
|
+
console.log(' ' + c.dim(String(i + 1).padStart(2))
|
|
1169
|
+
+ ' ' + c.white(x.titulo.slice(0, 52).padEnd(52))
|
|
1170
|
+
+ c.dim(`${String(x.mensagens).padStart(3)} msg ${quando}`));
|
|
1171
|
+
});
|
|
1172
|
+
console.log(c.dim('\n /conversas 1 abre a primeira e continua dela.'));
|
|
1173
|
+
break;
|
|
1174
|
+
}
|
|
1175
|
+
const n = Number(rest);
|
|
1176
|
+
const alvo = l.conversas[n - 1];
|
|
1177
|
+
if (!alvo) { console.log(c.err(` Não achei a conversa ${rest}.`)); break; }
|
|
1178
|
+
const sp2 = new Spinner('abrindo').start();
|
|
1179
|
+
const conv = await nuvem.lerConversa(alvo.id);
|
|
1180
|
+
sp2.stop();
|
|
1181
|
+
if (!conv.ok) { console.log(c.err(` ${conv.error}`)); break; }
|
|
1182
|
+
// A memória entra ZERADA: o resumo antigo não veio junto, e um
|
|
1183
|
+
// resumo de outra conversa colado aqui faria o modelo responder
|
|
1184
|
+
// sobre coisa que não foi dita nesta.
|
|
1185
|
+
state.history = conv.mensagens.map((m) => ({
|
|
1186
|
+
role: m.papel, content: m.texto,
|
|
1187
|
+
}));
|
|
1188
|
+
state.memoria = memoria.nova();
|
|
1189
|
+
state.gravador.retomar(alvo.id, alvo.titulo, conv.mensagens.length);
|
|
1190
|
+
console.log(c.ok(` ${alvo.titulo}`)
|
|
1191
|
+
+ c.muted(` · ${conv.mensagens.length} mensagens carregadas`));
|
|
1192
|
+
console.log(c.dim(' Pode continuar daqui — o que você disser volta pro app e pro celular.'));
|
|
1193
|
+
break;
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1046
1196
|
case 'sair': case 'logout': {
|
|
1047
1197
|
if (!conta.atual()) { console.log(c.muted(' Você não está conectado.')); break; }
|
|
1048
1198
|
conta.sair();
|
|
@@ -1304,7 +1454,19 @@ async function main() {
|
|
|
1304
1454
|
function sayBye() {
|
|
1305
1455
|
if (saidBye) return;
|
|
1306
1456
|
saidBye = true;
|
|
1307
|
-
|
|
1457
|
+
// O último turno pode ainda estar subindo — gravar é de propósito
|
|
1458
|
+
// solto, para não segurar o prompt. Fechar o terminal em cima dele
|
|
1459
|
+
// perderia justamente a resposta que a pessoa acabou de ler, e ela
|
|
1460
|
+
// não veria falta nenhuma até procurar no celular.
|
|
1461
|
+
const fim = () => console.log(c.muted('\nAté logo!'));
|
|
1462
|
+
Promise.race([
|
|
1463
|
+
state.gravador.esperar(),
|
|
1464
|
+
// Rede pendurada não vira terminal pendurado. `unref` porque o
|
|
1465
|
+
// temporizador sozinho seguraria o processo por 3s mesmo quando a
|
|
1466
|
+
// gravação já terminou — um "Até logo!" seguido de três segundos
|
|
1467
|
+
// de nada é o tipo de coisa que quem usa chama de travado.
|
|
1468
|
+
new Promise((r) => { const t = setTimeout(r, 3000); if (t.unref) t.unref(); }),
|
|
1469
|
+
]).then(fim, fim);
|
|
1308
1470
|
}
|
|
1309
1471
|
|
|
1310
1472
|
async function drain() {
|
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,
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* codex-engine.js — motor do agente rodando no Codex da SUA conta.
|
|
3
|
+
*
|
|
4
|
+
* "Você pode usar a barra codex para usar os modelos com a sua conta. No
|
|
5
|
+
* Claude ele usa o modelo Opus 5; no Codex ele vai usar o modelo baratinho
|
|
6
|
+
* ali."
|
|
7
|
+
*
|
|
8
|
+
* ── É UM MOTOR NOVO, NÃO UM PROTOCOLO NOVO ───────────────────────────────
|
|
9
|
+
* O que ensina um modelo de fora a ser cérebro do PrimoCode — o texto do
|
|
10
|
+
* protocolo, a leitura do bloco marcado, a volta para consertar o que veio
|
|
11
|
+
* torto — mora inteiro no claude-engine.js e é IMPORTADO daqui. Copiar
|
|
12
|
+
* aquilo para cá daria dois protocolos, e o segundo começaria a divergir do
|
|
13
|
+
* primeiro na primeira correção feita só de um lado — sem erro nenhum
|
|
14
|
+
* aparecendo, só um dos motores ficando pior que o outro com o tempo.
|
|
15
|
+
*
|
|
16
|
+
* O que muda de verdade entre os dois é uma coisa só: qual programa se chama
|
|
17
|
+
* e com quais argumentos. É isso, e só isso, que este arquivo tem.
|
|
18
|
+
*
|
|
19
|
+
* ── POR QUE LER A SAÍDA COMO TEXTO CRU ───────────────────────────────────
|
|
20
|
+
* O `claude -p` tem `--output-format json` e o formato é estável. O `codex`
|
|
21
|
+
* mudou de forma mais de uma vez, e amarrar-se ao JSON dele seria escolher
|
|
22
|
+
* quebrar no dia em que ele mudar de novo — calado, porque a saída
|
|
23
|
+
* continuaria vindo, só que com outras chaves.
|
|
24
|
+
*
|
|
25
|
+
* Então aqui se lê o texto e se procura o bloco marcado nele, que é o que o
|
|
26
|
+
* `extrairDecisao` já faz: ele varre ruído de terminal e fica com o último
|
|
27
|
+
* bloco legível. Enquanto o Codex escrever o bloco em algum lugar da saída,
|
|
28
|
+
* este motor funciona — independente de como ele embrulhe o resto.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
const { execSync, execFileSync } = require('child_process');
|
|
34
|
+
|
|
35
|
+
const claude = require('./claude-engine');
|
|
36
|
+
const chaves = require('./chaves');
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* O modelo barato do Codex.
|
|
40
|
+
*
|
|
41
|
+
* O /claude vai no Opus porque ali o que se quer é a decisão mais certa. Aqui
|
|
42
|
+
* o pedido foi o contrário — "o modelo baratinho" — e faz sentido: quem liga
|
|
43
|
+
* o /codex está usando a franquia da própria conta, e o motor do PrimoCode dá
|
|
44
|
+
* muitas voltas por tarefa.
|
|
45
|
+
*
|
|
46
|
+
* Vazio manda sem `--model`, e vale o padrão da conta. E se o Codex recusar
|
|
47
|
+
* este modelo, o motor tenta de novo SEM ele em vez de morrer — ver
|
|
48
|
+
* `chamarCodex`.
|
|
49
|
+
*/
|
|
50
|
+
const MODELO_CODEX = process.env.PRIMOCODE_CODEX_MODELO !== undefined
|
|
51
|
+
? process.env.PRIMOCODE_CODEX_MODELO
|
|
52
|
+
: 'gpt-5-mini';
|
|
53
|
+
|
|
54
|
+
/** O programa. Trocável para o teste pôr um de mentira no lugar. */
|
|
55
|
+
const CODEX = process.env.PRIMOCODE_CODEX_BIN || 'codex';
|
|
56
|
+
|
|
57
|
+
const TEMPO = 180000;
|
|
58
|
+
const BUFFER = 32 * 1024 * 1024;
|
|
59
|
+
|
|
60
|
+
function temCodex(executor = (cmd) => execSync(cmd, { stdio: ['ignore', 'pipe', 'ignore'] })) {
|
|
61
|
+
try { executor(`${CODEX} --version`); return true; }
|
|
62
|
+
catch { return false; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* O que o `codex exec` desta máquina aceita.
|
|
67
|
+
*
|
|
68
|
+
* Passar uma opção que a versão instalada não conhece não dá "opção
|
|
69
|
+
* desconhecida" e pronto: dá uma saída de ajuda no lugar da resposta, e o
|
|
70
|
+
* motor devolveria "não consegui ler a decisão" em toda rodada — com a
|
|
71
|
+
* pessoa sem nenhuma pista de que o problema é uma flag.
|
|
72
|
+
*
|
|
73
|
+
* Perguntar ao `--help` custa uma chamada por sessão e responde com o que
|
|
74
|
+
* ESTA versão faz, em vez do que a versão de hoje fazia quando isto foi
|
|
75
|
+
* escrito.
|
|
76
|
+
*/
|
|
77
|
+
let _ajuda = null;
|
|
78
|
+
function ajudaDoExec(executor) {
|
|
79
|
+
if (_ajuda !== null) return _ajuda;
|
|
80
|
+
const rodar = executor || ((args) => execFileSync(CODEX, args,
|
|
81
|
+
{ encoding: 'utf-8', timeout: 20000, stdio: ['ignore', 'pipe', 'ignore'] }));
|
|
82
|
+
try { _ajuda = String(rodar(['exec', '--help']) || ''); }
|
|
83
|
+
catch { _ajuda = ''; } // sem ajuda: segue com o mínimo
|
|
84
|
+
return _ajuda;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function esquecerAjuda() { _ajuda = null; }
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Os argumentos da chamada.
|
|
91
|
+
*
|
|
92
|
+
* `--skip-git-repo-check` só entra se esta versão o conhecer: sem ele, o
|
|
93
|
+
* Codex se recusa a rodar fora de um repositório git — e o PrimoCode trabalha
|
|
94
|
+
* em pasta comum o tempo todo.
|
|
95
|
+
*/
|
|
96
|
+
function argsDoExec({ comModelo = true, ajuda = null } = {}) {
|
|
97
|
+
const h = ajuda === null ? ajudaDoExec() : ajuda;
|
|
98
|
+
const args = ['exec'];
|
|
99
|
+
if (h.includes('--skip-git-repo-check')) args.push('--skip-git-repo-check');
|
|
100
|
+
// O Codex pede confirmação para agir na máquina. Aqui ele não age: o
|
|
101
|
+
// protocolo proíbe ferramenta nativa, quem executa é o PrimoCode. A opção
|
|
102
|
+
// só evita que ele pare esperando uma resposta que ninguém vai dar.
|
|
103
|
+
if (h.includes('--dangerously-bypass-approvals-and-sandbox')) {
|
|
104
|
+
args.push('--dangerously-bypass-approvals-and-sandbox');
|
|
105
|
+
} else if (h.includes('--full-auto')) {
|
|
106
|
+
args.push('--full-auto');
|
|
107
|
+
}
|
|
108
|
+
if (comModelo && MODELO_CODEX) args.push('--model', MODELO_CODEX);
|
|
109
|
+
// O prompt vai por STDIN: por argumento ele teria limite de tamanho (a
|
|
110
|
+
// conversa cresce) e qualquer texto começando com traço seria lido como
|
|
111
|
+
// opção. `-` é como o exec pede para ler da entrada.
|
|
112
|
+
args.push('-');
|
|
113
|
+
return args;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Um modelo que a conta não tem é o erro mais provável desta chamada — e o
|
|
117
|
+
* único que dá para consertar sozinho. */
|
|
118
|
+
function recusouOModelo(texto) {
|
|
119
|
+
return /model|modelo/i.test(texto)
|
|
120
|
+
&& /not (found|available|supported)|unknown|invalid|does not|inexistente|inválid/i.test(texto);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Chama o Codex e devolve o texto. Se ele recusar o modelo, tenta UMA vez sem
|
|
125
|
+
* `--model` e avisa — cair para o padrão da conta é melhor que a tarefa
|
|
126
|
+
* morrer por causa de um nome que mudou de catálogo.
|
|
127
|
+
*/
|
|
128
|
+
function chamarCodex(prompt, { executor, avisar = () => {} } = {}) {
|
|
129
|
+
// A chave própria da pessoa, quando ela configurou uma. Sem configuração
|
|
130
|
+
// o ambiente vai INTACTO — passar a variável vazia apagaria a chave que
|
|
131
|
+
// ela já tem exportada no shell, e o Codex pararia de funcionar por ter
|
|
132
|
+
// sido "configurado".
|
|
133
|
+
const rodar = executor || ((args, entrada) => execFileSync(CODEX, args, {
|
|
134
|
+
encoding: 'utf-8', timeout: TEMPO, input: entrada, maxBuffer: BUFFER,
|
|
135
|
+
env: chaves.ambienteDe('codex'),
|
|
136
|
+
}));
|
|
137
|
+
try {
|
|
138
|
+
return rodar(argsDoExec(), prompt);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
const detalhe = `${e.message || ''}\n${(e.stderr || '')}\n${(e.stdout || '')}`;
|
|
141
|
+
if (MODELO_CODEX && recusouOModelo(detalhe)) {
|
|
142
|
+
avisar(`o Codex não aceitou o modelo ${MODELO_CODEX} — indo com o padrão da sua conta`);
|
|
143
|
+
return rodar(argsDoExec({ comModelo: false }), prompt);
|
|
144
|
+
}
|
|
145
|
+
throw new Error(primeiraLinhaUtil(detalhe) || e.message);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** A primeira linha que diz alguma coisa. O erro do Codex costuma vir com
|
|
150
|
+
* banner, versão e caminho antes do motivo, e jogar tudo isso na tela é
|
|
151
|
+
* esconder o motivo no meio. */
|
|
152
|
+
function primeiraLinhaUtil(texto) {
|
|
153
|
+
for (const linha of String(texto || '').split('\n')) {
|
|
154
|
+
const l = linha.trim();
|
|
155
|
+
if (l && !/^(Command failed|codex\b|\s*$)/i.test(l)) return l.slice(0, 300);
|
|
156
|
+
}
|
|
157
|
+
return '';
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function garantirEngine() {
|
|
161
|
+
if (!temCodex()) {
|
|
162
|
+
return {
|
|
163
|
+
ok: false,
|
|
164
|
+
error: 'O Codex não está instalado nesta máquina.',
|
|
165
|
+
comoResolver: 'Instale o Codex CLI da OpenAI, entre na sua conta com `codex login`, '
|
|
166
|
+
+ 'e rode /codex de novo.',
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
return { ok: true, modelo: MODELO_CODEX || '(o padrão da sua conta)' };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Mesma forma de api.requestAct(server, payload, onEvent) — é isso que deixa
|
|
173
|
+
* o lib/act.js trocar de motor sem saber de nada disto. */
|
|
174
|
+
async function requestAct(server, payload, onEvent) {
|
|
175
|
+
try {
|
|
176
|
+
const pronto = garantirEngine();
|
|
177
|
+
if (!pronto.ok) {
|
|
178
|
+
onEvent({ erro: `${pronto.error} ${pronto.comoResolver || ''}`.trim() });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
const prompt = claude.promptDoProtocolo(payload);
|
|
182
|
+
const saida = chamarCodex(prompt, { avisar: (m) => onEvent({ aviso: m }) });
|
|
183
|
+
|
|
184
|
+
// A correção é uma segunda ida ao motor, com o mesmo protocolo e o
|
|
185
|
+
// diagnóstico do que saiu errado. Barata, e evita que um erro de forma
|
|
186
|
+
// do motor vire erro na cara de quem pediu a tarefa.
|
|
187
|
+
const pedirCorrecao = (aviso) => chamarCodex(claude.promptDeCorrecao(aviso));
|
|
188
|
+
|
|
189
|
+
await claude.emitirDecisao(saida, onEvent, pedirCorrecao, 0,
|
|
190
|
+
claude.nomesDasTools(payload));
|
|
191
|
+
} catch (e) {
|
|
192
|
+
onEvent({ erro: e.message });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
module.exports = {
|
|
197
|
+
MODELO_CODEX, CODEX,
|
|
198
|
+
temCodex, garantirEngine, requestAct,
|
|
199
|
+
argsDoExec, ajudaDoExec, esquecerAjuda,
|
|
200
|
+
chamarCodex, recusouOModelo, primeiraLinhaUtil,
|
|
201
|
+
};
|
package/lib/config.js
CHANGED
|
@@ -19,8 +19,21 @@ const ROOT_DIR = path.join(HOME, 'primocode');
|
|
|
19
19
|
const CONFIG_DIR = path.join(HOME, '.primocode');
|
|
20
20
|
const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
|
|
21
21
|
const HISTORY_FILE = path.join(CONFIG_DIR, 'history.jsonl');
|
|
22
|
+
const CONTA_FILE = path.join(CONFIG_DIR, 'conta.json');
|
|
22
23
|
const GENERAL_MEMORY = path.join(ROOT_DIR, 'memory-geral.md');
|
|
23
24
|
|
|
25
|
+
/* O token da conta, lido DIRETO do arquivo.
|
|
26
|
+
*
|
|
27
|
+
* Sem `require('./conta.js')` de propósito: o conta.js já carrega o
|
|
28
|
+
* config.js, e um exigir o outro nos dois sentidos deixa um dos dois com
|
|
29
|
+
* metade do módulo na mão — em Node isso não dá erro, dá `undefined` numa
|
|
30
|
+
* função no meio de um turno. Ler quatro linhas de JSON é mais barato que
|
|
31
|
+
* essa aula. */
|
|
32
|
+
function tokenDaConta() {
|
|
33
|
+
try { return JSON.parse(fs.readFileSync(CONTA_FILE, 'utf8')).token || null; }
|
|
34
|
+
catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
24
37
|
const GENERAL_MEMORY_TEMPLATE = `# Memória Geral
|
|
25
38
|
|
|
26
39
|
Informações que valem para todos os projetos. O PrimoCode lê este arquivo
|
|
@@ -254,7 +267,19 @@ function loadConfig() {
|
|
|
254
267
|
// Esforço, estilo Claude Code (/effort). É o que o usuário escolhe; o
|
|
255
268
|
// modelo é derivado dele. Guardado para durar entre sessões.
|
|
256
269
|
effort: process.env.PRIMOCODE_EFFORT || file.effort || 'medium',
|
|
257
|
-
token
|
|
270
|
+
/* O token que vai ao servidor.
|
|
271
|
+
*
|
|
272
|
+
* O da CONTA entra por último, como padrão de quem não configurou
|
|
273
|
+
* nada — é ele que faz o servidor reconhecer Premium/Super e liberar
|
|
274
|
+
* o teto. Entra AQUI e não em cada chamada porque são cinco lugares
|
|
275
|
+
* que mandam pedido: o sexto que alguém escrever esqueceria, e a
|
|
276
|
+
* pessoa veria o limite bater com a assinatura em dia.
|
|
277
|
+
*
|
|
278
|
+
* A ordem é a de quem manda mais: variável de ambiente ganha do
|
|
279
|
+
* arquivo, e o arquivo ganha da conta. Quem hospeda o próprio
|
|
280
|
+
* servidor e configurou um token não pode ter isso trocado por
|
|
281
|
+
* entrar numa conta. */
|
|
282
|
+
token: process.env.PRIMOCODE_TOKEN || file.token || tokenDaConta(),
|
|
258
283
|
autoApply: file.autoApply === true,
|
|
259
284
|
currentProject: file.currentProject || null,
|
|
260
285
|
userName: file.userName || null,
|
package/lib/nuvem.js
ADDED
|
@@ -0,0 +1,407 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nuvem.js — as conversas do PrimoCode no Firestore, cifradas.
|
|
3
|
+
*
|
|
4
|
+
* "As conversas que você vai ter vão ficar salvas no histórico do aplicativo
|
|
5
|
+
* desktop. Você vai poder acessá-las no terminal, e também acessá-las pelo
|
|
6
|
+
* celular e poder mexer pelo celular. Lembrando que tudo tem que estar
|
|
7
|
+
* criptografado."
|
|
8
|
+
*
|
|
9
|
+
* ── A CIFRA É A MESMA DO APP, BYTE POR BYTE ──────────────────────────────
|
|
10
|
+
* O `frontend/crypto.js` do Conecta Primo faz PBKDF2-SHA256 com 310.000
|
|
11
|
+
* voltas sobre o UID, com um sal de 16 bytes, e AES-256-GCM com o UID como
|
|
12
|
+
* dado autenticado. O sal viaja DENTRO do pacote (`s`), e é isso que faz uma
|
|
13
|
+
* conversa cifrada aqui abrir no celular — a chave se refaz de qualquer
|
|
14
|
+
* aparelho a partir do que está gravado.
|
|
15
|
+
*
|
|
16
|
+
* Reimplementar isso em Node não é escolha: o `crypto.subtle` do navegador não
|
|
17
|
+
* existe aqui do mesmo jeito, e o app não vai deixar de ser web. O que se pode
|
|
18
|
+
* fazer é garantir que as duas contas são a MESMA, e é o que o teste faz —
|
|
19
|
+
* ele cifra aqui, decifra no Chromium com o `crypto.js` de verdade, e ao
|
|
20
|
+
* contrário. Duas implementações que divergem em uma iteração produzem lixo
|
|
21
|
+
* ilegível sem erro em lugar nenhum.
|
|
22
|
+
*
|
|
23
|
+
* ── POR QUE FALAR DIRETO COM O FIRESTORE ─────────────────────────────────
|
|
24
|
+
* A alternativa era o servidor intermediar cada leitura e escrita, com uma
|
|
25
|
+
* autorização própria. Seria um SEGUNDO modelo de permissão ao lado das
|
|
26
|
+
* `firestore.rules`, e os dois divergiriam na primeira mudança delas. Aqui o
|
|
27
|
+
* CLI troca o token do PrimoCode por uma sessão curta (custom token → token de
|
|
28
|
+
* acesso) e usa a API REST: as regras do banco valem para o terminal
|
|
29
|
+
* exatamente como valem para o app e para o celular.
|
|
30
|
+
*
|
|
31
|
+
* ── ZERO DEPENDÊNCIA ─────────────────────────────────────────────────────
|
|
32
|
+
* Nada de `firebase-admin` nem do SDK web. O que se usa é o `crypto` e o
|
|
33
|
+
* `https` do Node, contra a API REST do Firestore e a do Identity Toolkit.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
'use strict';
|
|
37
|
+
|
|
38
|
+
const crypto = require('crypto');
|
|
39
|
+
const https = require('https');
|
|
40
|
+
const http = require('http');
|
|
41
|
+
|
|
42
|
+
const conta = require('./conta.js');
|
|
43
|
+
|
|
44
|
+
/* Os dois endereços saem daqui em vez de estarem escritos no meio do código.
|
|
45
|
+
Não é para trocar de nuvem: é para o teste conseguir pôr um Firestore de
|
|
46
|
+
mentira no lugar e CONFERIR O QUE SOBE. Sem isso, a única forma de saber se
|
|
47
|
+
um título vai em claro seria olhar o console do Firebase depois — que é
|
|
48
|
+
exatamente tarde demais. */
|
|
49
|
+
const FIRESTORE = process.env.PRIMOCODE_FIRESTORE || 'https://firestore.googleapis.com';
|
|
50
|
+
const IDENTIDADE = process.env.PRIMOCODE_IDENTITY || 'https://identitytoolkit.googleapis.com';
|
|
51
|
+
|
|
52
|
+
const VERSAO_CIFRA = 2; // o mesmo `v` do frontend/crypto.js
|
|
53
|
+
const ITERACOES = 310000;
|
|
54
|
+
const TAM_IV = 12;
|
|
55
|
+
|
|
56
|
+
// ── a cifra ──────────────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
function derivar(uid, sal) {
|
|
59
|
+
return crypto.pbkdf2Sync(Buffer.from(uid, 'utf8'), sal, ITERACOES, 32, 'sha256');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/* Cifra um texto no formato que o app grava e lê: { v, iv, ct, s }.
|
|
63
|
+
*
|
|
64
|
+
* O UID entra como AAD (dado autenticado, não cifrado): é o que impede mover
|
|
65
|
+
* um registro cifrado de uma conta para outra — a decifragem falha em vez de
|
|
66
|
+
* devolver o texto de outra pessoa. */
|
|
67
|
+
function cifrar(texto, uid, salDado) {
|
|
68
|
+
const sal = salDado || crypto.randomBytes(16);
|
|
69
|
+
const chave = derivar(uid, sal);
|
|
70
|
+
const iv = crypto.randomBytes(TAM_IV);
|
|
71
|
+
const c = crypto.createCipheriv('aes-256-gcm', chave, iv);
|
|
72
|
+
c.setAAD(Buffer.from(uid, 'utf8'));
|
|
73
|
+
const corpo = Buffer.concat([c.update(Buffer.from(String(texto ?? ''), 'utf8')), c.final()]);
|
|
74
|
+
// O WebCrypto põe a etiqueta de autenticação NO FIM do texto cifrado; o
|
|
75
|
+
// Node a entrega à parte. Sem colar as duas, o navegador recusa tudo o que
|
|
76
|
+
// o terminal escrever — e o erro sai como "conversa corrompida".
|
|
77
|
+
const ct = Buffer.concat([corpo, c.getAuthTag()]);
|
|
78
|
+
return { v: VERSAO_CIFRA, iv: iv.toString('base64'), ct: ct.toString('base64'),
|
|
79
|
+
s: sal.toString('base64') };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function decifrar(pacote, uid) {
|
|
83
|
+
if (!pacote || !pacote.ct) return '';
|
|
84
|
+
const sal = Buffer.from(pacote.s || '', 'base64');
|
|
85
|
+
const chave = derivar(uid, sal);
|
|
86
|
+
const bruto = Buffer.from(pacote.ct, 'base64');
|
|
87
|
+
// Os últimos 16 bytes são a etiqueta — a mesma separação, ao contrário.
|
|
88
|
+
const etiqueta = bruto.subarray(bruto.length - 16);
|
|
89
|
+
const corpo = bruto.subarray(0, bruto.length - 16);
|
|
90
|
+
const d = crypto.createDecipheriv('aes-256-gcm', chave,
|
|
91
|
+
Buffer.from(pacote.iv, 'base64'));
|
|
92
|
+
d.setAAD(Buffer.from(uid, 'utf8'));
|
|
93
|
+
d.setAuthTag(etiqueta);
|
|
94
|
+
return Buffer.concat([d.update(corpo), d.final()]).toString('utf8');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const cifrarJson = (obj, uid) => cifrar(JSON.stringify(obj), uid);
|
|
98
|
+
const decifrarJson = (p, uid) => JSON.parse(decifrar(p, uid));
|
|
99
|
+
|
|
100
|
+
// ── a sessão do Firestore ────────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function httpsJson(url, metodo, corpo, cabecalhos = {}, timeout = 20000) {
|
|
103
|
+
return new Promise((resolve, reject) => {
|
|
104
|
+
const u = new URL(url);
|
|
105
|
+
const lib = u.protocol === 'https:' ? https : http;
|
|
106
|
+
const dados = corpo == null ? null : Buffer.from(JSON.stringify(corpo), 'utf8');
|
|
107
|
+
const h = { Accept: 'application/json', ...cabecalhos };
|
|
108
|
+
if (dados) { h['Content-Type'] = 'application/json'; h['Content-Length'] = dados.length; }
|
|
109
|
+
const req = lib.request({
|
|
110
|
+
hostname: u.hostname, port: u.port || undefined,
|
|
111
|
+
path: u.pathname + u.search, method: metodo, timeout, headers: h,
|
|
112
|
+
}, (res) => {
|
|
113
|
+
let t = '';
|
|
114
|
+
res.setEncoding('utf8');
|
|
115
|
+
res.on('data', (c) => { t += c; });
|
|
116
|
+
res.on('end', () => {
|
|
117
|
+
let j = null;
|
|
118
|
+
try { j = JSON.parse(t); } catch { /* pode vir vazio */ }
|
|
119
|
+
if (res.statusCode >= 400) {
|
|
120
|
+
const m = (j && j.error && (j.error.message || j.error)) || `HTTP ${res.statusCode}`;
|
|
121
|
+
return reject(new Error(String(m)));
|
|
122
|
+
}
|
|
123
|
+
resolve(j || {});
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
req.on('timeout', () => req.destroy(new Error('o Firestore demorou demais')));
|
|
127
|
+
req.on('error', reject);
|
|
128
|
+
if (dados) req.write(dados);
|
|
129
|
+
req.end();
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
let _sessao = null; // { uid, idToken, expira, projeto }
|
|
134
|
+
|
|
135
|
+
/* Abre (ou renova) a sessão. O token de acesso do Firebase dura uma hora;
|
|
136
|
+
renovar cinco minutos antes evita a escrita que falha no meio de uma
|
|
137
|
+
conversa longa — e falhar ali perde a mensagem, não só o pedido. */
|
|
138
|
+
async function abrir() {
|
|
139
|
+
if (_sessao && Date.now() < _sessao.expira - 300000) return _sessao;
|
|
140
|
+
const s = await conta.sessao();
|
|
141
|
+
if (!s.ok) return null;
|
|
142
|
+
if (!s.apiKey || !s.projeto) {
|
|
143
|
+
// O servidor não está configurado para isto (falta FIREBASE_API_KEY /
|
|
144
|
+
// FIREBASE_PROJECT_ID no ambiente dele). Dizer é melhor que tentar e
|
|
145
|
+
// falhar com "requisição inválida" mais adiante.
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
const r = await httpsJson(
|
|
149
|
+
`${IDENTIDADE}/v1/accounts:signInWithCustomToken?key=${s.apiKey}`,
|
|
150
|
+
'POST', { token: s.customToken, returnSecureToken: true });
|
|
151
|
+
_sessao = {
|
|
152
|
+
uid: s.uid, projeto: s.projeto, plano: s.plano, pro: s.pro,
|
|
153
|
+
idToken: r.idToken,
|
|
154
|
+
expira: Date.now() + (Number(r.expiresIn || 3600) * 1000),
|
|
155
|
+
};
|
|
156
|
+
return _sessao;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function esquecer() { _sessao = null; }
|
|
160
|
+
|
|
161
|
+
const BASE = (proj) => `${FIRESTORE}/v1/projects/${proj}/databases/(default)/documents`;
|
|
162
|
+
|
|
163
|
+
/* O Firestore REST embrulha cada valor num objeto de tipo. Só três tipos
|
|
164
|
+
aparecem aqui, e é de propósito: tudo que é conteúdo já vem cifrado num
|
|
165
|
+
`mapValue`, e o que fica em claro é o mínimo que a regra confere (`role`)
|
|
166
|
+
e o que ordena (`timestamp`). */
|
|
167
|
+
function paraFirestore(v) {
|
|
168
|
+
if (v === null || v === undefined) return { nullValue: null };
|
|
169
|
+
if (typeof v === 'string') return { stringValue: v };
|
|
170
|
+
if (typeof v === 'number') return Number.isInteger(v)
|
|
171
|
+
? { integerValue: String(v) } : { doubleValue: v };
|
|
172
|
+
if (typeof v === 'boolean') return { booleanValue: v };
|
|
173
|
+
if (Array.isArray(v)) return { arrayValue: { values: v.map(paraFirestore) } };
|
|
174
|
+
const fields = {};
|
|
175
|
+
for (const [k, x] of Object.entries(v)) fields[k] = paraFirestore(x);
|
|
176
|
+
return { mapValue: { fields } };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function doFirestore(v) {
|
|
180
|
+
if (!v || typeof v !== 'object') return v;
|
|
181
|
+
if ('stringValue' in v) return v.stringValue;
|
|
182
|
+
if ('integerValue' in v) return Number(v.integerValue);
|
|
183
|
+
if ('doubleValue' in v) return v.doubleValue;
|
|
184
|
+
if ('booleanValue' in v) return v.booleanValue;
|
|
185
|
+
if ('nullValue' in v) return null;
|
|
186
|
+
if ('arrayValue' in v) return (v.arrayValue.values || []).map(doFirestore);
|
|
187
|
+
if ('mapValue' in v) {
|
|
188
|
+
const o = {};
|
|
189
|
+
for (const [k, x] of Object.entries(v.mapValue.fields || {})) o[k] = doFirestore(x);
|
|
190
|
+
return o;
|
|
191
|
+
}
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const campos = (obj) => {
|
|
196
|
+
const f = {};
|
|
197
|
+
for (const [k, v] of Object.entries(obj)) f[k] = paraFirestore(v);
|
|
198
|
+
return f;
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
// ── as conversas ─────────────────────────────────────────────────────────
|
|
202
|
+
|
|
203
|
+
const COLECAO = 'primocode';
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Abre (ou continua) uma conversa. O TÍTULO sobe cifrado, como no app.
|
|
207
|
+
*
|
|
208
|
+
* Os campos são exatamente os que a regra aceita — `enc`, `createdAt`,
|
|
209
|
+
* `updatedAt`, `messageCount`. Um campo a mais e a gravação é RECUSADA em
|
|
210
|
+
* produção, o que é o preço declarado da lista de permitidos: recusa é
|
|
211
|
+
* ruidosa e se conserta em minutos; conteúdo em claro é silencioso.
|
|
212
|
+
*/
|
|
213
|
+
async function salvarConversa({ id, titulo, mensagens = 0 }) {
|
|
214
|
+
const s = await abrir();
|
|
215
|
+
if (!s) return { ok: false, error: 'não conectado' };
|
|
216
|
+
const ident = id || ('pc' + Date.now().toString(36) + crypto.randomBytes(4).toString('hex'));
|
|
217
|
+
const agora = new Date().toISOString();
|
|
218
|
+
const corpo = {
|
|
219
|
+
fields: campos({
|
|
220
|
+
enc: cifrarJson({ titulo: String(titulo || 'Conversa') }, s.uid),
|
|
221
|
+
updatedAt: agora,
|
|
222
|
+
messageCount: mensagens,
|
|
223
|
+
...(id ? {} : { createdAt: agora }),
|
|
224
|
+
}),
|
|
225
|
+
};
|
|
226
|
+
// `updateMask` para não apagar `createdAt` ao atualizar: um PATCH sem ele
|
|
227
|
+
// substitui o documento inteiro, e a data de criação sumiria na primeira
|
|
228
|
+
// mensagem seguinte.
|
|
229
|
+
const mascara = Object.keys(corpo.fields).map((k) => `updateMask.fieldPaths=${k}`).join('&');
|
|
230
|
+
try {
|
|
231
|
+
await httpsJson(
|
|
232
|
+
`${BASE(s.projeto)}/users/${s.uid}/${COLECAO}/${ident}?${mascara}`,
|
|
233
|
+
'PATCH', corpo, { Authorization: `Bearer ${s.idToken}` });
|
|
234
|
+
return { ok: true, id: ident };
|
|
235
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Guarda uma mensagem. `papel` é 'user' ou 'assistant' — a regra confere.
|
|
240
|
+
* `feito` é o que o agente fez na volta (arquivos, comandos), e vai cifrado
|
|
241
|
+
* em `wenc`, no mesmo lugar em que o app guarda os cartões.
|
|
242
|
+
*/
|
|
243
|
+
async function salvarMensagem({ conversa, papel, texto, feito }) {
|
|
244
|
+
const s = await abrir();
|
|
245
|
+
if (!s) return { ok: false, error: 'não conectado' };
|
|
246
|
+
if (papel !== 'user' && papel !== 'assistant') {
|
|
247
|
+
return { ok: false, error: `papel inválido: ${papel}` };
|
|
248
|
+
}
|
|
249
|
+
const doc = { enc: cifrar(texto, s.uid), role: papel, timestamp: new Date().toISOString() };
|
|
250
|
+
if (feito) doc.wenc = cifrarJson(feito, s.uid);
|
|
251
|
+
try {
|
|
252
|
+
const r = await httpsJson(
|
|
253
|
+
`${BASE(s.projeto)}/users/${s.uid}/${COLECAO}/${conversa}/messages`,
|
|
254
|
+
'POST', { fields: campos(doc) }, { Authorization: `Bearer ${s.idToken}` });
|
|
255
|
+
return { ok: true, id: (r.name || '').split('/').pop() };
|
|
256
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** As conversas mais recentes, com o título já decifrado. */
|
|
260
|
+
async function listarConversas({ limite = 20 } = {}) {
|
|
261
|
+
const s = await abrir();
|
|
262
|
+
if (!s) return { ok: false, error: 'não conectado' };
|
|
263
|
+
try {
|
|
264
|
+
const r = await httpsJson(
|
|
265
|
+
`${BASE(s.projeto)}/users/${s.uid}/${COLECAO}?pageSize=${Math.min(100, limite)}`
|
|
266
|
+
+ '&orderBy=updatedAt%20desc',
|
|
267
|
+
'GET', null, { Authorization: `Bearer ${s.idToken}` });
|
|
268
|
+
const fora = [];
|
|
269
|
+
for (const d of r.documents || []) {
|
|
270
|
+
const campos2 = {};
|
|
271
|
+
for (const [k, v] of Object.entries(d.fields || {})) campos2[k] = doFirestore(v);
|
|
272
|
+
let titulo = '(sem título)';
|
|
273
|
+
// Uma conversa que não decifra NÃO derruba a lista: ela pode ter
|
|
274
|
+
// vindo de uma versão anterior da cifra, e mostrar as outras é
|
|
275
|
+
// melhor que mostrar nenhuma.
|
|
276
|
+
try { titulo = decifrarJson(campos2.enc, s.uid).titulo || titulo; } catch {}
|
|
277
|
+
fora.push({
|
|
278
|
+
id: (d.name || '').split('/').pop(),
|
|
279
|
+
titulo,
|
|
280
|
+
mensagens: campos2.messageCount || 0,
|
|
281
|
+
atualizada: campos2.updatedAt || null,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return { ok: true, conversas: fora };
|
|
285
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/** As mensagens de uma conversa, em ordem, decifradas. */
|
|
289
|
+
async function lerConversa(id, { limite = 200 } = {}) {
|
|
290
|
+
const s = await abrir();
|
|
291
|
+
if (!s) return { ok: false, error: 'não conectado' };
|
|
292
|
+
try {
|
|
293
|
+
const r = await httpsJson(
|
|
294
|
+
`${BASE(s.projeto)}/users/${s.uid}/${COLECAO}/${id}/messages`
|
|
295
|
+
+ `?pageSize=${Math.min(300, limite)}&orderBy=timestamp`,
|
|
296
|
+
'GET', null, { Authorization: `Bearer ${s.idToken}` });
|
|
297
|
+
const msgs = [];
|
|
298
|
+
for (const d of r.documents || []) {
|
|
299
|
+
const f = {};
|
|
300
|
+
for (const [k, v] of Object.entries(d.fields || {})) f[k] = doFirestore(v);
|
|
301
|
+
try {
|
|
302
|
+
msgs.push({
|
|
303
|
+
papel: f.role,
|
|
304
|
+
texto: decifrar(f.enc, s.uid),
|
|
305
|
+
feito: f.wenc ? decifrarJson(f.wenc, s.uid) : null,
|
|
306
|
+
quando: f.timestamp,
|
|
307
|
+
});
|
|
308
|
+
} catch { /* mensagem ilegível: pula, não derruba a conversa */ }
|
|
309
|
+
}
|
|
310
|
+
return { ok: true, mensagens: msgs };
|
|
311
|
+
} catch (e) { return { ok: false, error: e.message }; }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ── o gravador da sessão ─────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
/** Título a partir da primeira frase: uma linha, curta, sem cortar no meio de
|
|
317
|
+
* uma palavra quando dá para não cortar. */
|
|
318
|
+
function tituloDe(texto) {
|
|
319
|
+
const limpo = String(texto || '').replace(/\s+/g, ' ').trim();
|
|
320
|
+
if (!limpo) return 'Conversa';
|
|
321
|
+
if (limpo.length <= 60) return limpo;
|
|
322
|
+
const corte = limpo.slice(0, 60);
|
|
323
|
+
const espaco = corte.lastIndexOf(' ');
|
|
324
|
+
return (espaco > 30 ? corte.slice(0, espaco) : corte) + '…';
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/**
|
|
328
|
+
* Acompanha a sessão do terminal e vai subindo os turnos.
|
|
329
|
+
*
|
|
330
|
+
* ── POR QUE UMA FILA ─────────────────────────────────────────────────────
|
|
331
|
+
* Gravar não pode segurar o prompt: são três idas à rede por turno, e ninguém
|
|
332
|
+
* quer esperar um segundo para digitar a próxima frase. Então `turno()` volta
|
|
333
|
+
* na hora e o trabalho acontece atrás.
|
|
334
|
+
*
|
|
335
|
+
* Só que "atrás" e "em paralelo" não são a mesma coisa. Dois turnos soltos ao
|
|
336
|
+
* mesmo tempo criariam DUAS conversas — cada um veria `id` ainda vazio — e as
|
|
337
|
+
* mensagens chegariam fora de ordem. A fila é uma promessa só, encadeada: o
|
|
338
|
+
* segundo turno começa quando o primeiro acabou, sempre.
|
|
339
|
+
*
|
|
340
|
+
* ── POR QUE ENGOLIR O ERRO ───────────────────────────────────────────────
|
|
341
|
+
* Sem conta, sem rede, servidor fora: a conversa continua funcionando no
|
|
342
|
+
* terminal e simplesmente não sobe. Derrubar o turno de trabalho de alguém
|
|
343
|
+
* porque o histórico não salvou seria trocar um incômodo por um prejuízo.
|
|
344
|
+
*/
|
|
345
|
+
function gravador() {
|
|
346
|
+
let id = null;
|
|
347
|
+
let titulo = null;
|
|
348
|
+
let contagem = 0;
|
|
349
|
+
let fila = Promise.resolve();
|
|
350
|
+
let ultimoErro = null;
|
|
351
|
+
|
|
352
|
+
const enfileirar = (f) => {
|
|
353
|
+
fila = fila.then(f).catch((e) => { ultimoErro = e.message; });
|
|
354
|
+
return fila;
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
return {
|
|
358
|
+
get id() { return id; },
|
|
359
|
+
get mensagens() { return contagem; },
|
|
360
|
+
get erro() { return ultimoErro; },
|
|
361
|
+
|
|
362
|
+
/** Continua uma conversa que já existe (veio do app ou do celular). */
|
|
363
|
+
retomar(idExistente, tit, quantas = 0) {
|
|
364
|
+
id = idExistente; titulo = tit || null; contagem = quantas;
|
|
365
|
+
},
|
|
366
|
+
/** `/clear`: a próxima pergunta abre uma conversa nova. */
|
|
367
|
+
zerar() { id = null; titulo = null; contagem = 0; },
|
|
368
|
+
|
|
369
|
+
turno(pergunta, resposta, feito) {
|
|
370
|
+
return enfileirar(async () => {
|
|
371
|
+
const s = await abrir();
|
|
372
|
+
if (!s) return; // sem conta: fica só no local
|
|
373
|
+
if (!titulo) titulo = tituloDe(pergunta);
|
|
374
|
+
if (!id) {
|
|
375
|
+
const r = await salvarConversa({ titulo });
|
|
376
|
+
if (!r.ok) { ultimoErro = r.error; return; }
|
|
377
|
+
id = r.id;
|
|
378
|
+
}
|
|
379
|
+
const a = await salvarMensagem({ conversa: id, papel: 'user', texto: pergunta });
|
|
380
|
+
if (!a.ok) { ultimoErro = a.error; return; }
|
|
381
|
+
const b = await salvarMensagem({ conversa: id, papel: 'assistant',
|
|
382
|
+
texto: resposta, feito });
|
|
383
|
+
if (!b.ok) { ultimoErro = b.error; return; }
|
|
384
|
+
contagem += 2;
|
|
385
|
+
// A contagem e a data de mudança são o que ordena a lista no
|
|
386
|
+
// celular. Sem esta volta, a conversa de hoje aparece embaixo
|
|
387
|
+
// das de semana passada.
|
|
388
|
+
await salvarConversa({ id, titulo, mensagens: contagem });
|
|
389
|
+
});
|
|
390
|
+
},
|
|
391
|
+
|
|
392
|
+
/** O que ainda está subindo. Chamado na saída, para não perder o
|
|
393
|
+
* último turno quando alguém fecha o terminal logo depois. */
|
|
394
|
+
esperar() { return fila; },
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
module.exports = {
|
|
399
|
+
cifrar, decifrar, cifrarJson, decifrarJson,
|
|
400
|
+
gravador, tituloDe,
|
|
401
|
+
abrir, esquecer,
|
|
402
|
+
salvarConversa, salvarMensagem, listarConversas, lerConversa,
|
|
403
|
+
COLECAO, VERSAO_CIFRA, ITERACOES, TAM_IV,
|
|
404
|
+
// A tradução para o formato do Firestore sai daqui porque é ela que decide
|
|
405
|
+
// o que vira `stringValue` em claro e o que vira `mapValue` cifrado.
|
|
406
|
+
paraFirestore, doFirestore,
|
|
407
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "8.
|
|
3
|
+
"version": "8.32.0",
|
|
4
4
|
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
|
|
5
5
|
"main": "bin/primocode.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/primocode.js",
|
|
11
|
-
"test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js && node test/app.test.js && node test/elementos.test.js && node test/remotion.test.js && node test/aplicativo.test.js && node test/conta.test.mjs"
|
|
11
|
+
"test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js && node test/editor.test.js && node test/estudio-projeto.test.js && node test/vocabulario.test.js && node test/video-export.test.js && node test/monotonia.test.js && node test/clipe.test.js && node test/midia-envio.test.js && node test/pausas.test.js && node test/app.test.js && node test/elementos.test.js && node test/remotion.test.js && node test/aplicativo.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/codex.test.js && node test/chaves.test.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|
|
Binary file
|