primocode 10.14.0 → 10.15.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.
@@ -0,0 +1,19 @@
1
+ Robô 3D da tela do Primo (voz/primo_voz/vendor/RobotExpressive.glb)
2
+
3
+ Modelo: RobotExpressive, de Tomás Laulhé (https://www.patreon.com/quaternius). CC0 1.0 (domínio público).
4
+ Modificações de Don McCurdy: três morph targets de expressão, conversão com FBX2GLTF, materiais deduplicados.
5
+ Fonte: https://github.com/mrdoob/three.js/tree/r170/examples/models/gltf/RobotExpressive (README conferido na tag r170).
6
+ Aqui as cores dos materiais são trocadas em tempo de execução (branco/grafite + ciano); o arquivo .glb é o original.
7
+
8
+ three.js r170 + GLTFLoader (voz/primo_voz/vendor/three-robo.min.js, bundle mínimo gerado com esbuild)
9
+ Licença MIT, Copyright 2010-2024 three.js authors. Texto completo: voz/primo_voz/vendor/three.LICENSE.txt
10
+
11
+ --- Robô do Spline (voz.json: "robo": "spline", o padrão; "expressive" volta ao RobotExpressive acima) ---
12
+
13
+ Cena: "AI Robotics Landing Page" (template da comunidade v0/Spline)
14
+ https://v0.app/templates/ai-robotics-landing-page-iArz1CoDHnf
15
+ https://prod.spline.design/kZDDjO5HuC9GJUM2/scene.splinecode (baixada em tempo de execução, precisa de internet; não vai no repo)
16
+ Licença da cena: [VERIFICAR] — confirmar com o autor/Spline antes de uso comercial. Uso atual: pedido do Joel, uso interno.
17
+ Runtime: @splinetool/runtime 1.12.95 (voz/primo_voz/vendor/spline-runtime.min.js, bundle IIFE gerado com esbuild; local, sem CDN).
18
+ O pacote npm não declara campo "license" [VERIFICAR] — o Joel informou MIT; conferir no repositório splinetool antes de redistribuir.
19
+ Sem internet ou cena lenta (> 6 s): cai no RobotExpressive (CC0).
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # PrimoCode v10.14.0
1
+ # PrimoCode v10.15.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 v10.14.0
12
+ ▐▛███▜▌ Primo Code v10.15.0
13
13
  ▝▜█████▛▘ Bem-vindo de volta, Joel
14
14
  ▘▘ ▝▝ ~/primocode · /dir <pasta> muda
15
15
  ```
@@ -17,6 +17,10 @@ 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
+ ## Primo (voz)
21
+
22
+ `/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`.
23
+
20
24
  ## Novidades da 10.14
21
25
 
22
26
  - **Vídeo de motion de ponta a ponta** (EffectCraft, 68 receitas, voz, trilha, prévia e editor por comando). Detalhes no `CHANGELOG.md`.
package/bin/primocode.js CHANGED
@@ -212,7 +212,9 @@ const SLASH_COMANDOS = [
212
212
  ['/chats', 'suas conversas salvas, as mesmas do app e do celular'],
213
213
  ['/logout', 'desconecta este computador da conta'],
214
214
  ['!comando', 'roda direto no shell, sem passar pelo agente (o campo fica âmbar)'],
215
- ['/voice', 'liga o microfone: você fala, ele faz, e a resposta sai em voz alta'],
215
+ ['/voice', 'liga o Primo: diga "Primo, ..."; ele faz e responde em voz alta, com a tela dele (robô, agenda, clima, música)'],
216
+ ['/voice painel', 'só o painel do Primo (ESC esconde)'],
217
+ ['/voice nome <Nome>', 'dá outro nome ao assistente (vira a palavra de ativação)'],
216
218
  ['/voice parar', 'desliga o modo voz e fecha o microfone'],
217
219
  ['/skills', 'o que ele sabe fazer bem — ligue e desligue'],
218
220
  ['/marketplace', 'a tela das skills: navegue, ligue, crie'],
@@ -1619,6 +1621,7 @@ let chatEmVoo = null;
1619
1621
  /* O ouvinte de voz em segundo plano.
1620
1622
  Um por sessão: dois processos disputando o mesmo microfone dão um vazio
1621
1623
  intermitente que parece defeito de hardware e não é. */
1624
+ let vozPainel = null; // /voice painel: só o HUD
1622
1625
  let vozEmEspera = null; // a sessão do /voice: { processo, falar, calar }
1623
1626
  /* Por onde a fala entra no terminal. Nasce no main(), junto da fila — é lá
1624
1627
  que moram o `busy`, a fila e o `ask` pendente. */
@@ -2474,8 +2477,26 @@ async function handleSlash(text) {
2474
2477
 
2475
2478
  Desligar é o primeiro caso de propósito: quem quer fechar o
2476
2479
  microfone quer AGORA, sem esbarrar em checagem de dependência. */
2477
- if (/^(parar|stop|off|sair)$/i.test((rest || '').trim())) {
2478
- if (!vozEmEspera) { console.log(c.muted(' O modo voz não está ligado.')); break; }
2480
+ const argVoz = (rest || '').trim();
2481
+ // Só o painel (HUD em tela cheia no Mac; quadro no terminal fora dele).
2482
+ if (/^painel$/i.test(argVoz)) {
2483
+ const py = vozModo.python();
2484
+ if (!py) { console.log(c.err(' O painel precisa do Python 3.')); break; }
2485
+ if (vozPainel) { try { vozPainel.processo.kill(); } catch {} vozPainel = null; }
2486
+ vozPainel = vozModo.abrirPainel({ python: py });
2487
+ console.log(c.muted(vozPainel.tela ? ' Tela do Primo aberta — ESC esconde.' : ' Sem o painel gráfico aqui: rode `primocode voz painel` num terminal separado.'));
2488
+ break;
2489
+ }
2490
+ // /voice nome <Nome>: a pessoa dá o nome ao assistente (e a palavra de ativação passa a ser ele).
2491
+ const mNome = argVoz.match(/^nome\s+(.{2,20})$/i);
2492
+ if (mNome) {
2493
+ const salvo = vozModo.definirNome(mNome[1]);
2494
+ console.log(salvo ? c.ok(` Fechado: o assistente agora se chama ${salvo}. Vale na próxima vez que ligar o /voice.`) : c.err(' Nome inválido (1 ou 2 palavras, até 20 letras).'));
2495
+ break;
2496
+ }
2497
+ if (/^(parar|stop|off|sair)$/i.test(argVoz)) {
2498
+ if (vozPainel) { try { vozPainel.processo.kill(); } catch {} vozPainel = null; }
2499
+ if (!vozEmEspera) { console.log(c.muted(vozPainel === null ? ' Modo voz e painel fechados.' : ' O modo voz não está ligado.')); break; }
2479
2500
  try { vozEmEspera.processo.kill(); } catch {}
2480
2501
  vozEmEspera = null;
2481
2502
  console.log(c.muted(' Modo voz desligado. O microfone fechou.'));
@@ -2508,10 +2529,12 @@ async function handleSlash(text) {
2508
2529
  console.log(' ' + c.bold(c.brand('🎙 Modo voz ligado')) + c.muted(' — é só falar. A resposta sai em voz alta.'));
2509
2530
  console.log(' ' + c.dim('Falar por cima corta a voz · digitar continua valendo · /voice parar desliga'));
2510
2531
  },
2532
+ aoParar: () => { try { require('../lib/act.js').interromper(); } catch { /* nada rodando */ } },
2511
2533
  aoOuvir: (texto) => { if (entradaDeVoz) entradaDeVoz(texto); },
2512
2534
  mostrar: (texto) => console.log(' ' + c.dim(texto)),
2513
2535
  });
2514
2536
  vozEmEspera = sessao;
2537
+ require('../lib/voz-ponte').escutar((nome, args) => sessao.ferramenta(nome, args));
2515
2538
  sessao.pronto.then((r) => {
2516
2539
  if (vozEmEspera === sessao) vozEmEspera = null;
2517
2540
  if (r && !r.ok) console.log(c.err(` o modo voz fechou: ${r.error}`));
@@ -2883,6 +2906,25 @@ async function main() {
2883
2906
  process.exit(require('../lib/skills.js').main(process.argv.slice(3)));
2884
2907
  }
2885
2908
  // `primocode planos`: a tabela comparativa, sem abrir a sessão.
2909
+ // `primocode voz nome <Nome>` / `primocode voz painel`: o Primo sem abrir a sessão.
2910
+ if (process.argv[2] === 'voz') {
2911
+ const vz = require('../lib/voz.js');
2912
+ const [sub, ...resto] = process.argv.slice(3);
2913
+ if (sub === 'nome') {
2914
+ const n = vz.definirNome(resto.join(' '));
2915
+ console.log(n ? `Fechado: o assistente agora se chama ${n}.` : 'Uso: primocode voz nome <Nome> (1 ou 2 palavras)');
2916
+ process.exit(n ? 0 : 1);
2917
+ }
2918
+ if (sub === 'painel') {
2919
+ const py = vz.python();
2920
+ if (!py) { console.log('O painel precisa do Python 3.'); process.exit(1); }
2921
+ const r = vz.abrirPainel({ python: py });
2922
+ r.processo.on('close', () => process.exit(0));
2923
+ return;
2924
+ }
2925
+ console.log('Uso: primocode voz nome <Nome> | primocode voz painel (o resto é /voice dentro da sessão)');
2926
+ process.exit(0);
2927
+ }
2886
2928
  if (['doutor', 'doctor', 'preparar'].includes(process.argv[2])) {
2887
2929
  process.exit(await require('../lib/preparar.js').main(process.argv.slice(2)));
2888
2930
  }
@@ -3129,6 +3171,7 @@ async function main() {
3129
3171
  }
3130
3172
  } catch (e) {
3131
3173
  fala.problema(e);
3174
+ if (vozEmEspera && vozEmEspera.estado) vozEmEspera.estado('erro');
3132
3175
  }
3133
3176
  // Pedido falado (ou tarefa que recebeu recado falado): a resposta
3134
3177
  // final sai em voz alta, resumida. O detalhe fica na tela.
package/lib/act.js CHANGED
@@ -13,6 +13,8 @@
13
13
  const { c, Spinner, PENSANDO } = require('./ui');
14
14
  const fala = require('./fala');
15
15
  const especialistas = require('./especialistas');
16
+ const limites = require('./limites');
17
+ let avisouParalelo = false;
16
18
  const tools = require('./tools');
17
19
  const api = require('./api');
18
20
  const cfg = require('./config');
@@ -844,6 +846,7 @@ async function runAct(userPrompt, opts) {
844
846
  let paradoPorTravar = null;
845
847
  /* Anota ANTES de agir (lib/auditoria.js): recusado também fica no livro. */
846
848
  const anotar = (tc, decisao, motivo) => {
849
+ if (decisao !== 'recusada' && decisao !== 'negada') require('./voz-ponte').ferramenta(tc.name, tc.arguments);
847
850
  const id = auditoria.antes({ agente: rotulo, ferramenta: tc.name, args: tc.arguments, decisao, motivo });
848
851
  const falhou = auditoria.problema();
849
852
  if (falhou) console.log(c.warn(`⚠ ${falhou}`));
@@ -1322,8 +1325,12 @@ async function runAct(userPrompt, opts) {
1322
1325
  const spawns = toolCalls.filter((tc) => tc.name === 'spawn_agent');
1323
1326
  const resultadosSpawn = new Map();
1324
1327
  if (spawns.length && !PARAR) garantirPasta();
1325
- if (spawns.length > 1 && !PARAR) {
1326
- const juntos = spawns.slice(0, 8);
1328
+ /* O plano decide quantos subagentes rodam JUNTOS (lib/limites.js; grátis e
1329
+ Primo: um de cada vez, o resto continua em fila). Nunca recusa: só diz o porquê. */
1330
+ const par = spawns.length > 1 ? await limites.paralelo() : null;
1331
+ if (par) { const av = par.aviso(spawns.length); if (av && !avisouParalelo) { avisouParalelo = true; console.log(` ${av}`); } }
1332
+ if (spawns.length > 1 && par.max > 1 && !PARAR) {
1333
+ const juntos = spawns.slice(0, par.max);
1327
1334
  fala.paralelo(juntos.length, profundidade);
1328
1335
  const ids = juntos.map((tc) => anotar(tc, 'permitida'));
1329
1336
  const rs = await Promise.all(juntos.map((tc) =>
@@ -19,7 +19,7 @@ const zlib = require('zlib');
19
19
  const { spawnSync } = require('child_process');
20
20
 
21
21
  const LOCAL = { ok: true, offline: true, liberado: true, plano: 'local', restam: null, usados: 0,
22
- recursos: { videosMes: 0, resolucao: 2160, marcaDagua: false } };
22
+ recursos: { videosMes: 0, resolucao: 2160, marcaDagua: false, narracaoPremium: true, clubeCompleto: true, scriptsAE: true, subagentes: 7, kitsMarca: 0, versoesEditor: 0 } };
23
23
 
24
24
  /** Pergunta (acao "checar") ou conta (acao "gastar") no servidor. */
25
25
  async function consultar(acao = 'checar', { buscar = fetch, servidor, token, prazo = 6000 } = {}) {
@@ -33,6 +33,7 @@ async function consultar(acao = 'checar', { buscar = fetch, servidor, token, pra
33
33
  });
34
34
  const j = await r.json();
35
35
  if (!r.ok || !j || !j.recursos) return LOCAL;
36
+ require('../limites.js').guardar(j);
36
37
  return j;
37
38
  } catch { return LOCAL; }
38
39
  }
@@ -137,14 +138,20 @@ function aplicar(arquivo, recursos, { rodar = spawnSync, dim = dimensoes } = {})
137
138
  async function comCota(render, deps = {}) {
138
139
  const c = await (deps.consultar || consultar)('checar');
139
140
  if (!c.liberado) return { ok: false, error: mensagemLimite(c), cota: { plano: c.plano, restam: 0, upgrade: c.upgrade } };
141
+ const lim = require('../limites.js');
142
+ lim.guardar(c); lim.pegarAvisos();
140
143
  const r = await render();
144
+ const extra = lim.pegarAvisos();
141
145
  if (!r || !r.ok) return r;
142
146
  const a = c.offline ? { ok: true, mudou: false } : (deps.aplicar || aplicar)(r.video, c.recursos);
143
147
  if (!c.offline) await (deps.consultar || consultar)('gastar');
144
148
  const restam = c.restam == null ? null : Math.max(0, c.restam - 1);
145
149
  return { ...r, plano: { id: c.plano, restam, resolucao: c.recursos.resolucao, marcaDagua: !!(a.mudou && c.recursos.marcaDagua) },
146
- ...(c.offline ? { aviso: 'sem rede: modo local, sem cota nem marca d\'água.' }
147
- : restam === 0 ? { aviso: 'Foi o seu último vídeo do mês. Veja os planos com: primocode planos' } : {}) };
150
+ ...((() => {
151
+ const a = [...(c.offline ? ['sem rede: modo local, sem cota nem marca d\'água.'] : []),
152
+ ...(!c.offline && restam === 0 ? ['Foi o seu último vídeo do mês. Veja os planos com: primocode planos'] : []), ...extra];
153
+ return a.length ? { aviso: a.join(' ') } : {};
154
+ })()) };
148
155
  }
149
156
 
150
157
  module.exports = { LOCAL, consultar, mensagemLimite, pngMarca, filtros, aplicar, comCota };
@@ -249,6 +249,11 @@ async function editar(args = {}, { pasta, deps = {} } = {}) {
249
249
  return { ok: true, versao: v.n, video, versoes: lista(), nota: `voltou para a v${v.n}; a próxima mudança vira a v${ix.versoes.reduce((m, x) => Math.max(m, x.n), 0) + 1}.` };
250
250
  }
251
251
 
252
+ // O plano limita as edições guardadas por vídeo (grátis 3). Prévia e a versão original não contam.
253
+ const gastas = indice(pasta).versoes.filter((v) => !v.previa && v.resumo !== 'original').length;
254
+ const lim = await (deps.cabeMais ? deps.cabeMais(gastas) : require('../limites.js').cabeMais('versoesEditor', gastas));
255
+ if (!lim.ok && !args.previa) return { ok: false, error: lim.error + ' Dá para voltar a uma versão (voltar_para) ou ver as prévias.' };
256
+
252
257
  const spec = ler(ARQ(pasta));
253
258
  const mudancas = Array.isArray(args.mudancas) ? args.mudancas : [];
254
259
  if (!mudancas.length && !args.final) return { ok: false, error: 'mudancas está vazio (ou use final:true para renderizar a prévia em qualidade final).' };
@@ -508,7 +508,7 @@ async function ferramentaMidia(args) {
508
508
  if (args.id && !p) return semProjeto(args.id);
509
509
 
510
510
  if (acao === 'projetos') return require('./projetos.js').procurar({ termo: args.termo, categoria: args.categoria, limite: args.limite });
511
- if (acao === 'projeto') return require('./projetos.js').ver(args.nome || args.id_item || args.termo);
511
+ if (acao === 'projeto') return require('./projetos.js').ver(args.nome || args.id_item || args.termo, { c: await require('../limites.js').situacao() });
512
512
 
513
513
  if (acao === 'fontes') {
514
514
  if (args.conferir) return assets.conferirFonte(args.conferir);
@@ -712,8 +712,10 @@ async function ferramentaMotion(args) {
712
712
  if (!args.id || !p) return semProjeto(args.id);
713
713
  let receita = null;
714
714
  if (args.receita) {
715
- const r = require('./projetos.js').ver(args.receita);
715
+ const r = require('./projetos.js').ver(args.receita, { c: await require('../limites.js').situacao() });
716
716
  if (!r.ok) return r;
717
+ // Grátis vê o resumo da receita, não monta a partir dela: diz o porquê em vez de renderizar pela metade.
718
+ if (r.bloqueado) return { ok: false, error: r.aviso, projeto: r.projeto };
717
719
  receita = r.projeto;
718
720
  }
719
721
  return require('./cota.js').comCota(() => renderMotion(args, p, receita));
@@ -1204,6 +1206,11 @@ function vazamentoNoComeco(lista, palavras, direcao = '') {
1204
1206
  }
1205
1207
 
1206
1208
  async function narrarComGemini(lista, mp3, { voz, direcao } = {}) {
1209
+ /* A narração premium é do Pro em diante (lib/limites.js). Sem ela a corrente
1210
+ segue para a voz local (Kokoro/Edge) e o aviso explica o porquê. */
1211
+ const lim = require('../limites.js');
1212
+ const prem = await lim.exigir('narracaoPremium');
1213
+ if (!prem.ok) { lim.avisar(prem.error + ' A narração saiu com a voz local.'); return { ok: false, error: prem.error }; }
1207
1214
  try {
1208
1215
  const cfg = require('../config').loadConfig();
1209
1216
  const api = require('../api');
@@ -47,11 +47,23 @@ function procurar({ termo, categoria, limite = 10 } = {}) {
47
47
  }
48
48
 
49
49
  /** A receita inteira, pelo id (ou, na falta, o melhor palpite do texto). */
50
- function ver(id) {
50
+ function ver(id, { c } = {}) {
51
51
  const q = sem(id).trim();
52
52
  if (!q) return { ok: false, error: 'diga o id da receita (acao:"projetos" lista).' };
53
53
  const p = todos().find((x) => x.id === q) || procurar({ termo: q, limite: 1 }).projetos.map((x) => todos().find((y) => y.id === x.id))[0];
54
54
  if (!p) return { ok: false, error: `não há receita "${id}".`, ids: todos().map((x) => x.id) };
55
+ // O plano decide (lib/limites.js): grátis vê o resumo; sem scriptsAE a receita vem sem os scripts.
56
+ if (c && c.recursos) {
57
+ const lim = require('../limites.js');
58
+ if (!c.recursos.clubeCompleto) {
59
+ const { id: i, titulo, categoria, resumo, indicado, estilo, ficha } = p;
60
+ return { ok: true, bloqueado: true, projeto: { id: i, titulo, categoria, resumo, indicado, estilo, ficha }, aviso: lim.bloqueio('clubeCompleto', c) };
61
+ }
62
+ if (p.scripts_ae && !c.recursos.scriptsAE) {
63
+ const { scripts_ae: _, ...resto } = p;
64
+ return { ok: true, projeto: resto, scripts_ae_bloqueado: lim.bloqueio('scriptsAE', c) };
65
+ }
66
+ }
55
67
  // Os scripts do After Effects ficam em disco: a resposta leva o caminho, e o agente lê só o que precisar.
56
68
  if (p.scripts_ae) p.scripts_ae = { ...p.scripts_ae, caminho: path.join(__dirname, 'dados', 'projetos-ae', p.id.replace(/^clube-/, '')) };
57
69
  return { ok: true, projeto: p };
package/lib/limites.js ADDED
@@ -0,0 +1,100 @@
1
+ /**
2
+ * limites.js — o que o plano libera, aplicado de verdade na CLI.
3
+ *
4
+ * A tabela mora em server/lib/planos.js; o servidor responde o plano da pessoa
5
+ * em POST /api/video/cota (recursos + o catálogo enxuto). Aqui ficam:
6
+ * - situacao(): o plano agora, guardado 5 min na memória do processo;
7
+ * - o MODO LOCAL generoso (sem rede, servidor mudo ou campo que o servidor
8
+ * ainda não manda): nada é barrado. Errar para o lado de quem trabalha;
9
+ * - bloqueio(): a mensagem curta e honesta — o quê, em que plano você está,
10
+ * qual plano libera e o comando para assinar. Nunca barra sem dizer o porquê.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const GENEROSO = { videosMes: 0, resolucao: 2160, marcaDagua: false, narracaoPremium: true, clubeCompleto: true,
16
+ scriptsAE: true, prioridade: 0, subagentes: 7, kitsMarca: 0, versoesEditor: 0 };
17
+ const MAX_PARALELO = 8;
18
+ const VALIDADE = 5 * 60000;
19
+
20
+ let cache = null;
21
+ const avisos = [];
22
+
23
+ /** Completa o que o servidor não mandou com o generoso (campo novo, servidor velho). */
24
+ function completar(c) {
25
+ const r = { ...GENEROSO };
26
+ for (const [k, v] of Object.entries((c && c.recursos) || {})) if (v !== undefined && v !== null) r[k] = v;
27
+ return { ...(c || {}), recursos: r };
28
+ }
29
+
30
+ function guardar(c) { cache = { em: Date.now(), c: completar(c) }; }
31
+ function esquecer() { cache = null; }
32
+
33
+ /** Nos testes (node test/x.test.js) ninguém bate no servidor de verdade: vale o modo local. */
34
+ const emTeste = () => /\.test\.m?js$/.test(String(process.argv[1] || '')) || process.env.PRIMOCODE_LIMITES === 'local';
35
+
36
+ /** O plano agora: {plano, recursos, planos, offline}. Nunca lança. */
37
+ async function situacao(deps = {}) {
38
+ if (cache && Date.now() - cache.em < VALIDADE) return cache.c;
39
+ if (emTeste() && !deps.consultar) return completar(require('./estudio/cota.js').LOCAL);
40
+ let c;
41
+ try { c = await (deps.consultar || require('./estudio/cota.js').consultar)('checar'); } catch { c = require('./estudio/cota.js').LOCAL; }
42
+ guardar(c);
43
+ return cache.c;
44
+ }
45
+ const recursos = async (deps) => (await situacao(deps)).recursos;
46
+
47
+ const reais = (n) => `R$ ${Number(n).toFixed(2).replace('.', ',')}`;
48
+
49
+ /* Cada recurso: o que o plano ganha ao passar, e o texto de quando bate. */
50
+ const RECURSOS = {
51
+ narracaoPremium: { de: (r) => !!r.narracaoPremium, texto: 'A narração premium (voz do Gemini)', libera: 'a narração premium' },
52
+ clubeCompleto: { de: (r) => !!r.clubeCompleto, texto: 'A receita completa do Clube (etapas e prompt)', libera: 'as receitas completas do Clube' },
53
+ scriptsAE: { de: (r) => !!r.scriptsAE, texto: 'Os scripts de After Effects modelo', libera: 'os scripts de After Effects modelo' },
54
+ kitsMarca: { n: (r) => r.kitsMarca, texto: (r) => `Você já tem ${r.kitsMarca} kit${r.kitsMarca > 1 ? 's' : ''} de marca, o limite do plano`, libera: (r) => (r.kitsMarca ? `${r.kitsMarca} kits de marca` : 'kits de marca sem teto') },
55
+ versoesEditor: { n: (r) => r.versoesEditor, texto: (r) => `Este vídeo já tem ${r.versoesEditor} edições, o limite do plano`, libera: (r) => (r.versoesEditor ? `${r.versoesEditor} edições por vídeo` : 'edições sem teto') },
56
+ subagentes: { n: (r) => r.subagentes + 1, texto: (r) => `Seu plano roda ${r.subagentes + 1} subagente${r.subagentes ? 's juntos' : ' por vez'}`, libera: (r) => (r.subagentes ? `${r.subagentes + 1} subagentes juntos` : 'subagentes em paralelo') },
57
+ };
58
+
59
+ /** O primeiro plano acima do atual que libera `chave` (ou null). */
60
+ function quemLibera(chave, c) {
61
+ const def = RECURSOS[chave], lista = c.planos || [];
62
+ const i = lista.findIndex((p) => p.id === c.plano);
63
+ return lista.slice(i + 1).find((p) => (def.de ? def.de(p.recursos) : (def.n(p.recursos) === 0 || def.n(p.recursos) > def.n(c.recursos)))) || null;
64
+ }
65
+
66
+ /** A mensagem de limite: curta, com o motivo, o degrau que libera e o comando. */
67
+ function bloqueio(chave, c) {
68
+ const def = RECURSOS[chave], r = c.recursos;
69
+ const nome = ((c.planos || []).find((p) => p.id === c.plano) || {}).nome || c.plano;
70
+ const p = quemLibera(chave, c);
71
+ const texto = typeof def.texto === 'function' ? def.texto(r) : `${def.texto} não vêm no plano ${nome}`;
72
+ const libera = typeof def.libera === 'function' ? def.libera(p ? p.recursos : r) : def.libera;
73
+ return `${texto} (plano ${nome}).`
74
+ + (p ? ` O ${p.nome} (${reais(p.preco)}/mês) libera ${libera}.` : '')
75
+ + ' Veja e assine: primocode planos (ou primocode.web.app/planos).';
76
+ }
77
+
78
+ /** Libera o que é só ligado/desligado. {ok:true} ou {ok:false, error}. */
79
+ async function exigir(chave, deps) {
80
+ const c = await situacao(deps);
81
+ return RECURSOS[chave].de(c.recursos) ? { ok: true } : { ok: false, error: bloqueio(chave, c) };
82
+ }
83
+
84
+ /** Limite numérico: `usados` já é o que existe; passa se o teto é 0 (sem teto) ou se ainda cabe mais um. */
85
+ async function cabeMais(chave, usados, deps) {
86
+ const c = await situacao(deps), teto = RECURSOS[chave].n(c.recursos);
87
+ return !teto || usados < teto ? { ok: true } : { ok: false, error: bloqueio(chave, c) };
88
+ }
89
+
90
+ /** Quantos subagentes rodam juntos na mesma rodada (o principal fica de fora). */
91
+ async function paralelo(deps) {
92
+ const c = await situacao(deps);
93
+ return { max: Math.min(MAX_PARALELO, c.recursos.subagentes + 1), aviso: (n) => (n > c.recursos.subagentes + 1 ? bloqueio('subagentes', c) : null) };
94
+ }
95
+
96
+ /** Avisos que o resultado da ferramenta leva ao fim da vez (ex.: narração premium trocada pela local). */
97
+ function avisar(msg) { if (msg && !avisos.includes(msg)) avisos.push(msg); }
98
+ function pegarAvisos() { return avisos.splice(0); }
99
+
100
+ module.exports = { GENEROSO, MAX_PARALELO, situacao, recursos, guardar, esquecer, completar, bloqueio, quemLibera, exigir, cabeMais, paralelo, avisar, pegarAvisos, RECURSOS };
package/lib/marca-kit.js CHANGED
@@ -280,6 +280,9 @@ async function main(argv, { perguntar, dizer = console.log, pasta = process.cwd(
280
280
  const aqui = resto.includes('aqui');
281
281
  switch (op) {
282
282
  case 'criar': {
283
+ // O plano limita quantos kits se guarda (grátis 1): diz o porquê antes de perguntar qualquer coisa.
284
+ const lim = await (deps && deps.cabeMais ? deps.cabeMais(listar().length) : require('./limites.js').cabeMais('kitsMarca', listar().length));
285
+ if (!lim.ok) { dizer(lim.error); return 1; }
283
286
  const t = perguntar ? null : perguntarNoTerminal();
284
287
  try {
285
288
  const r = await criar({ perguntar: perguntar || t.ask, dizer, deps });
package/lib/planos.js CHANGED
@@ -15,14 +15,14 @@
15
15
  const https = require('https');
16
16
  const http = require('http');
17
17
 
18
- const R = (videosMes, resolucao, marcaDagua, narracaoPremium, clubeCompleto, scriptsAE, prioridade, subagentes) =>
19
- ({ videosMes, resolucao, marcaDagua, narracaoPremium, clubeCompleto, scriptsAE, prioridade, subagentes });
20
- const GRATIS = { id: 'free', nome: 'Grátis', preco: 0, recursos: R(3, 720, true, false, false, false, 0, 0), inclui: [] };
18
+ const R = (videosMes, resolucao, marcaDagua, narracaoPremium, clubeCompleto, scriptsAE, prioridade, subagentes, kitsMarca, versoesEditor) =>
19
+ ({ videosMes, resolucao, marcaDagua, narracaoPremium, clubeCompleto, scriptsAE, prioridade, subagentes, kitsMarca, versoesEditor });
20
+ const GRATIS = { id: 'free', nome: 'Grátis', preco: 0, recursos: R(3, 720, true, false, false, false, 0, 0, 1, 3), inclui: [] };
21
21
  const RESERVA = [
22
- { id: 'primo', nome: 'Primo', preco: 69.9, recursos: R(15, 720, false, false, true, false, 0, 0), inclui: [] },
23
- { id: 'pro', nome: 'Primo Pro', preco: 129.9, recursos: R(60, 1080, false, true, true, true, 1, 1), inclui: [] },
24
- { id: 'max', nome: 'Primo Max', preco: 299.9, recursos: R(200, 2160, false, true, true, true, 2, 3), inclui: [] },
25
- { id: 'studio', nome: 'Primo Studio', preco: 599.9, recursos: R(800, 2160, false, true, true, true, 3, 8), inclui: [] },
22
+ { id: 'primo', nome: 'Primo', preco: 69.9, recursos: R(15, 720, false, false, true, false, 0, 0, 5, 10), inclui: [] },
23
+ { id: 'pro', nome: 'Primo Pro', preco: 129.9, recursos: R(60, 1080, false, true, true, true, 1, 1, 20, 30), inclui: [] },
24
+ { id: 'max', nome: 'Primo Max', preco: 299.9, recursos: R(200, 2160, false, true, true, true, 2, 3, 100, 0), inclui: [] },
25
+ { id: 'studio', nome: 'Primo Studio', preco: 599.9, recursos: R(800, 2160, false, true, true, true, 3, 8, 0, 0), inclui: [] },
26
26
  ];
27
27
 
28
28
  function buscarPlanos(servidor, { timeout = 8000 } = {}) {
@@ -60,7 +60,9 @@ const LINHAS = [
60
60
  ['Projetos do Clube', (r) => (r.clubeCompleto ? 'completos' : 'resumo')],
61
61
  ['Scripts AE modelo', (r) => (r.scriptsAE ? '✓' : '—')],
62
62
  ['Prioridade na cadeia', (r) => (r.prioridade ? '●'.repeat(r.prioridade) : '—')],
63
- ['Subagentes', (r) => (r.subagentes ? String(r.subagentes) : '—')],
63
+ ['Subagentes juntos', (r) => (r.subagentes ? String(r.subagentes) : 'em fila')],
64
+ ['Kits de marca', (r) => (r.kitsMarca == null || r.kitsMarca === 0 ? 'sem teto' : String(r.kitsMarca))],
65
+ ['Edições por vídeo', (r) => (r.versoesEditor == null || r.versoesEditor === 0 ? 'sem teto' : String(r.versoesEditor))],
64
66
  ];
65
67
 
66
68
  /**
package/lib/preparar.js CHANGED
@@ -70,6 +70,8 @@ function estudioLinhas(dep = {}) {
70
70
  // marca ativa
71
71
  const mk = tenta(() => require('./marca-kit.js').ativa([process.cwd()]), null);
72
72
  poe(null, 'Marca ativa', mk ? (mk.nome || mk.slug || 'sim') : 'nenhuma — primocode marca criar <nome>');
73
+ // Primo (modo voz)
74
+ tenta(() => linhas.push(...require('./voz.js').linhasDoutor()), null);
73
75
  // MCP
74
76
  const mcp = tenta(() => Object.keys(require('./mcp.js').servidores()), []);
75
77
  poe(null, 'MCP', mcp.length ? mcp.join(', ') : 'nenhum servidor');
@@ -0,0 +1,15 @@
1
+ /**
2
+ * voz-ponte.js — o aviso do agente para o modo voz, sem um depender do outro.
3
+ *
4
+ * O agente (lib/act.js) diz "comecei a ferramenta X" aqui; o /voice, se estiver
5
+ * ligado, se inscreve e repassa ao Primo (painel mostra "executando" e ele
6
+ * confirma em voz alta: "rodando os testes"). Sem /voice, ninguém escuta.
7
+ */
8
+ 'use strict';
9
+
10
+ let ouvinte = null;
11
+
12
+ module.exports = {
13
+ escutar: (fn) => { ouvinte = typeof fn === 'function' ? fn : null; },
14
+ ferramenta: (nome, args) => { if (ouvinte) { try { ouvinte(String(nome || ''), args || {}); } catch { /* o aviso nunca derruba o agente */ } } },
15
+ };
package/lib/voz.js CHANGED
@@ -63,8 +63,19 @@ function paraFalar(texto, { frases = 3, teto = 420 } = {}) {
63
63
  return (fala || t.slice(0, teto)).trim();
64
64
  }
65
65
 
66
- /** Onde está o Python 3. Mesmo problema do Estúdio, mesma cura. */
66
+ /* O VENV DO PRIMO. As dependências opcionais (VAD, painel em tela cheia) moram
67
+ * em ~/.primocode/voz-venv, instaladas sob demanda: ninguém precisa quebrar o
68
+ * Python do sistema (PEP 668) nem perde nada se não instalar. */
69
+ const VENV = path.join(os.homedir(), '.primocode', 'voz-venv');
70
+ const venvPython = () => path.join(VENV, process.platform === 'win32' ? 'Scripts' : 'bin', process.platform === 'win32' ? 'python.exe' : 'python');
71
+
72
+ /** Onde está o Python 3. O do venv do Primo ganha, se já existir. */
67
73
  function python() {
74
+ if (fs.existsSync(venvPython())) return venvPython();
75
+ return pythonDoSistema();
76
+ }
77
+
78
+ function pythonDoSistema() {
68
79
  const nomes = process.platform === 'win32'
69
80
  ? ['python', 'py', 'python3']
70
81
  : ['python3', 'python', '/opt/homebrew/bin/python3', '/usr/local/bin/python3', '/usr/bin/python3'];
@@ -223,7 +234,7 @@ function instalar(py, pacotes, aoFalar = () => {}) {
223
234
  * @param {()=>void} [op.aoPronto] o microfone abriu
224
235
  * @returns {{processo, pronto: Promise, falar:(t:string)=>void, calar:()=>void}}
225
236
  */
226
- function abrir({ python: py, servidor, token, voz, aoOuvir, mostrar = () => {}, aoPronto = () => {} }) {
237
+ function abrir({ python: py, servidor, token, voz, aoParar = () => {}, aoOuvir, mostrar = () => {}, aoPronto = () => {} }) {
227
238
  let p;
228
239
  const manda = (obj) => {
229
240
  try { p.stdin.write(JSON.stringify(obj) + '\n'); } catch { /* já fechou */ }
@@ -237,6 +248,7 @@ function abrir({ python: py, servidor, token, voz, aoOuvir, mostrar = () => {},
237
248
  PRIMOCODE_SERVER: servidor,
238
249
  PRIMOCODE_TOKEN: token || '',
239
250
  PRIMOCODE_VOZ: voz || 'antonio',
251
+ PRIMOCODE_CWD: process.cwd(),
240
252
  PYTHONUNBUFFERED: '1',
241
253
  // O Whisper deixa um semáforo para o sistema recolher na saída,
242
254
  // e o Python avisa disso no stderr. É inofensivo; o aviso não é.
@@ -254,6 +266,7 @@ function abrir({ python: py, servidor, token, voz, aoOuvir, mostrar = () => {},
254
266
  try { msg = JSON.parse(linha); } catch { continue; }
255
267
  if (msg.tipo === 'fim') { try { p.kill(); } catch {} return; }
256
268
  if (msg.tipo === 'pronto') { try { aoPronto(); } catch {} continue; }
269
+ if (msg.tipo === 'parar') { try { aoParar(); } catch {} continue; }
257
270
  if (msg.tipo === 'erro') { mostrar(String(msg.texto || ''), true); continue; }
258
271
  if (msg.tipo === 'ouvi' && String(msg.texto || '').trim()) {
259
272
  try { aoOuvir(String(msg.texto).trim()); } catch (e) { mostrar(e.message, true); }
@@ -276,7 +289,12 @@ function abrir({ python: py, servidor, token, voz, aoOuvir, mostrar = () => {},
276
289
  return {
277
290
  processo: p,
278
291
  pronto,
279
- falar: (texto) => { const t = paraFalar(texto); if (t) manda({ tipo: 'falar', texto: t }); },
292
+ /* O texto vai BRUTO: quem resume é o Primo (voz/primo_voz/conversa.py) —
293
+ a voz diz o resumo, o painel mostra o resto (código, listas). */
294
+ falar: (texto) => { const t = String(texto || '').trim(); if (t) manda({ tipo: 'falar', texto: t, bruto: true }); },
295
+ // O agente começou uma ferramenta: o painel mostra "executando" e o Primo confirma ("rodando os testes").
296
+ ferramenta: (nome, args) => manda({ tipo: 'ferramenta', nome, args: args || {} }),
297
+ painel: (acao) => manda({ tipo: 'painel', acao }),
280
298
  calar: () => manda({ tipo: 'calar' }),
281
299
  // A bolinha na tela: 'esperando' quando o terminal termina sem falar.
282
300
  estado: (e) => manda({ tipo: 'estado', estado: e }),
@@ -303,7 +321,10 @@ function abrir({ python: py, servidor, token, voz, aoOuvir, mostrar = () => {},
303
321
  */
304
322
  const OPCIONAIS = () => [
305
323
  ['faster_whisper', 'faster-whisper', 'a transcrição local (Whisper)'],
324
+ ['pysilero_vad', 'pysilero-vad', 'o fim de fala preciso (Silero VAD)'],
306
325
  ...(process.platform === 'darwin' ? [
326
+ ['WebKit', 'pyobjc-framework-WebKit', 'o painel em tela cheia do Primo'],
327
+ ['EventKit', 'pyobjc-framework-EventKit', 'os compromissos de hoje do app Calendário'],
307
328
  ['Quartz', 'pyobjc-framework-Quartz', 'o cursor do Primo na tela'],
308
329
  ['Vision', 'pyobjc-framework-Vision', 'ler o texto da tela (clicar pelo nome em qualquer app)'],
309
330
  ] : []),
@@ -394,13 +415,23 @@ async function baixarWhisper(py) {
394
415
  * @returns {Promise<{ok:boolean, python?:string, esperando?:boolean,
395
416
  * avisos:string[], error?:string, comoResolver?:string}>}
396
417
  */
418
+ async function criarVenv(aoFalar) {
419
+ if (fs.existsSync(venvPython())) return null;
420
+ const base = pythonDoSistema();
421
+ if (!base) return null;
422
+ aoFalar('criando o ambiente do Primo (~/.primocode/voz-venv)');
423
+ const r = await rodar(base, ['-m', 'venv', VENV], 5 * 60 * 1000);
424
+ return r.ok ? null : `não consegui criar o ambiente do Primo (${porqueOPipFalhou(r.saida)}) — uso o Python do sistema.`;
425
+ }
426
+
397
427
  async function preparar(aoFalar = () => {}) {
428
+ const avisoVenv = await criarVenv(aoFalar);
398
429
  const py = python();
399
430
  if (!py) {
400
431
  return { ok: false, avisos: [], error: 'o modo voz precisa do Python 3, e não achei nenhum nesta máquina.',
401
432
  comoResolver: process.platform === 'darwin' ? 'brew install python' : 'instale em python.org e abra o terminal de novo' };
402
433
  }
403
- const avisos = [];
434
+ const avisos = avisoVenv ? [avisoVenv] : [];
404
435
 
405
436
  // 1. sistema, depois 2. os obrigatórios pelo pip.
406
437
  let falta = faltando(py);
@@ -435,4 +466,43 @@ async function preparar(aoFalar = () => {}) {
435
466
  return { ok: true, python: py, avisos };
436
467
  }
437
468
 
438
- module.exports = { DICA_COMPUTADOR, paraFalar, conferir, instalar, preparar, porqueOPipFalhou, abrir, python, PASTA };
469
+ /** `/voice painel`: só o HUD (sem microfone). Mac com PyObjC = tela cheia; senão, o quadro no terminal. */
470
+ function abrirPainel({ python: py }) {
471
+ const mac = process.platform === 'darwin' && temModulo(py, 'WebKit');
472
+ const p = spawn(py, ['-m', mac ? 'primo_voz.hud' : 'primo_voz.painel_tui', ...(mac ? ['--livre'] : [])], {
473
+ cwd: PASTA, env: { ...process.env, PYTHONPATH: PASTA }, stdio: mac ? 'ignore' : ['pipe', 'inherit', 'inherit'],
474
+ });
475
+ return { processo: p, tela: mac };
476
+ }
477
+
478
+ /** Grava o nome do assistente em ~/.primocode/voz.json (o mesmo arquivo que voz/primo_voz/identidade.py lê). */
479
+ function definirNome(nome) {
480
+ const limpo = String(nome || '').replace(/[^\p{L}\p{N} -]/gu, '').trim();
481
+ if (!limpo || limpo.split(/\s+/).length > 2 || limpo.length < 2 || limpo.length > 20) return null;
482
+ const arq = process.env.PRIMOCODE_VOZ_CONFIG || path.join(os.homedir(), '.primocode', 'voz.json');
483
+ let cfg = {};
484
+ try { cfg = JSON.parse(fs.readFileSync(arq, 'utf8')); } catch { /* primeira vez */ }
485
+ cfg.nome = limpo.split(/\s+/).map((w) => w[0].toUpperCase() + w.slice(1)).join(' ');
486
+ fs.mkdirSync(path.dirname(arq), { recursive: true });
487
+ fs.writeFileSync(arq, JSON.stringify(cfg, null, 2));
488
+ return cfg.nome;
489
+ }
490
+
491
+ /** As linhas do `primocode doutor` sobre o modo voz. */
492
+ function linhasDoutor() {
493
+ const py = python();
494
+ if (!py) return [{ ok: false, item: 'Primo (voz)', detalhe: 'sem Python 3', conserto: 'brew install python' }];
495
+ const tem = (m) => temModulo(py, m);
496
+ const venv = fs.existsSync(venvPython());
497
+ const linha = (ok, item, detalhe, conserto) => ({ ok, item, detalhe, conserto: ok === false ? conserto : undefined });
498
+ return [
499
+ linha(venv ? true : null, 'Primo: ambiente', venv ? VENV : 'ainda não criado — /voice cria sozinho', undefined),
500
+ linha(tem('sounddevice') && tem('numpy'), 'Primo: microfone', 'sounddevice + numpy', '/voice instala; ou: ' + py + ' -m pip install sounddevice numpy'),
501
+ linha(tem('faster_whisper') ? true : null, 'Primo: Whisper local', tem('faster_whisper') ? 'instalado' : 'não instalado — usa o servidor'),
502
+ linha(tem('pysilero_vad') ? true : null, 'Primo: Silero VAD', tem('pysilero_vad') ? 'fim de fala preciso' : 'não instalado — usa o corte por volume'),
503
+ linha(process.platform === 'darwin' ? (tem('WebKit') ? true : null) : null, 'Primo: tela cheia',
504
+ process.platform !== 'darwin' ? 'só no Mac — aqui, painel no terminal' : tem('WebKit') ? 'pronto' : 'sem PyObjC WebKit — usa a orbe'),
505
+ ];
506
+ }
507
+
508
+ module.exports = { definirNome, VENV, abrirPainel, linhasDoutor, DICA_COMPUTADOR, paraFalar, conferir, instalar, preparar, porqueOPipFalhou, abrir, python, PASTA };