primocode 11.1.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 +16 -2
- package/bin/primocode-rpc.js +100 -0
- package/lib/agentes-pessoais.js +120 -0
- package/lib/app-server/cli.js +64 -0
- package/lib/app-server/enderecos.js +43 -0
- package/lib/app-server/index.js +3 -0
- package/lib/app-server/pareamento.js +61 -0
- package/lib/app-server/protocolo.js +86 -0
- package/lib/app-server/servidor.js +805 -0
- package/lib/app-server/websocket.js +87 -0
- package/lib/avisos.js +65 -0
- package/lib/cli/sessao.js +202 -17
- package/lib/cli-nova.js +237 -0
- package/lib/config/config.js +22 -2
- package/lib/diff.js +12 -2
- package/lib/motor/aprovacao.js +114 -0
- package/lib/motor/eventos.js +77 -0
- package/lib/motor/index.js +8 -0
- package/lib/motor/tela-terminal.js +64 -0
- package/lib/motor/transcricao.js +57 -0
- package/lib/motor/turno.js +43 -0
- package/lib/motor/voz.js +65 -0
- package/lib/piloto.js +39 -0
- package/lib/plugin/entregaveis/nucleo.js +2 -2
- package/lib/plugin/estudio/criar.js +113 -0
- package/lib/plugin/estudio/gosto.js +3 -0
- package/lib/plugin/estudio/historico.js +60 -0
- package/lib/plugin/estudio/index.js +14 -1
- package/lib/plugin/estudio/kits/tela.html +3 -1
- package/lib/plugin/estudio/miniatura.js +56 -0
- package/lib/plugin/video/auto.js +1 -1
- package/lib/plugin/video/catalogo.js +20 -0
- package/lib/plugin/video/mutar.js +28 -0
- package/lib/plugin/video/ops-fx.js +6 -6
- package/lib/plugin/video/ops.js +2 -13
- package/lib/plugin/video/pedido.js +1 -1
- package/lib/plugin/video/zoom.js +1 -1
- package/lib/projetos-meta.js +107 -0
- package/lib/qr.js +209 -0
- package/lib/session/runner/act.js +127 -68
- package/lib/skill/catalogo.js +3 -1
- package/lib/tool/tools.js +7 -0
- package/lib/tui/entrada.js +143 -18
- package/lib/tui/tela.js +312 -49
- package/lib/tui/ui.js +78 -4
- package/package.json +10 -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
|
```
|
|
@@ -17,6 +17,20 @@ está no limite de pedidos, o próximo atende. Você não vê o erro, e não pag
|
|
|
17
17
|
Personalize o mascote (`primo`, `fox`, `cat`) e o nome com que ele te chama —
|
|
18
18
|
veja `/mascot` e `/name`.
|
|
19
19
|
|
|
20
|
+
## Novidades da 11.2
|
|
21
|
+
|
|
22
|
+
- **App desktop (Electron)** com a mesma tela do protótipo: conversa, Estúdio, projetos, agentes, kit da marca, avisos e busca. Ele sobe o motor sozinho e fala com ele pelo `app-server` (JSON-RPC). `primocode app` abre o app instalado; no repo, `npm run app:dev`.
|
|
23
|
+
- **Celular (Android):** `primocode server --rede` e a tela **Parear celular** geram um QR com token revogável. O app do celular (`mobile/`, Capacitor) usa o mesmo motor do computador. APK de teste: `npm run mobile:build`.
|
|
24
|
+
- **Motor reescrito em módulos** (`lib/motor/`, `lib/app-server/`), com turno, aprovação, voz e piloto automático. A CLI ganhou `/estudio`, `/agentes`, `/piloto`, `/avisos` e `/buscar`.
|
|
25
|
+
- **Primeira execução:** uma tela só, pulável (nome, esforço, kit da marca). Poppins vai embutida, sem fonte remota.
|
|
26
|
+
|
|
27
|
+
### App e celular, em duas linhas
|
|
28
|
+
|
|
29
|
+
```
|
|
30
|
+
npm run app:dev # desktop (precisa de `cd app && npm install` uma vez)
|
|
31
|
+
npm run mobile:build # APK debug (JDK 17 e Android SDK)
|
|
32
|
+
```
|
|
33
|
+
|
|
20
34
|
## Primo (voz)
|
|
21
35
|
|
|
22
36
|
`/voice` abre a tela do Primo: agenda, clima, música, máquina e um robô 3D no centro. Diga **"Primo"** para chamar; ele fala frase a frase e você pode interromper. Na primeira vez cria `~/.primocode/voz-venv` sozinho (`primocode doutor` mostra o estado). Robô offline: `"robo":"expressive"` em `~/.primocode/voz.json`.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Cliente de linha de comando para testar o app-server.
|
|
3
|
+
// primocode-rpc [--ws [porta] | --stdio] <método> ['{"json":"params"}']
|
|
4
|
+
// stdio (padrão) sobe `primocode server` como filho. --ws lê porta e token de
|
|
5
|
+
// ~/.primocode/app-server.json. Imprime cada notificação até turn/completed
|
|
6
|
+
// (ou turn/interrupted). Para métodos sem turno, imprime a resposta e sai.
|
|
7
|
+
'use strict';
|
|
8
|
+
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const crypto = require('crypto');
|
|
13
|
+
const net = require('net');
|
|
14
|
+
const { spawn } = require('child_process');
|
|
15
|
+
const P = require('../lib/app-server/protocolo');
|
|
16
|
+
|
|
17
|
+
const args = process.argv.slice(2);
|
|
18
|
+
let modo = 'stdio';
|
|
19
|
+
let portaWs = null;
|
|
20
|
+
const resto = [];
|
|
21
|
+
for (let i = 0; i < args.length; i++) {
|
|
22
|
+
if (args[i] === '--stdio') modo = 'stdio';
|
|
23
|
+
else if (args[i] === '--ws') { modo = 'ws'; if (/^\d+$/.test(args[i + 1] || '')) portaWs = Number(args[++i]); }
|
|
24
|
+
else resto.push(args[i]);
|
|
25
|
+
}
|
|
26
|
+
const [metodo, paramsBruto] = resto;
|
|
27
|
+
if (!metodo) { console.error('Uso: primocode-rpc [--ws [porta] | --stdio] <método> [\'{"json":"params"}\']'); process.exit(1); }
|
|
28
|
+
let params = {};
|
|
29
|
+
try { if (paramsBruto) params = JSON.parse(paramsBruto); } catch { console.error('params não é JSON válido'); process.exit(1); }
|
|
30
|
+
|
|
31
|
+
const FIM = new Set(['turn/completed', 'turn/interrupted']);
|
|
32
|
+
|
|
33
|
+
let sair = () => process.exit(0);
|
|
34
|
+
|
|
35
|
+
function aoLinha(linha) {
|
|
36
|
+
const r = P.interpretar(linha);
|
|
37
|
+
if (r.vazio) return;
|
|
38
|
+
const msg = r.msg || r.resposta;
|
|
39
|
+
if (!msg) return;
|
|
40
|
+
console.log(JSON.stringify(msg));
|
|
41
|
+
if (msg.method && FIM.has(msg.method)) sair();
|
|
42
|
+
else if (msg.id === 1) {
|
|
43
|
+
if (msg.error || !(metodo === 'turn/start')) sair();
|
|
44
|
+
else { /* espera as notificações do turno */ }
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const pedido = P.serializar({ jsonrpc: '2.0', id: 1, method: metodo, params });
|
|
49
|
+
|
|
50
|
+
if (modo === 'stdio') {
|
|
51
|
+
const filho = spawn(process.execPath, [path.join(__dirname, 'primocode.js'), 'server', '--stdio'], { stdio: ['pipe', 'pipe', 'inherit'] });
|
|
52
|
+
const corta = P.cortador();
|
|
53
|
+
filho.stdout.setEncoding('utf8');
|
|
54
|
+
filho.stdout.on('data', (d) => { for (const l of corta.push(d)) aoLinha(l); });
|
|
55
|
+
sair = () => { filho.kill(); process.exit(0); };
|
|
56
|
+
filho.stdin.write(pedido);
|
|
57
|
+
} else {
|
|
58
|
+
let info;
|
|
59
|
+
try { info = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.primocode', 'app-server.json'), 'utf8')); }
|
|
60
|
+
catch { console.error('Sem ~/.primocode/app-server.json: suba antes `primocode server --ws`.'); process.exit(1); }
|
|
61
|
+
const porta = portaWs || info.porta;
|
|
62
|
+
const sock = net.connect(porta, '127.0.0.1');
|
|
63
|
+
const chave = crypto.randomBytes(16).toString('base64');
|
|
64
|
+
sock.write(`GET /?token=${info.token} HTTP/1.1\r\nHost: 127.0.0.1:${porta}\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n`
|
|
65
|
+
+ `Sec-WebSocket-Key: ${chave}\r\nSec-WebSocket-Version: 13\r\n\r\n`);
|
|
66
|
+
let buf = Buffer.alloc(0);
|
|
67
|
+
let aberto = false;
|
|
68
|
+
const mascarar = (t) => {
|
|
69
|
+
const corpo = Buffer.from(t, 'utf8');
|
|
70
|
+
const m = crypto.randomBytes(4);
|
|
71
|
+
const cab = corpo.length < 126 ? Buffer.from([0x81, 0x80 | corpo.length])
|
|
72
|
+
: (() => { const h = Buffer.alloc(4); h[0] = 0x81; h[1] = 0x80 | 126; h.writeUInt16BE(corpo.length, 2); return h; })();
|
|
73
|
+
return Buffer.concat([cab, m, Buffer.from(corpo.map((b, i) => b ^ m[i % 4]))]);
|
|
74
|
+
};
|
|
75
|
+
sair = () => { try { sock.destroy(); } catch { /* já fechou */ } process.exit(0); };
|
|
76
|
+
sock.on('data', (d) => {
|
|
77
|
+
buf = Buffer.concat([buf, d]);
|
|
78
|
+
if (!aberto) {
|
|
79
|
+
const fim = buf.indexOf('\r\n\r\n');
|
|
80
|
+
if (fim < 0) return;
|
|
81
|
+
if (!/^HTTP\/1\.1 101/.test(buf.toString('latin1', 0, fim))) { console.error('Recusado: ' + buf.toString('latin1', 0, fim).split('\r\n')[0]); process.exit(1); }
|
|
82
|
+
buf = buf.subarray(fim + 4);
|
|
83
|
+
aberto = true;
|
|
84
|
+
sock.write(mascarar(pedido));
|
|
85
|
+
}
|
|
86
|
+
for (;;) {
|
|
87
|
+
if (buf.length < 2) return;
|
|
88
|
+
let n = buf[1] & 0x7f; let pos = 2;
|
|
89
|
+
if (n === 126) { if (buf.length < 4) return; n = buf.readUInt16BE(2); pos = 4; }
|
|
90
|
+
else if (n === 127) { if (buf.length < 10) return; n = Number(buf.readBigUInt64BE(2)); pos = 10; }
|
|
91
|
+
if (buf.length < pos + n) return;
|
|
92
|
+
const op = buf[0] & 0x0f;
|
|
93
|
+
const corpo = buf.subarray(pos, pos + n).toString('utf8');
|
|
94
|
+
buf = buf.subarray(pos + n);
|
|
95
|
+
if (op === 0x1) aoLinha(corpo);
|
|
96
|
+
else if (op === 0x8) process.exit(0);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
sock.on('error', (e) => { console.error('Erro: ' + e.message); process.exit(1); });
|
|
100
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Agentes: um arquivo por agente em ~/.primocode/agentes/<nome>.md, com frontmatter
|
|
2
|
+
// (nome, descricao, icone, cor, onde, ligado) e o texto do ofício. docs/estrutura.md, seções 7 e 9.
|
|
3
|
+
// Node puro. `onde`: "fora" (fora de projetos) ou o id de um projeto.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const os = require('os');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
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
|
+
const hexOk = (h) => /^#[0-9a-f]{6}$/i.test(String(h || ''));
|
|
12
|
+
const pasta = (dir) => dir || process.env.PRIMOCODE_AGENTES || path.join(os.homedir(), '.primocode', 'agentes');
|
|
13
|
+
|
|
14
|
+
function slug(n) {
|
|
15
|
+
return String(n || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
16
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 32);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ler(nome, { dir } = {}) {
|
|
20
|
+
const s = slug(nome);
|
|
21
|
+
if (!s) return null;
|
|
22
|
+
let txt;
|
|
23
|
+
try { txt = fs.readFileSync(path.join(pasta(dir), `${s}.md`), 'utf8'); } catch { return null; }
|
|
24
|
+
const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(txt);
|
|
25
|
+
const f = {};
|
|
26
|
+
if (m) for (const l of m[1].split('\n')) { const i = l.indexOf(':'); if (i > 0) f[l.slice(0, i).trim()] = l.slice(i + 1).trim(); }
|
|
27
|
+
return {
|
|
28
|
+
nome: s, titulo: f.nome || s, descricao: f.descricao || '',
|
|
29
|
+
icone: ICONES.includes(f.icone) ? f.icone : 'workflow', cor: hexOk(f.cor) ? f.cor : '#22d3ee',
|
|
30
|
+
onde: f.onde || 'fora', ligado: f.ligado === 'true', oficio: (m ? m[2] : txt).trim(),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const uma = (v) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim();
|
|
35
|
+
|
|
36
|
+
function gravar(a, dir) {
|
|
37
|
+
const d = pasta(dir);
|
|
38
|
+
fs.mkdirSync(d, { recursive: true });
|
|
39
|
+
const txt = `---\nnome: ${uma(a.titulo)}\ndescricao: ${uma(a.descricao)}\nicone: ${a.icone}\ncor: ${a.cor}\nonde: ${uma(a.onde) || 'fora'}\nligado: ${a.ligado ? 'true' : 'false'}\n---\n${a.oficio.trim()}\n`;
|
|
40
|
+
fs.writeFileSync(path.join(d, `${a.nome}.md`), txt);
|
|
41
|
+
return ler(a.nome, { dir });
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function listar({ dir } = {}) {
|
|
45
|
+
let nomes = [];
|
|
46
|
+
try { nomes = fs.readdirSync(pasta(dir)).filter((n) => n.endsWith('.md')); } catch { /* ainda não há */ }
|
|
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);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validar(c, atual) {
|
|
54
|
+
const a = { ...(atual || { icone: 'workflow', cor: '#22d3ee', onde: 'fora', ligado: false, descricao: '', oficio: '' }) };
|
|
55
|
+
if ('nome' in c && !atual) { a.titulo = uma(c.nome).slice(0, 60); a.nome = slug(c.nome); }
|
|
56
|
+
if (!atual && !a.nome) throw new Error('falta o nome (letras e números)');
|
|
57
|
+
if ('titulo' in c && atual) a.titulo = uma(c.titulo).slice(0, 60) || a.titulo;
|
|
58
|
+
if (!a.titulo) a.titulo = a.nome;
|
|
59
|
+
if ('descricao' in c) a.descricao = uma(c.descricao).slice(0, 200);
|
|
60
|
+
if ('icone' in c && c.icone) { if (!ICONES.includes(c.icone)) throw new Error(`ícone inválido (use: ${ICONES.join(', ')})`); a.icone = c.icone; }
|
|
61
|
+
if ('cor' in c && c.cor) { if (!hexOk(c.cor)) throw new Error('cor inválida (use #rrggbb)'); a.cor = String(c.cor).toLowerCase(); }
|
|
62
|
+
if ('onde' in c) a.onde = uma(c.onde) || 'fora';
|
|
63
|
+
if ('ligado' in c) a.ligado = c.ligado === true;
|
|
64
|
+
if ('oficio' in c) a.oficio = String(c.oficio || '').slice(0, 4000);
|
|
65
|
+
if (!a.oficio.trim()) throw new Error('falta o ofício (o que o agente faz)');
|
|
66
|
+
return a;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function criar(c, { dir } = {}) {
|
|
70
|
+
const a = validar(c, null);
|
|
71
|
+
if (ler(a.nome, { dir })) throw new Error(`já existe o agente @${a.nome}`);
|
|
72
|
+
return gravar(a, dir);
|
|
73
|
+
}
|
|
74
|
+
function atualizar(nome, c, { dir } = {}) {
|
|
75
|
+
const atual = ler(nome, { dir });
|
|
76
|
+
if (!atual) throw new Error(`não achei o agente ${nome}`);
|
|
77
|
+
return gravar(validar(c, atual), dir);
|
|
78
|
+
}
|
|
79
|
+
function alternar(nome, ligado, { dir } = {}) {
|
|
80
|
+
const atual = ler(nome, { dir });
|
|
81
|
+
if (!atual) throw new Error(`não achei o agente ${nome}`);
|
|
82
|
+
return gravar({ ...atual, ligado: typeof ligado === 'boolean' ? ligado : !atual.ligado }, dir);
|
|
83
|
+
}
|
|
84
|
+
function apagar(nome, { dir } = {}) {
|
|
85
|
+
const s = slug(nome);
|
|
86
|
+
if (!s || !ler(s, { dir })) throw new Error(`não achei o agente ${nome}`);
|
|
87
|
+
fs.unlinkSync(path.join(pasta(dir), `${s}.md`));
|
|
88
|
+
return { nome: s, apagado: true };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/* Os @agente do texto (ligados ou não) e os que a tela mandou explicitamente. */
|
|
92
|
+
function mencionados(texto, extras = [], { dir } = {}) {
|
|
93
|
+
const todos = listar({ dir });
|
|
94
|
+
const vistos = new Set();
|
|
95
|
+
for (const m of String(texto || '').matchAll(/(?:^|[\s(])@([\w-]{1,32})/g)) vistos.add(slug(m[1]));
|
|
96
|
+
for (const e of Array.isArray(extras) ? extras : []) vistos.add(slug(e));
|
|
97
|
+
return todos.filter((a) => vistos.has(a.nome));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/* O texto que entra no pedido: chamada explícita (@) manda; os ligados vão de cardápio, curtos.
|
|
101
|
+
O plano grátis conta idas ao modelo: o modelo só chama quem o pedido pede. */
|
|
102
|
+
function instrucao(texto, extras = [], { dir } = {}) {
|
|
103
|
+
const chamados = mencionados(texto, extras, { dir });
|
|
104
|
+
const nomes = new Set(chamados.map((a) => a.nome));
|
|
105
|
+
const ligados = listar({ dir }).filter((a) => a.ligado && !nomes.has(a.nome));
|
|
106
|
+
const linhas = [];
|
|
107
|
+
for (const a of chamados) {
|
|
108
|
+
linhas.push(`A pessoa chamou @${a.nome} (${a.descricao || 'agente dela'}). Chame spawn_agent com agente: "${a.nome}" e, em prompt, o pedido dela completo.`);
|
|
109
|
+
}
|
|
110
|
+
if (ligados.length) {
|
|
111
|
+
linhas.push('Agentes ligados da pessoa (chame com spawn_agent agente:"<nome>" SÓ se o pedido casa com o ofício; nenhum, se não casar):\n'
|
|
112
|
+
+ ligados.map((a) => `- ${a.nome}: ${a.descricao || a.oficio.slice(0, 80)}`).join('\n'));
|
|
113
|
+
}
|
|
114
|
+
return { texto: linhas.join('\n'), chamados };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/* O briefing que vai na frente da tarefa do subagente (molde de lib/especialistas.js). */
|
|
118
|
+
const comoEspecialista = (a) => ({ nome: a.nome, faz: a.descricao, briefing: `Você é o agente @${a.nome}. Seu ofício:\n${a.oficio}` });
|
|
119
|
+
|
|
120
|
+
module.exports = { ICONES, slug, ler, listar, criar, atualizar, alternar, apagar, mencionados, instrucao, comoEspecialista };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// `primocode server [--stdio | --ws [porta] [--rede]]` — sobe o app-server local.
|
|
2
|
+
// stdio é o padrão: stdout fica só para o JSON-RPC, avisos vão para stderr.
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const { criarServidor } = require('./servidor');
|
|
6
|
+
|
|
7
|
+
async function main(args) {
|
|
8
|
+
const i = args.indexOf('--ws');
|
|
9
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
10
|
+
console.log([
|
|
11
|
+
'Uso: primocode server [--stdio | --ws [porta] [--rede]]',
|
|
12
|
+
' primocode server --pareamentos | --revogar <id>',
|
|
13
|
+
' --stdio JSON-RPC em linhas no stdin/stdout (padrão)',
|
|
14
|
+
' --ws WebSocket em 127.0.0.1 (porta livre se omitida); porta e token em ~/.primocode/app-server.json',
|
|
15
|
+
' --rede (com --ws) escuta na rede local, para parear um celular. Só quem tem um token de pareamento entra;',
|
|
16
|
+
' o token de sessão continua valendo só neste computador. Sem --rede, nada sai de 127.0.0.1.',
|
|
17
|
+
' --pareamentos lista os celulares pareados (sem mostrar token)',
|
|
18
|
+
' --revogar <id> revoga um pareamento (vale na hora, mesmo com o servidor rodando)',
|
|
19
|
+
].join('\n'));
|
|
20
|
+
return 0;
|
|
21
|
+
}
|
|
22
|
+
if (args.includes('--pareamentos') || args.includes('--revogar')) {
|
|
23
|
+
const P = require('./pareamento').criarPareamentos({ arquivo: require('path').join(require('../config/config').CONFIG_DIR, 'pareamentos.json') });
|
|
24
|
+
const k = args.indexOf('--revogar');
|
|
25
|
+
if (k >= 0) {
|
|
26
|
+
const id = args[k + 1] || '';
|
|
27
|
+
if (!P.revogar(id)) { console.error(`não existe o pareamento "${id}"`); return 1; }
|
|
28
|
+
console.log(`pareamento ${id} revogado`);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
const lista = P.listar();
|
|
32
|
+
if (!lista.length) console.log('nenhum celular pareado');
|
|
33
|
+
for (const r of lista) console.log(`${r.id} ${r.nome} criado ${new Date(r.criado).toLocaleString('pt-BR')} último uso ${r.ultimoUso ? new Date(r.ultimoUso).toLocaleString('pt-BR') : 'nunca'}`);
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
36
|
+
if (args.includes('--rede') && i < 0) { console.error('--rede só vale com --ws.'); return 1; }
|
|
37
|
+
if (i >= 0 && args.includes('--stdio')) { console.error('Use --stdio ou --ws, não os dois.'); return 1; }
|
|
38
|
+
// PRIMOCODE_MOTOR_FALSO=<arquivo.js>: um motor de mentira (sem rede), para testar a tela.
|
|
39
|
+
const falso = process.env.PRIMOCODE_MOTOR_FALSO;
|
|
40
|
+
// PRIMOCODE_CONTA_FALSA=<arquivo.js>: { buscar, abrir } de mentira para o login (sem rede, sem navegador). Só para teste.
|
|
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
|
+
const srv = criarServidor({
|
|
45
|
+
...(transcritorFalso ? { transcritor: require(require('path').resolve(transcritorFalso)) } : {}),
|
|
46
|
+
...(falso ? { motor: require(require('path').resolve(falso)) } : {}),
|
|
47
|
+
...(contaFalsa ? { conta: require(require('path').resolve(contaFalsa)) } : {}),
|
|
48
|
+
});
|
|
49
|
+
if (i >= 0) {
|
|
50
|
+
const pedida = /^\d+$/.test(args[i + 1] || '') ? Number(args[i + 1]) : 0;
|
|
51
|
+
const rede = args.includes('--rede');
|
|
52
|
+
const { porta } = await srv.ws({ porta: pedida, rede });
|
|
53
|
+
console.error(`app-server em ws://127.0.0.1:${porta} (token em ${srv.arquivoInfo})`);
|
|
54
|
+
if (rede) console.error('modo --rede: escutando na rede local; só entra celular com token de pareamento (tela "Parear celular")');
|
|
55
|
+
const sair = () => srv.fechar().then(() => process.exit(0));
|
|
56
|
+
process.on('SIGINT', sair);
|
|
57
|
+
process.on('SIGTERM', sair);
|
|
58
|
+
return new Promise(() => {}); // roda até o sinal
|
|
59
|
+
}
|
|
60
|
+
srv.stdio(process.stdin, process.stdout);
|
|
61
|
+
return new Promise((resolve) => process.stdin.on('end', () => resolve(0)));
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
module.exports = { main };
|
|
@@ -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 };
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Pareamento do celular (docs/estrutura.md, seção 15). Cada celular tem um token PRÓPRIO, separado do
|
|
2
|
+
// token de sessão do motor: dá para listar quem está pareado e revogar um sem derrubar o resto.
|
|
3
|
+
// Só o hash SHA-256 fica no disco (`pareamentos.json`, 0600); o token aparece uma única vez, na criação.
|
|
4
|
+
'use strict';
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const crypto = require('crypto');
|
|
9
|
+
|
|
10
|
+
const PREFIXO = 'pcp_';
|
|
11
|
+
const hash = (t) => crypto.createHash('sha256').update(String(t)).digest('hex');
|
|
12
|
+
|
|
13
|
+
function criarPareamentos({ arquivo }) {
|
|
14
|
+
function ler() {
|
|
15
|
+
try { const d = JSON.parse(fs.readFileSync(arquivo, 'utf8')); return Array.isArray(d.pareamentos) ? d.pareamentos : []; } catch { return []; }
|
|
16
|
+
}
|
|
17
|
+
function gravar(lista) {
|
|
18
|
+
fs.mkdirSync(path.dirname(arquivo), { recursive: true });
|
|
19
|
+
fs.writeFileSync(arquivo, JSON.stringify({ pareamentos: lista }, null, 2), { mode: 0o600 });
|
|
20
|
+
try { fs.chmodSync(arquivo, 0o600); } catch { /* sem chmod */ }
|
|
21
|
+
}
|
|
22
|
+
const publico = (r) => ({ id: r.id, nome: r.nome, criado: r.criado, ultimoUso: r.ultimoUso || null });
|
|
23
|
+
|
|
24
|
+
return {
|
|
25
|
+
/* Gera o token e guarda só o hash. O token volta aqui e em nenhum outro lugar. */
|
|
26
|
+
criar(nome) {
|
|
27
|
+
const token = PREFIXO + crypto.randomBytes(24).toString('hex');
|
|
28
|
+
const r = { id: crypto.randomBytes(4).toString('hex'), nome: String(nome || '').trim().slice(0, 60) || 'Celular', criado: Date.now(), ultimoUso: null, hash: hash(token) };
|
|
29
|
+
gravar([...ler(), r]);
|
|
30
|
+
return { ...publico(r), token };
|
|
31
|
+
},
|
|
32
|
+
listar() { return ler().map(publico); },
|
|
33
|
+
revogar(id) {
|
|
34
|
+
const lista = ler();
|
|
35
|
+
const resto = lista.filter((r) => r.id !== id);
|
|
36
|
+
if (resto.length === lista.length) return false;
|
|
37
|
+
gravar(resto);
|
|
38
|
+
return true;
|
|
39
|
+
},
|
|
40
|
+
/* O registro público de quem tem esse token, ou null. Relê o arquivo: revogar de fora vale na hora. */
|
|
41
|
+
validar(token) {
|
|
42
|
+
const t = String(token || '');
|
|
43
|
+
if (!t.startsWith(PREFIXO)) return null;
|
|
44
|
+
const h = Buffer.from(hash(t));
|
|
45
|
+
for (const r of ler()) {
|
|
46
|
+
const o = Buffer.from(String(r.hash || ''));
|
|
47
|
+
if (o.length === h.length && crypto.timingSafeEqual(o, h)) return publico(r);
|
|
48
|
+
}
|
|
49
|
+
return null;
|
|
50
|
+
},
|
|
51
|
+
marcarUso(id) {
|
|
52
|
+
const lista = ler();
|
|
53
|
+
const r = lista.find((x) => x.id === id);
|
|
54
|
+
if (!r || Date.now() - (r.ultimoUso || 0) < 30000) return;
|
|
55
|
+
r.ultimoUso = Date.now();
|
|
56
|
+
try { gravar(lista); } catch { /* sem gravar o uso */ }
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
module.exports = { criarPareamentos, PREFIXO };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// JSON-RPC 2.0 em linhas (NDJSON). Só texto: parse, montagem e erros padrão.
|
|
2
|
+
// Node puro, sem dependência. Contrato em docs/estrutura.md, seção 3.
|
|
3
|
+
'use strict';
|
|
4
|
+
|
|
5
|
+
const ERRO = Object.freeze({
|
|
6
|
+
PARSE: -32700,
|
|
7
|
+
PEDIDO_INVALIDO: -32600,
|
|
8
|
+
METODO: -32601,
|
|
9
|
+
PARAMS: -32602,
|
|
10
|
+
INTERNO: -32603,
|
|
11
|
+
// Faixa livre do JSON-RPC (-32000..-32099): erros do PrimoCode.
|
|
12
|
+
OCUPADO: -32000,
|
|
13
|
+
NAO_ACHEI: -32001,
|
|
14
|
+
NEGADO: -32002,
|
|
15
|
+
AINDA_NAO: -32003,
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const MENSAGEM = Object.freeze({
|
|
19
|
+
[ERRO.PARSE]: 'JSON inválido',
|
|
20
|
+
[ERRO.PEDIDO_INVALIDO]: 'Pedido inválido',
|
|
21
|
+
[ERRO.METODO]: 'Método não existe',
|
|
22
|
+
[ERRO.PARAMS]: 'Parâmetros inválidos',
|
|
23
|
+
[ERRO.INTERNO]: 'Erro interno',
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/* Erro com código, para os métodos lançarem: throw erro(ERRO.PARAMS, 'falta texto'). */
|
|
27
|
+
function erro(codigo, mensagem, data) {
|
|
28
|
+
const e = new Error(mensagem || MENSAGEM[codigo] || 'Erro');
|
|
29
|
+
e.codigo = codigo;
|
|
30
|
+
if (data !== undefined) e.data = data;
|
|
31
|
+
return e;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* Error não vira JSON sozinho (sai `{}`): troca por { mensagem }. */
|
|
35
|
+
function substituto(_chave, valor) {
|
|
36
|
+
if (valor instanceof Error) return { mensagem: valor.message, nome: valor.name };
|
|
37
|
+
if (typeof valor === 'bigint') return String(valor);
|
|
38
|
+
return valor;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const serializar = (obj) => JSON.stringify(obj, substituto) + '\n';
|
|
42
|
+
|
|
43
|
+
const resposta = (id, resultado) => ({ jsonrpc: '2.0', id, result: resultado === undefined ? null : resultado });
|
|
44
|
+
const respostaErro = (id, codigo, mensagem, data) => {
|
|
45
|
+
const error = { code: codigo, message: mensagem || MENSAGEM[codigo] || 'Erro' };
|
|
46
|
+
if (data !== undefined) error.data = data;
|
|
47
|
+
return { jsonrpc: '2.0', id: id === undefined ? null : id, error };
|
|
48
|
+
};
|
|
49
|
+
const notificacao = (method, params) => ({ jsonrpc: '2.0', method, params });
|
|
50
|
+
|
|
51
|
+
/* Uma linha → { msg } ou { erro: <resposta de erro pronta> }.
|
|
52
|
+
Aceita pedido (com id) e notificação (sem id). Lote ([...]) não é suportado. */
|
|
53
|
+
function interpretar(linha) {
|
|
54
|
+
const texto = String(linha || '').trim();
|
|
55
|
+
if (!texto) return { vazio: true };
|
|
56
|
+
let msg;
|
|
57
|
+
try { msg = JSON.parse(texto); } catch { return { erro: respostaErro(null, ERRO.PARSE) }; }
|
|
58
|
+
if (!msg || typeof msg !== 'object' || Array.isArray(msg) || msg.jsonrpc !== '2.0') {
|
|
59
|
+
return { erro: respostaErro(msg && msg.id, ERRO.PEDIDO_INVALIDO, 'Pedido inválido (precisa de jsonrpc "2.0", sem lote)') };
|
|
60
|
+
}
|
|
61
|
+
if (typeof msg.method !== 'string' || !msg.method) {
|
|
62
|
+
// Respostas do cliente (id sem method) não são pedido: ignoradas pelo servidor.
|
|
63
|
+
if ('id' in msg && ('result' in msg || 'error' in msg)) return { resposta: msg };
|
|
64
|
+
return { erro: respostaErro(msg.id, ERRO.PEDIDO_INVALIDO, 'Falta "method"') };
|
|
65
|
+
}
|
|
66
|
+
if (msg.params !== undefined && (msg.params === null || typeof msg.params !== 'object')) {
|
|
67
|
+
return { erro: respostaErro(msg.id, ERRO.PARAMS, 'params precisa ser objeto') };
|
|
68
|
+
}
|
|
69
|
+
return { msg };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* Corta um fluxo de bytes em linhas inteiras. push(texto) devolve as linhas completas. */
|
|
73
|
+
function cortador() {
|
|
74
|
+
let resto = '';
|
|
75
|
+
return {
|
|
76
|
+
push(pedaco) {
|
|
77
|
+
resto += pedaco;
|
|
78
|
+
const partes = resto.split('\n');
|
|
79
|
+
resto = partes.pop();
|
|
80
|
+
return partes;
|
|
81
|
+
},
|
|
82
|
+
sobra: () => resto,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
module.exports = { ERRO, erro, serializar, resposta, respostaErro, notificacao, interpretar, cortador };
|