primocode 8.30.0 → 8.31.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 +78 -2
- 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
|
@@ -27,6 +27,7 @@ const effort = require('../lib/effort');
|
|
|
27
27
|
const memoria = require('../lib/memoria-conversa');
|
|
28
28
|
const appLib = require('../lib/app');
|
|
29
29
|
const conta = require('../lib/conta');
|
|
30
|
+
const nuvem = require('../lib/nuvem');
|
|
30
31
|
|
|
31
32
|
// Rede de segurança de último recurso: sem isto, uma exceção fora de um
|
|
32
33
|
// try/catch (ex.: dentro de um callback de socket, como o parser de SSE) não
|
|
@@ -56,6 +57,9 @@ const state = {
|
|
|
56
57
|
// O resumo incremental da conversa — um /compact automático. O porquê
|
|
57
58
|
// está em lib/memoria-conversa.js.
|
|
58
59
|
memoria: memoria.nova(),
|
|
60
|
+
// O histórico que o app desktop e o celular enxergam. Cifrado antes de
|
|
61
|
+
// sair daqui; o porquê de cada peça está em lib/nuvem.js.
|
|
62
|
+
gravador: nuvem.gravador(),
|
|
59
63
|
lastResponse: '',
|
|
60
64
|
currentProject: null,
|
|
61
65
|
workDir: null, // pasta onde o agente trabalha nesta sessão
|
|
@@ -83,6 +87,7 @@ ${c.bold('Comandos')}
|
|
|
83
87
|
${c.brand('/open')} <arquivo|url> abre no navegador
|
|
84
88
|
${c.brand('/app')} abre a janela do PrimoCode (chat, sem terminal)
|
|
85
89
|
${c.brand('/entrar')} entra na sua conta Conecta Primo · ${c.brand('/conta')} mostra o plano
|
|
90
|
+
${c.brand('/conversas')} [n] suas conversas salvas — as mesmas do app e do celular
|
|
86
91
|
${c.brand('/sair')} desconecta este computador
|
|
87
92
|
${c.brand('/status')} checa o servidor
|
|
88
93
|
${c.brand('/name')} <nome> como o Primo te chama
|
|
@@ -127,6 +132,7 @@ const SLASH_COMANDOS = [
|
|
|
127
132
|
['/app', 'abre a janela do PrimoCode'],
|
|
128
133
|
['/entrar', 'entra na sua conta Conecta Primo'],
|
|
129
134
|
['/conta', 'sua conta, plano e computadores conectados'],
|
|
135
|
+
['/conversas', 'suas conversas salvas, as mesmas do app e do celular'],
|
|
130
136
|
['/sair', 'desconecta este computador'],
|
|
131
137
|
['/status', 'checa o servidor'],
|
|
132
138
|
['/name', 'como ele te chama'],
|
|
@@ -498,6 +504,10 @@ async function sendPrompt(userText) {
|
|
|
498
504
|
state.history.push({ role: 'assistant', content: r.assistantMessage.content });
|
|
499
505
|
state.lastResponse = r.assistantMessage.content;
|
|
500
506
|
cfg.appendHistory({ mode: 'primocode', project: state.currentProject, prompt: userText });
|
|
507
|
+
// Sobe cifrado, atrás, sem segurar o prompt. Se não houver conta ou
|
|
508
|
+
// rede, não acontece nada — a conversa segue aqui do mesmo jeito.
|
|
509
|
+
state.gravador.turno(userText, r.assistantMessage.content,
|
|
510
|
+
{ modo: 'primocode', projeto: state.currentProject || null });
|
|
501
511
|
} else {
|
|
502
512
|
await comprimirConversa();
|
|
503
513
|
await runChat(prompt, userText);
|
|
@@ -640,6 +650,7 @@ async function runChat(promptForModel, userTextForMemory) {
|
|
|
640
650
|
state.history.push({ role: 'assistant', content: text });
|
|
641
651
|
state.lastResponse = text;
|
|
642
652
|
cfg.appendHistory({ mode: 'chat', prompt: userTextForMemory });
|
|
653
|
+
state.gravador.turno(userTextForMemory, text, { modo: 'chat' });
|
|
643
654
|
}
|
|
644
655
|
}
|
|
645
656
|
|
|
@@ -909,7 +920,11 @@ async function handleSlash(text) {
|
|
|
909
920
|
break;
|
|
910
921
|
}
|
|
911
922
|
|
|
912
|
-
|
|
923
|
+
// Limpar aqui não apaga o que já subiu: encerra ESTA conversa e a
|
|
924
|
+
// próxima pergunta abre outra. Apagar do histórico é decisão da
|
|
925
|
+
// pessoa, e ela toma no app — não de raspão num comando de limpar tela.
|
|
926
|
+
case 'clear': state.history = []; state.memoria = memoria.nova();
|
|
927
|
+
state.gravador.zerar(); console.log(c.ok('Conversa limpa.')); break;
|
|
913
928
|
case 'cls': process.stdout.write('\x1Bc'); break;
|
|
914
929
|
|
|
915
930
|
case 'status': {
|
|
@@ -1043,6 +1058,55 @@ async function handleSlash(text) {
|
|
|
1043
1058
|
break;
|
|
1044
1059
|
}
|
|
1045
1060
|
|
|
1061
|
+
/* O histórico da conta — o MESMO que o app desktop e o celular
|
|
1062
|
+
mostram. Sem número, lista; com número, abre e continua dali. */
|
|
1063
|
+
case 'conversas': case 'historico': {
|
|
1064
|
+
if (!conta.atual()) {
|
|
1065
|
+
console.log(c.muted(' Não conectado. ') + c.brand('/entrar')
|
|
1066
|
+
+ c.muted(' para ver suas conversas aqui, no app e no celular.'));
|
|
1067
|
+
break;
|
|
1068
|
+
}
|
|
1069
|
+
const sp = new Spinner('lendo suas conversas').start();
|
|
1070
|
+
const l = await nuvem.listarConversas({ limite: 20 });
|
|
1071
|
+
sp.stop();
|
|
1072
|
+
if (!l.ok) { console.log(c.err(` ${l.error}`)); break; }
|
|
1073
|
+
if (!l.conversas.length) {
|
|
1074
|
+
console.log(c.muted(' Nenhuma conversa salva ainda. A próxima já fica.'));
|
|
1075
|
+
break;
|
|
1076
|
+
}
|
|
1077
|
+
if (!rest) {
|
|
1078
|
+
console.log(c.white('\n Suas conversas'));
|
|
1079
|
+
l.conversas.forEach((x, i) => {
|
|
1080
|
+
const quando = x.atualizada
|
|
1081
|
+
? new Date(x.atualizada).toLocaleDateString('pt-BR') : '—';
|
|
1082
|
+
console.log(' ' + c.dim(String(i + 1).padStart(2))
|
|
1083
|
+
+ ' ' + c.white(x.titulo.slice(0, 52).padEnd(52))
|
|
1084
|
+
+ c.dim(`${String(x.mensagens).padStart(3)} msg ${quando}`));
|
|
1085
|
+
});
|
|
1086
|
+
console.log(c.dim('\n /conversas 1 abre a primeira e continua dela.'));
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
const n = Number(rest);
|
|
1090
|
+
const alvo = l.conversas[n - 1];
|
|
1091
|
+
if (!alvo) { console.log(c.err(` Não achei a conversa ${rest}.`)); break; }
|
|
1092
|
+
const sp2 = new Spinner('abrindo').start();
|
|
1093
|
+
const conv = await nuvem.lerConversa(alvo.id);
|
|
1094
|
+
sp2.stop();
|
|
1095
|
+
if (!conv.ok) { console.log(c.err(` ${conv.error}`)); break; }
|
|
1096
|
+
// A memória entra ZERADA: o resumo antigo não veio junto, e um
|
|
1097
|
+
// resumo de outra conversa colado aqui faria o modelo responder
|
|
1098
|
+
// sobre coisa que não foi dita nesta.
|
|
1099
|
+
state.history = conv.mensagens.map((m) => ({
|
|
1100
|
+
role: m.papel, content: m.texto,
|
|
1101
|
+
}));
|
|
1102
|
+
state.memoria = memoria.nova();
|
|
1103
|
+
state.gravador.retomar(alvo.id, alvo.titulo, conv.mensagens.length);
|
|
1104
|
+
console.log(c.ok(` ${alvo.titulo}`)
|
|
1105
|
+
+ c.muted(` · ${conv.mensagens.length} mensagens carregadas`));
|
|
1106
|
+
console.log(c.dim(' Pode continuar daqui — o que você disser volta pro app e pro celular.'));
|
|
1107
|
+
break;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1046
1110
|
case 'sair': case 'logout': {
|
|
1047
1111
|
if (!conta.atual()) { console.log(c.muted(' Você não está conectado.')); break; }
|
|
1048
1112
|
conta.sair();
|
|
@@ -1304,7 +1368,19 @@ async function main() {
|
|
|
1304
1368
|
function sayBye() {
|
|
1305
1369
|
if (saidBye) return;
|
|
1306
1370
|
saidBye = true;
|
|
1307
|
-
|
|
1371
|
+
// O último turno pode ainda estar subindo — gravar é de propósito
|
|
1372
|
+
// solto, para não segurar o prompt. Fechar o terminal em cima dele
|
|
1373
|
+
// perderia justamente a resposta que a pessoa acabou de ler, e ela
|
|
1374
|
+
// não veria falta nenhuma até procurar no celular.
|
|
1375
|
+
const fim = () => console.log(c.muted('\nAté logo!'));
|
|
1376
|
+
Promise.race([
|
|
1377
|
+
state.gravador.esperar(),
|
|
1378
|
+
// Rede pendurada não vira terminal pendurado. `unref` porque o
|
|
1379
|
+
// temporizador sozinho seguraria o processo por 3s mesmo quando a
|
|
1380
|
+
// gravação já terminou — um "Até logo!" seguido de três segundos
|
|
1381
|
+
// de nada é o tipo de coisa que quem usa chama de travado.
|
|
1382
|
+
new Promise((r) => { const t = setTimeout(r, 3000); if (t.unref) t.unref(); }),
|
|
1383
|
+
]).then(fim, fim);
|
|
1308
1384
|
}
|
|
1309
1385
|
|
|
1310
1386
|
async function drain() {
|
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.31.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"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|
|
Binary file
|