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 CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v11.2.0
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.2.0
12
+ ▐▛███▜▌ Primo Code v11.3.1
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -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
- return nomes.map((n) => ler(n.slice(0, -3), { dir })).filter(Boolean).sort((a, b) => a.nome.localeCompare(b.nome));
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) {
@@ -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: acompanhar, aprovar, mandar pedido, ver entrega e histórico.
30
- Fica de fora: arquivos, configuração, projetos e agentes (escrita), terminal, abrir coisas no computador. */
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
- function criarServidor({ motor, casa, raizes, telaDir, abrir, voz, rede = false, enderecos, conta: contaOp = {} } = {}) {
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
- if (dados.titulo === 'Nova conversa' && !dados.historico.length) dados.titulo = pedido.replace(/\s+/g, ' ').slice(0, 60);
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
- const r = Marca.salvar(m);
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
- const codigo = `${urls[0]}/?token=${r.token}`;
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
- for (const k of CAMPOS_CONFIG) if (k in p) mudar[k] = p[k];
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
- const out = [];
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_ = http.createServer(estatico);
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
  };