primocode 11.2.0 → 11.3.1
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/README.md +2 -2
- package/lib/agentes-pessoais.js +5 -2
- package/lib/app-server/cli.js +6 -0
- package/lib/app-server/enderecos.js +43 -0
- package/lib/app-server/servidor.js +174 -31
- package/lib/avisos.js +1 -0
- package/lib/cli/sessao.js +174 -16
- package/lib/config/config.js +16 -1
- package/lib/diff.js +12 -2
- package/lib/motor/fala-viva.js +77 -0
- package/lib/motor/processo-vivo.js +66 -0
- package/lib/motor/transcricao.js +81 -0
- package/lib/plugin/entregaveis/nucleo.js +2 -2
- package/lib/plugin/estudio/gosto.js +3 -0
- package/lib/plugin/estudio/kits/tela.html +3 -1
- package/lib/projetos-meta.js +20 -3
- package/lib/session/runner/act.js +23 -4
- package/lib/tool/tools.js +1 -0
- package/lib/tui/entrada.js +143 -18
- package/lib/tui/tela.js +312 -49
- package/lib/tui/ui.js +77 -3
- package/package.json +6 -2
- package/studio/voz_kokoro_vivo.py +46 -0
- package/voz/primo_voz/transcrever.py +37 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v11.
|
|
1
|
+
# PrimoCode v11.3.1
|
|
2
2
|
|
|
3
3
|
Agente de engenharia brasileiro para o terminal, no estilo Claude Code.
|
|
4
4
|
Cria arquivos de verdade, roda comandos, **controla o navegador e o desktop** —
|
|
@@ -9,7 +9,7 @@ OpenRouter, e cada apelido (`top`/`main`/`fast`) é uma **corrente**: se um mode
|
|
|
9
9
|
está no limite de pedidos, o próximo atende. Você não vê o erro, e não paga nada.
|
|
10
10
|
|
|
11
11
|
```
|
|
12
|
-
▐▛███▜▌ Primo Code v11.
|
|
12
|
+
▐▛███▜▌ Primo Code v11.3.1
|
|
13
13
|
▝▜█████▛▘ Bem-vindo de volta, Joel
|
|
14
14
|
▘▘ ▝▝ ~/primocode · /dir <pasta> muda
|
|
15
15
|
```
|
package/lib/agentes-pessoais.js
CHANGED
|
@@ -7,7 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const os = require('os');
|
|
8
8
|
const path = require('path');
|
|
9
9
|
|
|
10
|
-
const ICONES = ['workflow', 'film', 'palette', 'pen', 'folder', 'shield', 'bell', 'sliders', 'home'];
|
|
10
|
+
const ICONES = ['workflow', 'film', 'palette', 'pen', 'folder', 'shield', 'bell', 'sliders', 'home', 'scan-search', 'flask-conical', 'git-branch', 'pen-line', 'scissors', 'captions', 'trending-up', 'send', 'sparkles'];
|
|
11
11
|
const hexOk = (h) => /^#[0-9a-f]{6}$/i.test(String(h || ''));
|
|
12
12
|
const pasta = (dir) => dir || process.env.PRIMOCODE_AGENTES || path.join(os.homedir(), '.primocode', 'agentes');
|
|
13
13
|
|
|
@@ -44,7 +44,10 @@ function gravar(a, dir) {
|
|
|
44
44
|
function listar({ dir } = {}) {
|
|
45
45
|
let nomes = [];
|
|
46
46
|
try { nomes = fs.readdirSync(pasta(dir)).filter((n) => n.endsWith('.md')); } catch { /* ainda não há */ }
|
|
47
|
-
|
|
47
|
+
/* Na ordem em que foram criados (o arquivo mais antigo primeiro); empate por nome. */
|
|
48
|
+
const nasc = (n) => { try { return fs.statSync(path.join(pasta(dir), n)).birthtimeMs; } catch { return 0; } };
|
|
49
|
+
return nomes.map((n) => ({ n, t: nasc(n) })).sort((a, b) => a.t - b.t || a.n.localeCompare(b.n))
|
|
50
|
+
.map(({ n }) => ler(n.slice(0, -3), { dir })).filter(Boolean);
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
function validar(c, atual) {
|
package/lib/app-server/cli.js
CHANGED
|
@@ -39,7 +39,13 @@ async function main(args) {
|
|
|
39
39
|
const falso = process.env.PRIMOCODE_MOTOR_FALSO;
|
|
40
40
|
// PRIMOCODE_CONTA_FALSA=<arquivo.js>: { buscar, abrir } de mentira para o login (sem rede, sem navegador). Só para teste.
|
|
41
41
|
const contaFalsa = process.env.PRIMOCODE_CONTA_FALSA;
|
|
42
|
+
// PRIMOCODE_TRANSCRITOR_FALSO=<arquivo.js>: async (wav) => ({ ok, texto }) no lugar do Whisper local. Só para teste de tela.
|
|
43
|
+
const transcritorFalso = process.env.PRIMOCODE_TRANSCRITOR_FALSO;
|
|
44
|
+
// PRIMOCODE_FALA_FALSA=<arquivo.js>: { falar(texto) -> {ok,arquivo,mime}, aquecer(), disponivel() } no lugar da voz local. Só para teste.
|
|
45
|
+
const falaFalsa = process.env.PRIMOCODE_FALA_FALSA;
|
|
42
46
|
const srv = criarServidor({
|
|
47
|
+
...(falaFalsa ? { falaViva: require(require('path').resolve(falaFalsa)) } : {}),
|
|
48
|
+
...(transcritorFalso ? { transcritor: require(require('path').resolve(transcritorFalso)) } : {}),
|
|
43
49
|
...(falso ? { motor: require(require('path').resolve(falso)) } : {}),
|
|
44
50
|
...(contaFalsa ? { conta: require(require('path').resolve(contaFalsa)) } : {}),
|
|
45
51
|
});
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
// Endereços do computador para o pareamento do celular, na ordem em que valem a pena tentar.
|
|
2
|
+
// Achado (02/11): com VPN (Tailscale, 100.64.0.0/10) a lista vinha com o IP da VPN primeiro, o QR levava só ele e o celular
|
|
3
|
+
// no Wi-Fi não alcançava. Agora a rede local (192.168/16, 10/8, 172.16/12) vem antes, a VPN e os túneis por último,
|
|
4
|
+
// e o código do QR leva TODOS (o app do celular tenta um por um).
|
|
5
|
+
'use strict';
|
|
6
|
+
|
|
7
|
+
const partes = (ip) => String(ip).split('.').map(Number);
|
|
8
|
+
|
|
9
|
+
/* 0 = LAN doméstica/escritório (melhor), 1 = outro endereço roteável, 2 = VPN/túnel/link-local (último recurso). */
|
|
10
|
+
function classe(ip, nome = '') {
|
|
11
|
+
const [a, b] = partes(ip);
|
|
12
|
+
if (/^(utun|tun|tap|ppp|ipsec|wg|tailscale|zt|vpn)/i.test(String(nome)) || /tailscale|zerotier|wireguard/i.test(String(nome))) return 2;
|
|
13
|
+
if (a === 100 && b >= 64 && b <= 127) return 2; // CGNAT 100.64.0.0/10: Tailscale e afins
|
|
14
|
+
if (a === 169 && b === 254) return 2; // link-local
|
|
15
|
+
if (a === 192 && b === 168) return 0;
|
|
16
|
+
if (a === 10) return 0;
|
|
17
|
+
if (a === 172 && b >= 16 && b <= 31) return 0;
|
|
18
|
+
return 1;
|
|
19
|
+
}
|
|
20
|
+
const peso = (ip) => { const [a, b] = partes(ip); return a === 192 && b === 168 ? 0 : a === 10 ? 1 : a === 172 ? 2 : 3; };
|
|
21
|
+
|
|
22
|
+
/* `itens`: [{ ip, nome? }] ou ['ip', ...]. Estável e sem repetição. */
|
|
23
|
+
function ordenar(itens) {
|
|
24
|
+
const lista = [];
|
|
25
|
+
const vistos = new Set();
|
|
26
|
+
for (const x of itens || []) {
|
|
27
|
+
const ip = typeof x === 'string' ? x : x && x.ip;
|
|
28
|
+
if (!ip || vistos.has(ip) || !/^\d{1,3}(\.\d{1,3}){3}$/.test(ip)) continue;
|
|
29
|
+
vistos.add(ip);
|
|
30
|
+
lista.push({ ip, nome: typeof x === 'string' ? '' : x.nome || '', i: lista.length });
|
|
31
|
+
}
|
|
32
|
+
lista.sort((p, q) => (classe(p.ip, p.nome) - classe(q.ip, q.nome)) || (peso(p.ip) - peso(q.ip)) || (p.i - q.i));
|
|
33
|
+
return lista.map((x) => x.ip);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/* Os IPv4 externos das interfaces, já ordenados. */
|
|
37
|
+
function locais(interfaces) {
|
|
38
|
+
const itens = [];
|
|
39
|
+
for (const [nome, lista] of Object.entries(interfaces || {})) for (const i of lista || []) if (i.family === 'IPv4' && !i.internal) itens.push({ ip: i.address, nome });
|
|
40
|
+
return ordenar(itens);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
module.exports = { ordenar, locais, classe };
|
|
@@ -17,7 +17,7 @@ const { criarPortao } = require('../motor/aprovacao');
|
|
|
17
17
|
const { ERRO, erro } = P;
|
|
18
18
|
const MAX_LEITURA = 1024 * 1024;
|
|
19
19
|
const BLOQUEADOS = /(^|[\\/])(\.ssh|\.aws|\.gnupg|\.env[^\\/]*|app-server\.json|pareamentos\.json|config\.json)$/;
|
|
20
|
-
const CAMPOS_CONFIG = ['effort', 'model', 'mode', 'permissoes', 'userName', 'mascot', 'reacoes', 'theme', 'autoApply', 'piloto', 'grupo', 'inicioFeito'];
|
|
20
|
+
const CAMPOS_CONFIG = ['effort', 'model', 'mode', 'permissoes', 'userName', 'mascot', 'reacoes', 'theme', 'autoApply', 'piloto', 'grupo', 'inicioFeito', 'preferencias'];
|
|
21
21
|
const PILOTO_MS = Number(process.env.PRIMOCODE_PILOTO_MS) || 15 * 60000;
|
|
22
22
|
|
|
23
23
|
const RAIZ_TELA = path.join(__dirname, '..', '..', 'app', 'renderer');
|
|
@@ -26,13 +26,20 @@ const TIPOS_ESTATICOS = {
|
|
|
26
26
|
'.css': 'text/css; charset=utf-8', '.svg': 'image/svg+xml', '.png': 'image/png', '.json': 'application/json', '.ico': 'image/x-icon', '.woff2': 'font/woff2',
|
|
27
27
|
};
|
|
28
28
|
|
|
29
|
-
/* O que um celular pareado pode pedir ao motor
|
|
30
|
-
|
|
29
|
+
/* O que um celular pareado pode pedir ao motor. Desde 02/11 o app do celular tem TODAS as telas do desktop (Início, Studio, Projetos,
|
|
30
|
+
Agentes, Kit da marca, Avisos, Configurações, busca, voz), então as telas ganharam os pedidos delas, com limites:
|
|
31
|
+
config/write só nos campos de preferência (nunca permissões, piloto, modelo ou servidor), voice/start sem texto (não gera fala no
|
|
32
|
+
computador), fs/list e fs/read continuam de fora (arquivo do computador não vai para o celular).
|
|
33
|
+
Fica de fora: arquivos, permissões, piloto automático, abrir coisas no computador, conta (entrar/sair), pareamento, telas do Estúdio. */
|
|
31
34
|
const PAREADO_PODE = new Set([
|
|
32
|
-
'thread/start', 'thread/list', 'thread/resume', 'thread/rename', 'turn/start', 'turn/interrupt', 'turn/queue',
|
|
35
|
+
'thread/start', 'thread/list', 'thread/resume', 'thread/rename', 'thread/archive', 'turn/start', 'turn/interrupt', 'turn/queue',
|
|
33
36
|
'approval/respond', 'question/respond', 'notice/list', 'notice/read', 'studio/history', 'project/list', 'agent/list', 'config/read',
|
|
34
37
|
'account/status', // só ver se há conta; entrar, sair e o login pelo navegador são do computador
|
|
38
|
+
'project/create', 'project/update', 'project/delete', 'agent/create', 'agent/update', 'agent/delete', 'agent/toggle',
|
|
39
|
+
'brand/read', 'brand/write', 'config/write', 'voice/start', 'voice/stop', 'voice/transcribe',
|
|
40
|
+
'voice/warm', 'voice/frases', 'voice/speak', // modo voz ao vivo do celular: só gera áudio e devolve ao celular, nunca toca no computador
|
|
35
41
|
]);
|
|
42
|
+
const CAMPOS_CONFIG_PAREADO = ['effort', 'userName', 'mascot', 'reacoes', 'theme', 'preferencias'];
|
|
36
43
|
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
37
44
|
|
|
38
45
|
const texto = (v) => (typeof v === 'string' ? v : '');
|
|
@@ -47,7 +54,16 @@ function abrirNoSistema(abs) {
|
|
|
47
54
|
}
|
|
48
55
|
const MIDIAS = { '.mp4': 'video/mp4', '.webm': 'video/webm', '.mov': 'video/quicktime', '.pdf': 'application/pdf', '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp', '.gif': 'image/gif' };
|
|
49
56
|
|
|
50
|
-
|
|
57
|
+
/* O login do GitHub, lido do hosts.yml do `gh` (sem rodar o gh). Devolve o usuário ou null. */
|
|
58
|
+
function githubConectado() {
|
|
59
|
+
try {
|
|
60
|
+
const t = fs.readFileSync(path.join(os.homedir(), '.config', 'gh', 'hosts.yml'), 'utf8');
|
|
61
|
+
const m = /^\s+user:\s*(\S+)/m.exec(t);
|
|
62
|
+
return m ? m[1] : null;
|
|
63
|
+
} catch { return null; }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false, enderecos, conta: contaOp = {}, transcritor, falaViva } = {}) {
|
|
51
67
|
const cfg = require('../config/config');
|
|
52
68
|
const m = motor || require('../motor');
|
|
53
69
|
const base = casa || cfg.CONFIG_DIR;
|
|
@@ -67,6 +83,8 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
67
83
|
const Marca = require('../plugin/marca-kit');
|
|
68
84
|
const Piloto = require('../piloto');
|
|
69
85
|
const Voz = voz || require('../motor/voz').criarVoz({ pasta: path.join(base, 'voz') });
|
|
86
|
+
let Fala = falaViva || null; // a boca do modo voz ao vivo (lib/motor/fala-viva.js), criada só quando alguém fala
|
|
87
|
+
const falaDoModoVoz = () => (Fala = Fala || require('../motor/fala-viva').criarFalaViva({ pasta: path.join(base, 'voz') }));
|
|
70
88
|
let loginNavegador = null;
|
|
71
89
|
const statusConta = () => {
|
|
72
90
|
const a = Conta.atual();
|
|
@@ -82,6 +100,24 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
82
100
|
let http_ = null;
|
|
83
101
|
let porta = null;
|
|
84
102
|
|
|
103
|
+
/* A linha `primocode:abrir-gosto <url>` que o motor escreve no stderr (só com PRIMOCODE_APP=1, que o app liga) vira screen/open
|
|
104
|
+
para as telas do computador. O resto do stderr passa igual. */
|
|
105
|
+
let restoDoStderr = '';
|
|
106
|
+
const escreverStderr = process.stderr.write.bind(process.stderr);
|
|
107
|
+
const olharStderr = process.env.PRIMOCODE_APP === '1' && !process.env.PRIMOCODE_SEM_TELA_NO_APP;
|
|
108
|
+
function filtrarStderr(chunk) {
|
|
109
|
+
restoDoStderr = (restoDoStderr + String(chunk)).slice(-4000);
|
|
110
|
+
const linhas = restoDoStderr.split('\n');
|
|
111
|
+
restoDoStderr = linhas.pop();
|
|
112
|
+
for (const l of linhas) {
|
|
113
|
+
const url = require('../plugin/estudio/gosto').urlDaLinha(l);
|
|
114
|
+
if (url) for (const c of conexoes) { if (!c.pareado) { try { c.enviar(P.notificacao('screen/open', { url })); } catch { /* caiu */ } } }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (olharStderr) {
|
|
118
|
+
process.stderr.write = (chunk, ...resto) => { try { filtrarStderr(chunk); } catch { /* nunca quebra o log */ } return escreverStderr(chunk, ...resto); };
|
|
119
|
+
}
|
|
120
|
+
|
|
85
121
|
/* ── Conversas: um JSON por thread em ~/.primocode/threads ── */
|
|
86
122
|
const arquivoDe = (id) => path.join(pastaThreads, `${id}.json`);
|
|
87
123
|
function salvar(t) {
|
|
@@ -144,6 +180,27 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
144
180
|
};
|
|
145
181
|
}
|
|
146
182
|
|
|
183
|
+
/* Título da conversa (bug 2): depois do primeiro turno de uma conversa nova, quem leu a troca escreve o título
|
|
184
|
+
(lib/session/titulo-conversa.js) e a tela recebe thread/renamed. Falha ou demora: fica o provisório. */
|
|
185
|
+
function perguntarTitulo(prompt) {
|
|
186
|
+
if (typeof m.titular === 'function') return m.titular(prompt);
|
|
187
|
+
const c = cfg.loadConfig();
|
|
188
|
+
let resposta = '';
|
|
189
|
+
return require('../provider/api').requestChat(c.server, { prompt, history: [], model: c.model, token: c.token }, (evt) => { if (evt.delta) resposta += evt.delta; }).then(() => resposta);
|
|
190
|
+
}
|
|
191
|
+
function batizar(t, pedido, resposta) {
|
|
192
|
+
const provisorio = t.provisorio;
|
|
193
|
+
if (!provisorio) return;
|
|
194
|
+
t.provisorio = null;
|
|
195
|
+
const texto_ = typeof resposta === 'string' ? resposta : Array.isArray(resposta) ? resposta.map((x) => (x && x.text) || '').join('') : '';
|
|
196
|
+
require('../session/titulo-conversa').escolher({ pedido, resposta: texto_, perguntar: perguntarTitulo }).then((titulo) => {
|
|
197
|
+
if (!titulo || t.dados.titulo !== provisorio) return; // veio lixo, ou a pessoa já renomeou
|
|
198
|
+
t.dados.titulo = titulo.slice(0, 120);
|
|
199
|
+
salvar(t);
|
|
200
|
+
difundir('thread/renamed', { thread: t.dados.id, titulo: t.dados.titulo });
|
|
201
|
+
}).catch(() => { /* fica o provisório */ });
|
|
202
|
+
}
|
|
203
|
+
|
|
147
204
|
/* ── Caminhos: só dentro das raízes permitidas, sem segredos ── */
|
|
148
205
|
function caminhoSeguro(p) {
|
|
149
206
|
const abs = path.resolve(texto(p));
|
|
@@ -214,7 +271,8 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
214
271
|
const c = cfg.loadConfig();
|
|
215
272
|
const projeto = texto(p.projeto) || dados.projeto || undefined;
|
|
216
273
|
if (projeto) dados.projeto = projeto;
|
|
217
|
-
|
|
274
|
+
// O título provisório é o começo do pedido; quando o primeiro turno termina, o modelo escreve o de verdade (batizar).
|
|
275
|
+
if (dados.titulo === 'Nova conversa' && !dados.historico.length) { dados.titulo = pedido.replace(/\s+/g, ' ').slice(0, 60); t.provisorio = dados.titulo; }
|
|
218
276
|
|
|
219
277
|
// Menções (@arquivo) e anexos entram como instrução: não viram a fala da pessoa.
|
|
220
278
|
const partes = [];
|
|
@@ -247,6 +305,7 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
247
305
|
if (r && r.userMessage && !r.parado) {
|
|
248
306
|
dados.historico.push(r.userMessage);
|
|
249
307
|
if (r.assistantMessage) dados.historico.push(r.assistantMessage);
|
|
308
|
+
batizar(t, pedido, r.assistantMessage && r.assistantMessage.content);
|
|
250
309
|
}
|
|
251
310
|
}).catch((e) => {
|
|
252
311
|
// O motor já emite turn/interrupted com o erro; aqui só garante que a tela saiba.
|
|
@@ -313,7 +372,7 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
313
372
|
'brand/read'(p) {
|
|
314
373
|
const vista = (k) => ({
|
|
315
374
|
nome: k.nome, cores: k.cores || {}, fontes: k.fontes || {}, tom: k.tom || '', slogan: k.slogan || '',
|
|
316
|
-
site: k.site || '', redes: k.redes || '', temLogo: !!k.logo, contraste: Marca.validarContraste(k.cores || {}),
|
|
375
|
+
site: k.site || '', redes: k.redes || '', temLogo: !!k.logo, logo: k.logo || null, contraste: Marca.validarContraste(k.cores || {}),
|
|
317
376
|
});
|
|
318
377
|
const ativa = (() => { try { return fs.readFileSync(path.join(Marca.raiz(), '.ativa'), 'utf8').trim() || null; } catch { return null; } })();
|
|
319
378
|
if (p.nome) { const k = Marca.ler(p.nome); if (!k) throw erro(ERRO.NAO_ACHEI, `não há kit "${p.nome}"`); return { kit: vista(k), ativa }; }
|
|
@@ -328,7 +387,18 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
328
387
|
for (const k of ['tom', 'slogan', 'site', 'redes']) if (k in p) m[k] = texto(p[k]).slice(0, 300);
|
|
329
388
|
if (p.cores && typeof p.cores === 'object') m.cores = { ...(atual.cores || {}), ...Object.fromEntries(Object.entries(p.cores).map(([k, v]) => [k, Marca.normHex(v) || v])) };
|
|
330
389
|
if (p.fontes && typeof p.fontes === 'object') m.fontes = { ...(atual.fontes || {}), titulo: texto(p.fontes.titulo || (atual.fontes || {}).titulo), texto: texto(p.fontes.texto || (atual.fontes || {}).texto) };
|
|
331
|
-
|
|
390
|
+
// Logo vindo da tela: um data URL png ou svg de até 1 MB, gravado num arquivo temporário e copiado para o kit.
|
|
391
|
+
let logoArquivo = null;
|
|
392
|
+
if (p.logo && typeof p.logo.dataUrl === 'string') {
|
|
393
|
+
const mm = /^data:image\/(png|svg\+xml);base64,([A-Za-z0-9+/=]+)$/.exec(p.logo.dataUrl);
|
|
394
|
+
if (!mm) throw erro(ERRO.PARAMS, 'o logo tem que ser png ou svg');
|
|
395
|
+
const bytes = Buffer.from(mm[2], 'base64');
|
|
396
|
+
if (bytes.length > 1048576) throw erro(ERRO.PARAMS, 'o logo passa de 1 MB');
|
|
397
|
+
logoArquivo = path.join(os.tmpdir(), `primo-logo-${process.pid}-${Date.now()}.${mm[1] === 'png' ? 'png' : 'svg'}`);
|
|
398
|
+
fs.writeFileSync(logoArquivo, bytes, { mode: 0o600 });
|
|
399
|
+
}
|
|
400
|
+
let r;
|
|
401
|
+
try { r = Marca.salvar(m, { logoArquivo }); } finally { if (logoArquivo) { try { fs.unlinkSync(logoArquivo); } catch { /* já saiu */ } } }
|
|
332
402
|
if (!r.ok) throw erro(ERRO.PARAMS, r.error);
|
|
333
403
|
if (p.ativar) Marca.usar(nome);
|
|
334
404
|
return metodos['brand/read']({ nome });
|
|
@@ -364,18 +434,38 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
364
434
|
},
|
|
365
435
|
|
|
366
436
|
/* ── Pareamento do celular (só o computador, nunca um celular pareado) ── */
|
|
367
|
-
'pairing/info'() { return { rede: modoRede, porta, enderecos: modoRede ? enderecosLocais() : [], pareamentos: Pareamentos.listar() }; },
|
|
437
|
+
'pairing/info'() { return { rede: modoRede, porta: portaRede || porta, enderecos: modoRede ? enderecosLocais() : [], pareamentos: Pareamentos.listar() }; },
|
|
368
438
|
'pairing/create'(p) {
|
|
369
439
|
if (!modoRede) throw erro(ERRO.NEGADO, 'o servidor está só em 127.0.0.1. Suba com `primocode server --ws --rede` para parear um celular');
|
|
370
440
|
const ips = enderecosLocais();
|
|
371
441
|
if (!ips.length) throw erro(ERRO.NEGADO, 'não achei um endereço de rede local neste computador');
|
|
372
442
|
const r = Pareamentos.criar(p.nome);
|
|
373
|
-
const urls = ips.map((ip) => `ws://${ip}:${porta}`);
|
|
374
|
-
|
|
443
|
+
const urls = ips.map((ip) => `ws://${ip}:${portaRede || porta}`);
|
|
444
|
+
// O código leva TODOS os endereços: o primeiro vai no ws://, os outros em `&h=` (mesma porta). O app do celular tenta um por um.
|
|
445
|
+
const codigo = `${urls[0]}/?token=${r.token}${ips.length > 1 ? `&h=${ips.slice(1).join(',')}` : ''}`;
|
|
375
446
|
const q = require('../qr').gerarQR(codigo, { ecc: 'M' });
|
|
376
447
|
// A tela desenha o QR com estas linhas ('1' = módulo escuro); nenhum HTML vem do motor.
|
|
377
448
|
return { ...r, urls, codigo, qr: { tamanho: q.tamanho, linhas: q.modulos.map((l) => l.map((b) => (b ? '1' : '0')).join('')) } };
|
|
378
449
|
},
|
|
450
|
+
/* Telas do Estúdio dentro do app (estilos e gosto): o motor escreve a linha de protocolo no stderr (lib/plugin/estudio/gosto.js),
|
|
451
|
+
o servidor a vira `screen/open` e a tela mostra a página num quadro. `screen/dismiss` fecha pelo botão Fechar do quadro. */
|
|
452
|
+
async 'screen/dismiss'(p, conn) {
|
|
453
|
+
if (conn && conn.pareado) throw erro(ERRO.NEGADO, 'só o computador');
|
|
454
|
+
const url = require('../plugin/estudio/gosto').urlDaLinha(`primocode:abrir-gosto ${texto(p.url)}`);
|
|
455
|
+
if (!url) throw erro(ERRO.PARAMS, 'url inválida');
|
|
456
|
+
const u = new URL(url);
|
|
457
|
+
await new Promise((ok) => {
|
|
458
|
+
const rq = http.request({ host: '127.0.0.1', port: u.port, path: '/fechar', method: 'POST', headers: { 'x-gosto-token': u.searchParams.get('t'), 'content-length': 0 } }, (r) => { r.resume(); r.on('end', ok); });
|
|
459
|
+
rq.on('error', ok); rq.setTimeout(3000, () => { rq.destroy(); ok(); }); rq.end();
|
|
460
|
+
});
|
|
461
|
+
return { ok: true };
|
|
462
|
+
},
|
|
463
|
+
/* Liga/desliga o modo rede sem reiniciar (botão "Conectar celular"). Só o computador. */
|
|
464
|
+
async 'pairing/enable'(p, conn) {
|
|
465
|
+
if (conn && conn.pareado) throw erro(ERRO.NEGADO, 'só o computador');
|
|
466
|
+
const r = await mudarRede(p.ligar !== false);
|
|
467
|
+
return { ...r, enderecos: modoRede ? enderecosLocais() : [] };
|
|
468
|
+
},
|
|
379
469
|
'pairing/list'() { return { pareamentos: Pareamentos.listar() }; },
|
|
380
470
|
'pairing/revoke'(p) {
|
|
381
471
|
const id = texto(p.id);
|
|
@@ -391,15 +481,18 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
391
481
|
out.servidor = c.server;
|
|
392
482
|
out.temToken = !!c.token;
|
|
393
483
|
out.projetoAtual = c.currentProject;
|
|
484
|
+
out.github = githubConectado();
|
|
394
485
|
if (conn && conn.pareado) out.pareado = true;
|
|
395
486
|
return out;
|
|
396
487
|
},
|
|
397
|
-
'config/write'(p) {
|
|
488
|
+
'config/write'(p, conn) {
|
|
398
489
|
const mudar = {};
|
|
399
|
-
|
|
490
|
+
const campos = conn && conn.pareado ? CAMPOS_CONFIG_PAREADO : CAMPOS_CONFIG;
|
|
491
|
+
for (const k of campos) if (k in p) mudar[k] = p[k];
|
|
492
|
+
if (conn && conn.pareado && !Object.keys(mudar).length && CAMPOS_CONFIG.some((k) => k in p)) throw erro(ERRO.NEGADO, 'o celular só muda as preferências; o resto é do computador');
|
|
400
493
|
if (!Object.keys(mudar).length) throw erro(ERRO.PARAMS, `nada para gravar (campos: ${CAMPOS_CONFIG.join(', ')})`);
|
|
401
494
|
cfg.saveConfig(mudar);
|
|
402
|
-
return metodos['config/read']();
|
|
495
|
+
return metodos['config/read']({}, conn);
|
|
403
496
|
},
|
|
404
497
|
|
|
405
498
|
/* ── Conta (login do app): só as funções de lib/config/conta.js. Nunca devolve token. ── */
|
|
@@ -430,8 +523,33 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
430
523
|
},
|
|
431
524
|
|
|
432
525
|
/* ── Voz (6.9): provedor trocável em lib/motor/voz.js; só gera arquivo, nunca toca ── */
|
|
433
|
-
async 'voice/start'(p) { return Voz.iniciar({ texto: p && p.texto }); },
|
|
526
|
+
async 'voice/start'(p, conn) { return Voz.iniciar({ texto: conn && conn.pareado ? undefined : p && p.texto }); }, // o celular não manda o computador falar
|
|
434
527
|
'voice/stop'() { return Voz.parar(); },
|
|
528
|
+
/* Modo voz ao vivo: abrir a tela sobe o Whisper e o Kokoro em segundo plano (modelo carregado antes da primeira fala). */
|
|
529
|
+
'voice/warm'() {
|
|
530
|
+
if (!transcritor) require('../motor/transcricao').aquecer().catch(() => {});
|
|
531
|
+
falaDoModoVoz().aquecer().catch(() => {});
|
|
532
|
+
return { ok: true, fala: falaDoModoVoz().disponivel() };
|
|
533
|
+
},
|
|
534
|
+
/* A resposta do Primo em frases curtas para falar (sem markdown, código, caminho nem link). */
|
|
535
|
+
'voice/frases'(p) { return { frases: require('../motor/fala-viva').frasesParaFalar(p && p.texto, { max: 6 }) }; },
|
|
536
|
+
/* Uma frase vira áudio (arquivo gerado e devolvido em base64). Nunca toca: quem toca é a tela. */
|
|
537
|
+
async 'voice/speak'(p) {
|
|
538
|
+
const f = falaDoModoVoz();
|
|
539
|
+
const r = await f.falar(p && p.texto);
|
|
540
|
+
if (!r.ok) return { ok: false, error: r.error };
|
|
541
|
+
let audio;
|
|
542
|
+
try { audio = fs.readFileSync(r.arquivo).toString('base64'); } catch (e) { return { ok: false, error: `não li o áudio: ${e.message}` }; }
|
|
543
|
+
try { fs.rmSync(r.arquivo, { force: true }); } catch { /* sem arquivo */ }
|
|
544
|
+
return { ok: true, audio, mime: r.mime || 'audio/wav', provedor: r.provedor, ms: r.ms };
|
|
545
|
+
},
|
|
546
|
+
/* Escuta (bug 3): o renderer grava (MediaRecorder), manda o WAV em base64 e recebe o texto. Whisper local; sem rede. */
|
|
547
|
+
async 'voice/transcribe'(p) {
|
|
548
|
+
const T = require('../motor/transcricao');
|
|
549
|
+
const r = await T.transcreverBase64(p && p.audio, { pasta: path.join(base, 'voz'), ...(transcritor ? { transcritor } : {}) });
|
|
550
|
+
if (!r.ok) return { ok: false, error: r.error, comoResolver: r.comoResolver };
|
|
551
|
+
return { ok: true, texto: r.texto };
|
|
552
|
+
},
|
|
435
553
|
|
|
436
554
|
/* ── Studio: histórico do que o Estúdio gerou e abrir no sistema ── */
|
|
437
555
|
'studio/history'(p) {
|
|
@@ -579,10 +697,8 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
579
697
|
}
|
|
580
698
|
const tokenBate = (req) => !!autenticar(req);
|
|
581
699
|
function enderecosLocais() {
|
|
582
|
-
if (enderecos) return enderecos(); // teste: endereços de mentira
|
|
583
|
-
|
|
584
|
-
for (const lista of Object.values(os.networkInterfaces())) for (const i of lista || []) if (i.family === 'IPv4' && !i.internal) out.push(i.address);
|
|
585
|
-
return out;
|
|
700
|
+
if (enderecos) return require('./enderecos').ordenar(enderecos()); // teste: endereços de mentira
|
|
701
|
+
return require('./enderecos').locais(os.networkInterfaces()); // LAN primeiro, VPN (100.64/10, utun) por último
|
|
586
702
|
}
|
|
587
703
|
/* Anti-rebind: no padrão só 127.0.0.1/localhost; com --rede também o IP (literal) de uma interface do computador. Nome de host nunca. */
|
|
588
704
|
function hostPermitido(cabecalho) {
|
|
@@ -643,21 +759,27 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
643
759
|
fs.createReadStream(abs, { start: ini, end: fim }).on('error', () => res.destroy()).pipe(res);
|
|
644
760
|
}
|
|
645
761
|
|
|
762
|
+
/* Um servidor HTTP+WebSocket com as regras do motor. Um só em 127.0.0.1; com a rede ligada, um segundo em 0.0.0.0 (porta própria). */
|
|
763
|
+
function criarHttp() {
|
|
764
|
+
const srv = http.createServer(estatico);
|
|
765
|
+
srv.on('upgrade', (req, socket) => {
|
|
766
|
+
const recusar = (codigo, frase) => { socket.write(`HTTP/1.1 ${codigo} ${frase}\r\nConnection: close\r\n\r\n`); socket.destroy(); };
|
|
767
|
+
const quem = autenticar(req);
|
|
768
|
+
if (!quem) return recusar(401, 'Unauthorized');
|
|
769
|
+
if (String(req.headers.upgrade || '').toLowerCase() !== 'websocket' || !req.headers['sec-websocket-key']) return recusar(400, 'Bad Request');
|
|
770
|
+
const conn = quem.pareado ? { pareado: quem.pareado } : {};
|
|
771
|
+
const sock = WS.aceitar(req, socket, { aoTexto: (t) => tratar(t, conn), aoFechar: () => soltar(conn) });
|
|
772
|
+
conn.enviar = (obj) => sock.enviar(JSON.stringify(obj, (k, v) => (v instanceof Error ? { mensagem: v.message, nome: v.name } : v)));
|
|
773
|
+
conn.fechar = sock.fechar;
|
|
774
|
+
conexoes.add(conn);
|
|
775
|
+
});
|
|
776
|
+
return srv;
|
|
777
|
+
}
|
|
778
|
+
|
|
646
779
|
function ws({ porta: pedida = 0, rede: pedirRede } = {}) {
|
|
647
780
|
if (pedirRede !== undefined) modoRede = !!pedirRede;
|
|
648
781
|
return new Promise((resolve, reject) => {
|
|
649
|
-
http_ =
|
|
650
|
-
http_.on('upgrade', (req, socket) => {
|
|
651
|
-
const recusar = (codigo, frase) => { socket.write(`HTTP/1.1 ${codigo} ${frase}\r\nConnection: close\r\n\r\n`); socket.destroy(); };
|
|
652
|
-
const quem = autenticar(req);
|
|
653
|
-
if (!quem) return recusar(401, 'Unauthorized');
|
|
654
|
-
if (String(req.headers.upgrade || '').toLowerCase() !== 'websocket' || !req.headers['sec-websocket-key']) return recusar(400, 'Bad Request');
|
|
655
|
-
const conn = quem.pareado ? { pareado: quem.pareado } : {};
|
|
656
|
-
const sock = WS.aceitar(req, socket, { aoTexto: (t) => tratar(t, conn), aoFechar: () => soltar(conn) });
|
|
657
|
-
conn.enviar = (obj) => sock.enviar(JSON.stringify(obj, (k, v) => (v instanceof Error ? { mensagem: v.message, nome: v.name } : v)));
|
|
658
|
-
conn.fechar = sock.fechar;
|
|
659
|
-
conexoes.add(conn);
|
|
660
|
-
});
|
|
782
|
+
http_ = criarHttp();
|
|
661
783
|
http_.on('error', reject);
|
|
662
784
|
http_.listen(pedida, modoRede ? '0.0.0.0' : '127.0.0.1', () => {
|
|
663
785
|
porta = http_.address().port;
|
|
@@ -671,11 +793,32 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
671
793
|
});
|
|
672
794
|
}
|
|
673
795
|
|
|
796
|
+
/* Liga ou desliga o modo rede em tempo de execução (o mesmo do --rede), sem reiniciar o app: sobe um segundo servidor em 0.0.0.0
|
|
797
|
+
(porta própria, a do QR). O de 127.0.0.1 não muda. Desligar derruba os celulares conectados. */
|
|
798
|
+
let redeSrv = null;
|
|
799
|
+
let portaRede = null;
|
|
800
|
+
async function mudarRede(ligar) {
|
|
801
|
+
if (ligar && !modoRede) {
|
|
802
|
+
if (!http_) throw erro(ERRO.NEGADO, 'o servidor ainda não está no ar');
|
|
803
|
+
await new Promise((ok, falha) => { const s2 = criarHttp(); s2.once('error', falha); s2.listen(0, '0.0.0.0', () => { redeSrv = s2; portaRede = s2.address().port; ok(); }); });
|
|
804
|
+
modoRede = true;
|
|
805
|
+
} else if (!ligar && modoRede && redeSrv) {
|
|
806
|
+
for (const c of [...conexoes]) if (c.pareado && c.fechar) c.fechar();
|
|
807
|
+
const s2 = redeSrv; redeSrv = null; portaRede = null; modoRede = false;
|
|
808
|
+
await new Promise((ok) => { s2.close(() => ok()); if (s2.closeAllConnections) s2.closeAllConnections(); setTimeout(ok, 500); });
|
|
809
|
+
}
|
|
810
|
+
return { rede: modoRede, porta: portaRede || porta };
|
|
811
|
+
}
|
|
812
|
+
|
|
674
813
|
function fechar() {
|
|
814
|
+
if (olharStderr) process.stderr.write = escreverStderr;
|
|
675
815
|
if (timerPiloto) { clearInterval(timerPiloto); timerPiloto = null; }
|
|
676
816
|
for (const c of [...conexoes]) if (c.fechar) c.fechar();
|
|
677
817
|
conexoes.clear();
|
|
818
|
+
if (!transcritor) { try { require('../motor/transcricao').parar(); } catch { /* sem whisper vivo */ } }
|
|
819
|
+
if (Fala && Fala.parar) { try { Fala.parar(); } catch { /* sem voz viva */ } }
|
|
678
820
|
try { if (porta && JSON.parse(fs.readFileSync(arquivoInfo, 'utf8')).token === token) fs.rmSync(arquivoInfo, { force: true }); } catch { /* nada a limpar */ }
|
|
821
|
+
if (redeSrv) { try { redeSrv.close(); redeSrv.closeAllConnections && redeSrv.closeAllConnections(); } catch { /* já fechou */ } redeSrv = null; }
|
|
679
822
|
return new Promise((res) => { if (http_) { http_.close(() => res()); http_.closeAllConnections && http_.closeAllConnections(); } else res(); });
|
|
680
823
|
}
|
|
681
824
|
|
package/lib/avisos.js
CHANGED
|
@@ -27,6 +27,7 @@ function criar(dados, { arquivo: a } = {}) {
|
|
|
27
27
|
estado: aprov ? 'pendente' : 'novo',
|
|
28
28
|
nivel: dados.nivel === 'erro' ? 'erro' : 'ok',
|
|
29
29
|
texto: String(dados.texto || '').slice(0, 600),
|
|
30
|
+
detalhe: dados.detalhe ? String(dados.detalhe).slice(0, 300) : null,
|
|
30
31
|
agente: dados.agente || null, thread: dados.thread || null, aprovacao: dados.aprovacao || null,
|
|
31
32
|
criado: Date.now(),
|
|
32
33
|
};
|