primocode 11.2.0 → 11.3.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/README.md +2 -2
- package/lib/agentes-pessoais.js +5 -2
- package/lib/app-server/cli.js +3 -0
- package/lib/app-server/enderecos.js +43 -0
- package/lib/app-server/servidor.js +151 -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/transcricao.js +57 -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/voz/primo_voz/transcrever.py +12 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# PrimoCode v11.
|
|
1
|
+
# PrimoCode v11.3.0
|
|
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.0
|
|
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,10 @@ 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;
|
|
42
44
|
const srv = criarServidor({
|
|
45
|
+
...(transcritorFalso ? { transcritor: require(require('path').resolve(transcritorFalso)) } : {}),
|
|
43
46
|
...(falso ? { motor: require(require('path').resolve(falso)) } : {}),
|
|
44
47
|
...(contaFalsa ? { conta: require(require('path').resolve(contaFalsa)) } : {}),
|
|
45
48
|
});
|
|
@@ -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,19 @@ 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',
|
|
35
40
|
]);
|
|
41
|
+
const CAMPOS_CONFIG_PAREADO = ['effort', 'userName', 'mascot', 'reacoes', 'theme', 'preferencias'];
|
|
36
42
|
const LOOPBACK = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']);
|
|
37
43
|
|
|
38
44
|
const texto = (v) => (typeof v === 'string' ? v : '');
|
|
@@ -47,7 +53,16 @@ function abrirNoSistema(abs) {
|
|
|
47
53
|
}
|
|
48
54
|
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
55
|
|
|
50
|
-
|
|
56
|
+
/* O login do GitHub, lido do hosts.yml do `gh` (sem rodar o gh). Devolve o usuário ou null. */
|
|
57
|
+
function githubConectado() {
|
|
58
|
+
try {
|
|
59
|
+
const t = fs.readFileSync(path.join(os.homedir(), '.config', 'gh', 'hosts.yml'), 'utf8');
|
|
60
|
+
const m = /^\s+user:\s*(\S+)/m.exec(t);
|
|
61
|
+
return m ? m[1] : null;
|
|
62
|
+
} catch { return null; }
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false, enderecos, conta: contaOp = {}, transcritor } = {}) {
|
|
51
66
|
const cfg = require('../config/config');
|
|
52
67
|
const m = motor || require('../motor');
|
|
53
68
|
const base = casa || cfg.CONFIG_DIR;
|
|
@@ -82,6 +97,24 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
82
97
|
let http_ = null;
|
|
83
98
|
let porta = null;
|
|
84
99
|
|
|
100
|
+
/* A linha `primocode:abrir-gosto <url>` que o motor escreve no stderr (só com PRIMOCODE_APP=1, que o app liga) vira screen/open
|
|
101
|
+
para as telas do computador. O resto do stderr passa igual. */
|
|
102
|
+
let restoDoStderr = '';
|
|
103
|
+
const escreverStderr = process.stderr.write.bind(process.stderr);
|
|
104
|
+
const olharStderr = process.env.PRIMOCODE_APP === '1' && !process.env.PRIMOCODE_SEM_TELA_NO_APP;
|
|
105
|
+
function filtrarStderr(chunk) {
|
|
106
|
+
restoDoStderr = (restoDoStderr + String(chunk)).slice(-4000);
|
|
107
|
+
const linhas = restoDoStderr.split('\n');
|
|
108
|
+
restoDoStderr = linhas.pop();
|
|
109
|
+
for (const l of linhas) {
|
|
110
|
+
const url = require('../plugin/estudio/gosto').urlDaLinha(l);
|
|
111
|
+
if (url) for (const c of conexoes) { if (!c.pareado) { try { c.enviar(P.notificacao('screen/open', { url })); } catch { /* caiu */ } } }
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (olharStderr) {
|
|
115
|
+
process.stderr.write = (chunk, ...resto) => { try { filtrarStderr(chunk); } catch { /* nunca quebra o log */ } return escreverStderr(chunk, ...resto); };
|
|
116
|
+
}
|
|
117
|
+
|
|
85
118
|
/* ── Conversas: um JSON por thread em ~/.primocode/threads ── */
|
|
86
119
|
const arquivoDe = (id) => path.join(pastaThreads, `${id}.json`);
|
|
87
120
|
function salvar(t) {
|
|
@@ -144,6 +177,27 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
144
177
|
};
|
|
145
178
|
}
|
|
146
179
|
|
|
180
|
+
/* Título da conversa (bug 2): depois do primeiro turno de uma conversa nova, quem leu a troca escreve o título
|
|
181
|
+
(lib/session/titulo-conversa.js) e a tela recebe thread/renamed. Falha ou demora: fica o provisório. */
|
|
182
|
+
function perguntarTitulo(prompt) {
|
|
183
|
+
if (typeof m.titular === 'function') return m.titular(prompt);
|
|
184
|
+
const c = cfg.loadConfig();
|
|
185
|
+
let resposta = '';
|
|
186
|
+
return require('../provider/api').requestChat(c.server, { prompt, history: [], model: c.model, token: c.token }, (evt) => { if (evt.delta) resposta += evt.delta; }).then(() => resposta);
|
|
187
|
+
}
|
|
188
|
+
function batizar(t, pedido, resposta) {
|
|
189
|
+
const provisorio = t.provisorio;
|
|
190
|
+
if (!provisorio) return;
|
|
191
|
+
t.provisorio = null;
|
|
192
|
+
const texto_ = typeof resposta === 'string' ? resposta : Array.isArray(resposta) ? resposta.map((x) => (x && x.text) || '').join('') : '';
|
|
193
|
+
require('../session/titulo-conversa').escolher({ pedido, resposta: texto_, perguntar: perguntarTitulo }).then((titulo) => {
|
|
194
|
+
if (!titulo || t.dados.titulo !== provisorio) return; // veio lixo, ou a pessoa já renomeou
|
|
195
|
+
t.dados.titulo = titulo.slice(0, 120);
|
|
196
|
+
salvar(t);
|
|
197
|
+
difundir('thread/renamed', { thread: t.dados.id, titulo: t.dados.titulo });
|
|
198
|
+
}).catch(() => { /* fica o provisório */ });
|
|
199
|
+
}
|
|
200
|
+
|
|
147
201
|
/* ── Caminhos: só dentro das raízes permitidas, sem segredos ── */
|
|
148
202
|
function caminhoSeguro(p) {
|
|
149
203
|
const abs = path.resolve(texto(p));
|
|
@@ -214,7 +268,8 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
214
268
|
const c = cfg.loadConfig();
|
|
215
269
|
const projeto = texto(p.projeto) || dados.projeto || undefined;
|
|
216
270
|
if (projeto) dados.projeto = projeto;
|
|
217
|
-
|
|
271
|
+
// O título provisório é o começo do pedido; quando o primeiro turno termina, o modelo escreve o de verdade (batizar).
|
|
272
|
+
if (dados.titulo === 'Nova conversa' && !dados.historico.length) { dados.titulo = pedido.replace(/\s+/g, ' ').slice(0, 60); t.provisorio = dados.titulo; }
|
|
218
273
|
|
|
219
274
|
// Menções (@arquivo) e anexos entram como instrução: não viram a fala da pessoa.
|
|
220
275
|
const partes = [];
|
|
@@ -247,6 +302,7 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
247
302
|
if (r && r.userMessage && !r.parado) {
|
|
248
303
|
dados.historico.push(r.userMessage);
|
|
249
304
|
if (r.assistantMessage) dados.historico.push(r.assistantMessage);
|
|
305
|
+
batizar(t, pedido, r.assistantMessage && r.assistantMessage.content);
|
|
250
306
|
}
|
|
251
307
|
}).catch((e) => {
|
|
252
308
|
// O motor já emite turn/interrupted com o erro; aqui só garante que a tela saiba.
|
|
@@ -313,7 +369,7 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
313
369
|
'brand/read'(p) {
|
|
314
370
|
const vista = (k) => ({
|
|
315
371
|
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 || {}),
|
|
372
|
+
site: k.site || '', redes: k.redes || '', temLogo: !!k.logo, logo: k.logo || null, contraste: Marca.validarContraste(k.cores || {}),
|
|
317
373
|
});
|
|
318
374
|
const ativa = (() => { try { return fs.readFileSync(path.join(Marca.raiz(), '.ativa'), 'utf8').trim() || null; } catch { return null; } })();
|
|
319
375
|
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 +384,18 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
328
384
|
for (const k of ['tom', 'slogan', 'site', 'redes']) if (k in p) m[k] = texto(p[k]).slice(0, 300);
|
|
329
385
|
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
386
|
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
|
-
|
|
387
|
+
// Logo vindo da tela: um data URL png ou svg de até 1 MB, gravado num arquivo temporário e copiado para o kit.
|
|
388
|
+
let logoArquivo = null;
|
|
389
|
+
if (p.logo && typeof p.logo.dataUrl === 'string') {
|
|
390
|
+
const mm = /^data:image\/(png|svg\+xml);base64,([A-Za-z0-9+/=]+)$/.exec(p.logo.dataUrl);
|
|
391
|
+
if (!mm) throw erro(ERRO.PARAMS, 'o logo tem que ser png ou svg');
|
|
392
|
+
const bytes = Buffer.from(mm[2], 'base64');
|
|
393
|
+
if (bytes.length > 1048576) throw erro(ERRO.PARAMS, 'o logo passa de 1 MB');
|
|
394
|
+
logoArquivo = path.join(os.tmpdir(), `primo-logo-${process.pid}-${Date.now()}.${mm[1] === 'png' ? 'png' : 'svg'}`);
|
|
395
|
+
fs.writeFileSync(logoArquivo, bytes, { mode: 0o600 });
|
|
396
|
+
}
|
|
397
|
+
let r;
|
|
398
|
+
try { r = Marca.salvar(m, { logoArquivo }); } finally { if (logoArquivo) { try { fs.unlinkSync(logoArquivo); } catch { /* já saiu */ } } }
|
|
332
399
|
if (!r.ok) throw erro(ERRO.PARAMS, r.error);
|
|
333
400
|
if (p.ativar) Marca.usar(nome);
|
|
334
401
|
return metodos['brand/read']({ nome });
|
|
@@ -364,18 +431,38 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
364
431
|
},
|
|
365
432
|
|
|
366
433
|
/* ── Pareamento do celular (só o computador, nunca um celular pareado) ── */
|
|
367
|
-
'pairing/info'() { return { rede: modoRede, porta, enderecos: modoRede ? enderecosLocais() : [], pareamentos: Pareamentos.listar() }; },
|
|
434
|
+
'pairing/info'() { return { rede: modoRede, porta: portaRede || porta, enderecos: modoRede ? enderecosLocais() : [], pareamentos: Pareamentos.listar() }; },
|
|
368
435
|
'pairing/create'(p) {
|
|
369
436
|
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
437
|
const ips = enderecosLocais();
|
|
371
438
|
if (!ips.length) throw erro(ERRO.NEGADO, 'não achei um endereço de rede local neste computador');
|
|
372
439
|
const r = Pareamentos.criar(p.nome);
|
|
373
|
-
const urls = ips.map((ip) => `ws://${ip}:${porta}`);
|
|
374
|
-
|
|
440
|
+
const urls = ips.map((ip) => `ws://${ip}:${portaRede || porta}`);
|
|
441
|
+
// 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.
|
|
442
|
+
const codigo = `${urls[0]}/?token=${r.token}${ips.length > 1 ? `&h=${ips.slice(1).join(',')}` : ''}`;
|
|
375
443
|
const q = require('../qr').gerarQR(codigo, { ecc: 'M' });
|
|
376
444
|
// A tela desenha o QR com estas linhas ('1' = módulo escuro); nenhum HTML vem do motor.
|
|
377
445
|
return { ...r, urls, codigo, qr: { tamanho: q.tamanho, linhas: q.modulos.map((l) => l.map((b) => (b ? '1' : '0')).join('')) } };
|
|
378
446
|
},
|
|
447
|
+
/* Telas do Estúdio dentro do app (estilos e gosto): o motor escreve a linha de protocolo no stderr (lib/plugin/estudio/gosto.js),
|
|
448
|
+
o servidor a vira `screen/open` e a tela mostra a página num quadro. `screen/dismiss` fecha pelo botão Fechar do quadro. */
|
|
449
|
+
async 'screen/dismiss'(p, conn) {
|
|
450
|
+
if (conn && conn.pareado) throw erro(ERRO.NEGADO, 'só o computador');
|
|
451
|
+
const url = require('../plugin/estudio/gosto').urlDaLinha(`primocode:abrir-gosto ${texto(p.url)}`);
|
|
452
|
+
if (!url) throw erro(ERRO.PARAMS, 'url inválida');
|
|
453
|
+
const u = new URL(url);
|
|
454
|
+
await new Promise((ok) => {
|
|
455
|
+
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); });
|
|
456
|
+
rq.on('error', ok); rq.setTimeout(3000, () => { rq.destroy(); ok(); }); rq.end();
|
|
457
|
+
});
|
|
458
|
+
return { ok: true };
|
|
459
|
+
},
|
|
460
|
+
/* Liga/desliga o modo rede sem reiniciar (botão "Conectar celular"). Só o computador. */
|
|
461
|
+
async 'pairing/enable'(p, conn) {
|
|
462
|
+
if (conn && conn.pareado) throw erro(ERRO.NEGADO, 'só o computador');
|
|
463
|
+
const r = await mudarRede(p.ligar !== false);
|
|
464
|
+
return { ...r, enderecos: modoRede ? enderecosLocais() : [] };
|
|
465
|
+
},
|
|
379
466
|
'pairing/list'() { return { pareamentos: Pareamentos.listar() }; },
|
|
380
467
|
'pairing/revoke'(p) {
|
|
381
468
|
const id = texto(p.id);
|
|
@@ -391,15 +478,18 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
391
478
|
out.servidor = c.server;
|
|
392
479
|
out.temToken = !!c.token;
|
|
393
480
|
out.projetoAtual = c.currentProject;
|
|
481
|
+
out.github = githubConectado();
|
|
394
482
|
if (conn && conn.pareado) out.pareado = true;
|
|
395
483
|
return out;
|
|
396
484
|
},
|
|
397
|
-
'config/write'(p) {
|
|
485
|
+
'config/write'(p, conn) {
|
|
398
486
|
const mudar = {};
|
|
399
|
-
|
|
487
|
+
const campos = conn && conn.pareado ? CAMPOS_CONFIG_PAREADO : CAMPOS_CONFIG;
|
|
488
|
+
for (const k of campos) if (k in p) mudar[k] = p[k];
|
|
489
|
+
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
490
|
if (!Object.keys(mudar).length) throw erro(ERRO.PARAMS, `nada para gravar (campos: ${CAMPOS_CONFIG.join(', ')})`);
|
|
401
491
|
cfg.saveConfig(mudar);
|
|
402
|
-
return metodos['config/read']();
|
|
492
|
+
return metodos['config/read']({}, conn);
|
|
403
493
|
},
|
|
404
494
|
|
|
405
495
|
/* ── Conta (login do app): só as funções de lib/config/conta.js. Nunca devolve token. ── */
|
|
@@ -430,8 +520,15 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
430
520
|
},
|
|
431
521
|
|
|
432
522
|
/* ── 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 }); },
|
|
523
|
+
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
524
|
'voice/stop'() { return Voz.parar(); },
|
|
525
|
+
/* Escuta (bug 3): o renderer grava (MediaRecorder), manda o WAV em base64 e recebe o texto. Whisper local; sem rede. */
|
|
526
|
+
async 'voice/transcribe'(p) {
|
|
527
|
+
const T = require('../motor/transcricao');
|
|
528
|
+
const r = await T.transcreverBase64(p && p.audio, { pasta: path.join(base, 'voz'), ...(transcritor ? { transcritor } : {}) });
|
|
529
|
+
if (!r.ok) return { ok: false, error: r.error, comoResolver: r.comoResolver };
|
|
530
|
+
return { ok: true, texto: r.texto };
|
|
531
|
+
},
|
|
435
532
|
|
|
436
533
|
/* ── Studio: histórico do que o Estúdio gerou e abrir no sistema ── */
|
|
437
534
|
'studio/history'(p) {
|
|
@@ -579,10 +676,8 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
579
676
|
}
|
|
580
677
|
const tokenBate = (req) => !!autenticar(req);
|
|
581
678
|
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;
|
|
679
|
+
if (enderecos) return require('./enderecos').ordenar(enderecos()); // teste: endereços de mentira
|
|
680
|
+
return require('./enderecos').locais(os.networkInterfaces()); // LAN primeiro, VPN (100.64/10, utun) por último
|
|
586
681
|
}
|
|
587
682
|
/* 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
683
|
function hostPermitido(cabecalho) {
|
|
@@ -643,21 +738,27 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
643
738
|
fs.createReadStream(abs, { start: ini, end: fim }).on('error', () => res.destroy()).pipe(res);
|
|
644
739
|
}
|
|
645
740
|
|
|
741
|
+
/* 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). */
|
|
742
|
+
function criarHttp() {
|
|
743
|
+
const srv = http.createServer(estatico);
|
|
744
|
+
srv.on('upgrade', (req, socket) => {
|
|
745
|
+
const recusar = (codigo, frase) => { socket.write(`HTTP/1.1 ${codigo} ${frase}\r\nConnection: close\r\n\r\n`); socket.destroy(); };
|
|
746
|
+
const quem = autenticar(req);
|
|
747
|
+
if (!quem) return recusar(401, 'Unauthorized');
|
|
748
|
+
if (String(req.headers.upgrade || '').toLowerCase() !== 'websocket' || !req.headers['sec-websocket-key']) return recusar(400, 'Bad Request');
|
|
749
|
+
const conn = quem.pareado ? { pareado: quem.pareado } : {};
|
|
750
|
+
const sock = WS.aceitar(req, socket, { aoTexto: (t) => tratar(t, conn), aoFechar: () => soltar(conn) });
|
|
751
|
+
conn.enviar = (obj) => sock.enviar(JSON.stringify(obj, (k, v) => (v instanceof Error ? { mensagem: v.message, nome: v.name } : v)));
|
|
752
|
+
conn.fechar = sock.fechar;
|
|
753
|
+
conexoes.add(conn);
|
|
754
|
+
});
|
|
755
|
+
return srv;
|
|
756
|
+
}
|
|
757
|
+
|
|
646
758
|
function ws({ porta: pedida = 0, rede: pedirRede } = {}) {
|
|
647
759
|
if (pedirRede !== undefined) modoRede = !!pedirRede;
|
|
648
760
|
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
|
-
});
|
|
761
|
+
http_ = criarHttp();
|
|
661
762
|
http_.on('error', reject);
|
|
662
763
|
http_.listen(pedida, modoRede ? '0.0.0.0' : '127.0.0.1', () => {
|
|
663
764
|
porta = http_.address().port;
|
|
@@ -671,11 +772,30 @@ function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false,
|
|
|
671
772
|
});
|
|
672
773
|
}
|
|
673
774
|
|
|
775
|
+
/* 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
|
|
776
|
+
(porta própria, a do QR). O de 127.0.0.1 não muda. Desligar derruba os celulares conectados. */
|
|
777
|
+
let redeSrv = null;
|
|
778
|
+
let portaRede = null;
|
|
779
|
+
async function mudarRede(ligar) {
|
|
780
|
+
if (ligar && !modoRede) {
|
|
781
|
+
if (!http_) throw erro(ERRO.NEGADO, 'o servidor ainda não está no ar');
|
|
782
|
+
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(); }); });
|
|
783
|
+
modoRede = true;
|
|
784
|
+
} else if (!ligar && modoRede && redeSrv) {
|
|
785
|
+
for (const c of [...conexoes]) if (c.pareado && c.fechar) c.fechar();
|
|
786
|
+
const s2 = redeSrv; redeSrv = null; portaRede = null; modoRede = false;
|
|
787
|
+
await new Promise((ok) => { s2.close(() => ok()); if (s2.closeAllConnections) s2.closeAllConnections(); setTimeout(ok, 500); });
|
|
788
|
+
}
|
|
789
|
+
return { rede: modoRede, porta: portaRede || porta };
|
|
790
|
+
}
|
|
791
|
+
|
|
674
792
|
function fechar() {
|
|
793
|
+
if (olharStderr) process.stderr.write = escreverStderr;
|
|
675
794
|
if (timerPiloto) { clearInterval(timerPiloto); timerPiloto = null; }
|
|
676
795
|
for (const c of [...conexoes]) if (c.fechar) c.fechar();
|
|
677
796
|
conexoes.clear();
|
|
678
797
|
try { if (porta && JSON.parse(fs.readFileSync(arquivoInfo, 'utf8')).token === token) fs.rmSync(arquivoInfo, { force: true }); } catch { /* nada a limpar */ }
|
|
798
|
+
if (redeSrv) { try { redeSrv.close(); redeSrv.closeAllConnections && redeSrv.closeAllConnections(); } catch { /* já fechou */ } redeSrv = null; }
|
|
679
799
|
return new Promise((res) => { if (http_) { http_.close(() => res()); http_.closeAllConnections && http_.closeAllConnections(); } else res(); });
|
|
680
800
|
}
|
|
681
801
|
|
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
|
};
|