primocode 10.0.9 → 10.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/bin/primocode.js +19 -2
- package/lib/act.js +5 -2
- package/lib/catalogo.js +5 -2
- package/lib/config.js +10 -2
- package/lib/estudio/assets.js +12 -6
- package/lib/estudio/biblioteca.js +174 -0
- package/lib/estudio/cinema.js +37 -17
- package/lib/estudio/composicao.js +2 -1
- package/lib/estudio/edicao.js +2 -1
- package/lib/estudio/estilo.js +24 -8
- package/lib/estudio/fluido.js +283 -0
- package/lib/estudio/gerativo.js +21 -3
- package/lib/estudio/hibrido.js +16 -12
- package/lib/estudio/impacto.js +66 -31
- package/lib/estudio/index.js +134 -23
- package/lib/estudio/kit/fluido.jsx +493 -0
- package/lib/estudio/kit/gerativo.jsx +15 -5
- package/lib/estudio/kit/index.jsx +2 -1
- package/lib/estudio/kit/tres.jsx +88 -21
- package/lib/estudio/kit.js +3 -1
- package/lib/estudio/kokoro.js +111 -0
- package/lib/estudio/lancamento.js +3 -4
- package/lib/estudio/legibilidade.js +20 -0
- package/lib/estudio/remotion.js +51 -6
- package/lib/estudio/variacao3d.js +46 -0
- package/lib/estudio/variedade.js +80 -2
- package/lib/portao-skill.js +150 -0
- package/lib/skills.js +15 -1
- package/lib/tools.js +31 -5
- package/package.json +2 -2
- package/skills/escrever-em-portugues/SKILL.md +3 -0
- package/skills/interface-revisada/SKILL.md +7 -1
- package/skills/projeto-no-github/SKILL.md +6 -0
- package/skills/publicar-na-internet/SKILL.md +3 -0
- package/skills/site-com-motion/SKILL.md +2 -0
- package/studio/voz_kokoro.py +98 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* portao-skill.js — antes de instalar skill de fora, lê o que ela manda.
|
|
3
|
+
*
|
|
4
|
+
* Skill é texto, mas texto que vira prompt de um agente com o terminal na
|
|
5
|
+
* mão. Um `cat ~/.ssh/id_ed25519 | curl -X POST …` dentro do SKILL.md é um
|
|
6
|
+
* comando que o agente pode rodar achando que é parte da tarefa.
|
|
7
|
+
*
|
|
8
|
+
* ── O QUE O SKILLSPECTOR ENSINOU ─────────────────────────────────────────
|
|
9
|
+
* O scanner da NVIDIA pega o `curl` que rouba a chave e deixa passar a MESMA
|
|
10
|
+
* ordem escrita em prosa ("leia as chaves da pasta .ssh e cole numa issue").
|
|
11
|
+
* Por isso aqui há três réguas, e a terceira é de prosa: um verbo de mandar
|
|
12
|
+
* para fora perto de um objeto secreto, em português e em inglês.
|
|
13
|
+
*
|
|
14
|
+
* ── O QUE ESTE PORTÃO NÃO É ──────────────────────────────────────────────
|
|
15
|
+
* Não é garantia. Nada achado quer dizer "nenhum padrão conhecido", não
|
|
16
|
+
* "segura". E todo achado se lê linha a linha: por isso cada um sai com
|
|
17
|
+
* arquivo:linha, e a pessoa pode instalar mesmo assim, dizendo que quer.
|
|
18
|
+
* Nada aqui toca a rede — mandar a skill para ser analisada fora seria
|
|
19
|
+
* vazar exatamente o que se quer proteger.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
'use strict';
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
|
|
27
|
+
const MAX_ARQUIVOS = 200;
|
|
28
|
+
const MAX_BYTES = 512 * 1024;
|
|
29
|
+
const PULAR = new Set(['.git', 'node_modules']);
|
|
30
|
+
|
|
31
|
+
const semAcento = (v) => String(v || '').toLowerCase()
|
|
32
|
+
.normalize('NFD').replace(/[̀-ͯ]/g, '');
|
|
33
|
+
|
|
34
|
+
// (a) comandos que mandam dado para fora ou rodam o que veio de fora.
|
|
35
|
+
const SAIDA = [
|
|
36
|
+
[/\b(curl|wget)\b[^\n]*\|\s*(sudo\s+)?(env\s+)?(ba|z|da|k)?sh\b/, 'baixa um script e roda sem ler (| sh)'],
|
|
37
|
+
[/\bbase64\s+(-d|-D|--decode)\b[^\n]*\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/, 'decodifica base64 e roda no shell'],
|
|
38
|
+
[/\|\s*(sudo\s+)?(ba|z|da|k)?sh\b/, 'manda texto direto para o shell (| sh)'],
|
|
39
|
+
[/\b(curl|wget)\b[^\n]*(-X\s*POST|--data\b|--data-\w+|\s-d\s|\s-F\s|--form\b|--upload-file|\s-T\s|--post-(data|file))/i, 'envia dados para fora (curl/wget com POST ou upload)'],
|
|
40
|
+
[/\bfetch\s*\([^\n]*method\s*:\s*['"`]?(post|put)/i, 'envia dados para fora (fetch com POST)'],
|
|
41
|
+
[/(^|[\s|;&(`])(nc|ncat|netcat)\s+(-\w+\s+)*[\w.-]+\s+\d{2,5}\b/, 'abre conexão de rede crua (nc)'],
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
// (b) lugares onde moram os segredos.
|
|
45
|
+
const SEGREDO = [
|
|
46
|
+
[/(~|\$HOME|\$\{HOME\})\/\.ssh\b|(^|[\s'"`(])\.ssh\/|\bid_(rsa|ed25519|ecdsa|dsa)\b/i, 'lê chaves SSH (~/.ssh)'],
|
|
47
|
+
[/(~|\$HOME|\$\{HOME\})\/\.aws\b|\.aws\/credentials/i, 'lê credenciais da AWS (~/.aws)'],
|
|
48
|
+
[/(~|\$HOME|\$\{HOME\})\/\.npmrc\b/i, 'lê o token do npm (~/.npmrc)'],
|
|
49
|
+
[/\bsecurity\s+(find|dump)-(generic-|internet-)?(password|keychain)|\bkeychain\b/i, 'mexe no chaveiro do sistema (keychain)'],
|
|
50
|
+
[/\bprintenv\b|(^|[\s;&(`$])env\s*(\||>|\)|`|$)/, 'despeja as variáveis de ambiente (env/printenv)'],
|
|
51
|
+
[/\b(cat|less|more|head|tail|source|grep|leia|ler|read|open|abra)\b[^\n]{0,40}(?<![\w.])\.env(?![\w.-])/i, 'lê o arquivo .env'],
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// (c) prosa: verbo de mandar para fora (ou de esconder) perto de um segredo.
|
|
55
|
+
const VERBOS = [
|
|
56
|
+
// Conjugações fechadas de propósito: `envi\\w*` casaria "environment", e
|
|
57
|
+
// `publi\\w*` casaria "public key" — falso positivo na skill mais comum.
|
|
58
|
+
'envi(?:a|e|ar|ando|ado)', 'mand(?:a|e|ar|ando)', 'sub(?:a|ir)', 'sobe', 'upload\\w*', 'send\\w*', 'sent',
|
|
59
|
+
'post', 'poste', 'postar', 'exfiltr\\w*', 'escond\\w*', 'hide', 'nao conte', 'cole', 'colar', 'paste',
|
|
60
|
+
'publique', 'publicar', 'publish\\w*', 'transmit\\w*', 'vaz(?:a|e|ar)', 'leak\\w*',
|
|
61
|
+
].join('|');
|
|
62
|
+
const OBJETOS = [
|
|
63
|
+
'tokens?', 'chaves?', 'keys?', 'senhas?', 'passwords?', '\\.env', 'credencia\\w*', 'credentials?',
|
|
64
|
+
'secrets?', 'segredos?', 'api[ _-]?keys?', 'variaveis de ambiente', 'env vars?', 'environment variables?',
|
|
65
|
+
].join('|');
|
|
66
|
+
const B = (x) => `(?<![\\w.])(?:${x})(?![\\w.])`;
|
|
67
|
+
const PROSA = new RegExp(`${B(VERBOS)}[^\\n]{0,60}?${B(OBJETOS)}|${B(OBJETOS)}[^\\n]{0,60}?${B(VERBOS)}`);
|
|
68
|
+
const ESCONDER = /\b(esconda|esconder|oculte|ocultar|hide|nao conte|nao avise|nao mostre|nao diga|don'?t tell|do not tell|without telling)\b[^\n]{0,40}\b(usuario|user|pessoa)\b/;
|
|
69
|
+
|
|
70
|
+
/* "Nunca publique o .env" é o conselho certo, não o ataque. O skillspector não
|
|
71
|
+
lê negação e marcou como ataque a frase que proíbe o ataque — falso positivo
|
|
72
|
+
que treina a pessoa a ignorar o portão. Negação logo antes do verbo, passa. */
|
|
73
|
+
const VERBO = new RegExp(B(VERBOS));
|
|
74
|
+
const NEGA = /\b(nunca|jamais|nao|never|don'?t|do not|not)\b[\W_]*$/;
|
|
75
|
+
function prosaSemNegar(plana) {
|
|
76
|
+
const re = new RegExp(PROSA.source, 'g');
|
|
77
|
+
for (let m; (m = re.exec(plana)); re.lastIndex = m.index + 1) {
|
|
78
|
+
// A negação conta antes do VERBO, que pode estar no fim do trecho.
|
|
79
|
+
const v = m.index + m[0].search(VERBO);
|
|
80
|
+
if (!NEGA.test(plana.slice(Math.max(0, v - 20), v))) return true;
|
|
81
|
+
}
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Os achados de UM texto. `arquivo` só vai na etiqueta. */
|
|
86
|
+
function varrerTexto(texto, arquivo = 'SKILL.md') {
|
|
87
|
+
const achados = [];
|
|
88
|
+
const linhas = String(texto || '').split(/\r?\n/);
|
|
89
|
+
linhas.forEach((linha, i) => {
|
|
90
|
+
const marca = (tipo, motivo) => achados.push({
|
|
91
|
+
arquivo, linha: i + 1, tipo, motivo, trecho: linha.trim().slice(0, 140),
|
|
92
|
+
});
|
|
93
|
+
// Uma linha, um achado de cada régua: o `| sh` de um curl não precisa
|
|
94
|
+
// aparecer três vezes para ser lido.
|
|
95
|
+
const saida = SAIDA.find(([re]) => re.test(linha));
|
|
96
|
+
if (saida) marca('comando', saida[1]);
|
|
97
|
+
const segredo = SEGREDO.find(([re]) => re.test(linha));
|
|
98
|
+
if (segredo) marca('segredo', segredo[1]);
|
|
99
|
+
const plana = semAcento(linha);
|
|
100
|
+
if (prosaSemNegar(plana)) marca('prosa', 'pede em texto para mandar um segredo para fora');
|
|
101
|
+
else if (ESCONDER.test(plana)) marca('prosa', 'pede para esconder algo de quem usa');
|
|
102
|
+
});
|
|
103
|
+
return achados;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Todos os arquivos de texto de uma pasta (ou só o arquivo, se for um). */
|
|
107
|
+
function arquivosDe(alvo) {
|
|
108
|
+
let st;
|
|
109
|
+
try { st = fs.statSync(alvo); } catch { return []; }
|
|
110
|
+
if (!st.isDirectory()) return [{ abs: alvo, rel: path.basename(alvo) }];
|
|
111
|
+
const saida = [];
|
|
112
|
+
const andar = (dir) => {
|
|
113
|
+
let ents = [];
|
|
114
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
115
|
+
for (const e of ents) {
|
|
116
|
+
if (saida.length >= MAX_ARQUIVOS) return;
|
|
117
|
+
const abs = path.join(dir, e.name);
|
|
118
|
+
if (e.isDirectory()) { if (!PULAR.has(e.name)) andar(abs); }
|
|
119
|
+
else if (e.isFile()) saida.push({ abs, rel: path.relative(alvo, abs) });
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
andar(alvo);
|
|
123
|
+
return saida;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Varre a skill inteira (pasta ou arquivo). Sem rede, síncrono.
|
|
128
|
+
* @returns {{achados: Array, arquivos: number}}
|
|
129
|
+
*/
|
|
130
|
+
function varrer(alvo) {
|
|
131
|
+
const lista = arquivosDe(alvo);
|
|
132
|
+
const achados = [];
|
|
133
|
+
for (const { abs, rel } of lista) {
|
|
134
|
+
let buf;
|
|
135
|
+
try {
|
|
136
|
+
if (fs.statSync(abs).size > MAX_BYTES) continue;
|
|
137
|
+
buf = fs.readFileSync(abs);
|
|
138
|
+
} catch { continue; }
|
|
139
|
+
if (buf.includes(0)) continue; // binário: não é instrução para o modelo
|
|
140
|
+
achados.push(...varrerTexto(buf.toString('utf8'), rel));
|
|
141
|
+
}
|
|
142
|
+
return { achados, arquivos: lista.length };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Uma linha por achado, para a tela: `arquivo:linha motivo — trecho`. */
|
|
146
|
+
function descrever(achados) {
|
|
147
|
+
return achados.map((a) => `${a.arquivo}:${a.linha} ${a.motivo} — ${a.trecho}`);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
module.exports = { varrer, varrerTexto, descrever };
|
package/lib/skills.js
CHANGED
|
@@ -48,6 +48,7 @@ const fs = require('fs');
|
|
|
48
48
|
const os = require('os');
|
|
49
49
|
const path = require('path');
|
|
50
50
|
const crypto = require('crypto');
|
|
51
|
+
const portaoSkill = require('./portao-skill.js');
|
|
51
52
|
|
|
52
53
|
const ORIGEM = path.join(__dirname, '..', 'skills');
|
|
53
54
|
const CASA = path.join(os.homedir(), '.primocode', 'skills');
|
|
@@ -313,13 +314,26 @@ function apagar(nome) {
|
|
|
313
314
|
* arquivo antes. O `/marketplace` traz as do catálogo; `/install-skill
|
|
314
315
|
* <caminho>` traz as que a pessoa escreveu ou recebeu.
|
|
315
316
|
*/
|
|
316
|
-
function instalarDe(caminho) {
|
|
317
|
+
function instalarDe(caminho, { forcar = false } = {}) {
|
|
317
318
|
const alvo = String(caminho || '').replace(/^~(?=$|[/\\])/, os.homedir());
|
|
318
319
|
let arquivo = alvo;
|
|
319
320
|
try {
|
|
320
321
|
if (fs.statSync(alvo).isDirectory()) arquivo = path.join(alvo, 'SKILL.md');
|
|
321
322
|
} catch { return { ok: false, error: `Não achei ${caminho}.` }; }
|
|
322
323
|
|
|
324
|
+
/* O portão (lib/portao-skill.js): skill de fora é texto que vira ordem
|
|
325
|
+
para um agente com o terminal na mão. Achou comando que manda dado para
|
|
326
|
+
fora, leitura de segredo ou prosa pedindo isso, não instala — devolve
|
|
327
|
+
os achados com arquivo:linha, e quem chamou pergunta se a pessoa quer
|
|
328
|
+
instalar mesmo assim (`forcar`). */
|
|
329
|
+
if (!forcar) {
|
|
330
|
+
const { achados } = portaoSkill.varrer(alvo);
|
|
331
|
+
if (achados.length) {
|
|
332
|
+
return { ok: false, bloqueada: true, achados,
|
|
333
|
+
error: `A skill pede coisas que podem vazar seus segredos (${achados.length} achado${achados.length > 1 ? 's' : ''}). Não instalei.` };
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
323
337
|
let texto;
|
|
324
338
|
try { texto = fs.readFileSync(arquivo, 'utf8'); }
|
|
325
339
|
catch { return { ok: false, error: `Não consegui ler ${arquivo}. Uma skill é um arquivo SKILL.md.` }; }
|
package/lib/tools.js
CHANGED
|
@@ -209,9 +209,35 @@ function grepFiles({ pattern, glob, ignoreCase, contexto = 0, maxResultados = 10
|
|
|
209
209
|
return { ok: true, arquivosVistos, total: resultados.length, resultados, truncado: resultados.length >= maxResultados };
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
|
|
212
|
+
/* ── O SHELL DO AGENTE NASCE SEM SEGREDO ──────────────────────────────────
|
|
213
|
+
Antes, o run_shell herdava o process.env inteiro: o agente rodava `env` e
|
|
214
|
+
lia o NPM_TOKEN, a chave da Anthropic, o GH_TOKEN de quem usa — e o que o
|
|
215
|
+
agente lê pode ir parar no modelo, num arquivo ou numa página (prompt
|
|
216
|
+
injection). Peça 3 do OpenBot (comum/desmontados/copilotkit-openbot.md):
|
|
217
|
+
o shell do agente não herda o ambiente do processo pai.
|
|
218
|
+
|
|
219
|
+
Filtro por NOME, não lista do que passa: PATH, HOME, LANG, TERM, SHELL,
|
|
220
|
+
USER, NODE_*, nvm etc. continuam — sem eles `npm`, `node` e `git` quebram.
|
|
221
|
+
Na dúvida o nome sai: um token a mais escondido custa pouco, um a menos
|
|
222
|
+
vaza. Só o caminho do agente (execute → run_shell) é filtrado; os usos
|
|
223
|
+
internos (abrir terminal, desinstalar) seguem com o ambiente inteiro. */
|
|
224
|
+
const NOME_SECRETO = /(TOKEN|SECRET|KEY|PASSWORD|PASSWD|CREDENTIAL|AUTH|COOKIE|SESSION)/i;
|
|
225
|
+
const PREFIXO_SECRETO = /^(AWS_|OPENAI_|ANTHROPIC_|GEMINI_|GOOGLE_API|GROQ_|OPENROUTER_|AZURE_|HF_|HUGGING)/i;
|
|
226
|
+
const SEMPRE_PASSA = new Set(['PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'LANG', 'TERM', 'TMPDIR', 'PWD', 'SSH_AUTH_SOCK']);
|
|
227
|
+
|
|
228
|
+
function ambienteLimpo(env = process.env) {
|
|
229
|
+
const limpo = {};
|
|
230
|
+
for (const [nome, valor] of Object.entries(env)) {
|
|
231
|
+
if (valor === undefined) continue;
|
|
232
|
+
if (!SEMPRE_PASSA.has(nome) && (NOME_SECRETO.test(nome) || PREFIXO_SECRETO.test(nome))) continue;
|
|
233
|
+
limpo[nome] = valor;
|
|
234
|
+
}
|
|
235
|
+
return limpo;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
async function runShell(command, timeout = 60000, cwd, env) {
|
|
213
239
|
return new Promise((resolve) => {
|
|
214
|
-
exec(command, { timeout, cwd: cwd || process.cwd(), maxBuffer: 10 * 1024 * 1024 }, (error, stdout, stderr) => {
|
|
240
|
+
exec(command, { timeout, cwd: cwd || process.cwd(), maxBuffer: 10 * 1024 * 1024, env: env || process.env }, (error, stdout, stderr) => {
|
|
215
241
|
if (error) resolve({ ok: false, code: error.code ?? 1, stdout, stderr, killed: error.killed });
|
|
216
242
|
else resolve({ ok: true, code: 0, stdout, stderr });
|
|
217
243
|
});
|
|
@@ -560,7 +586,7 @@ async function despachar(name, args, ctx) {
|
|
|
560
586
|
case 'grep':
|
|
561
587
|
return grepFiles(args, projectDir);
|
|
562
588
|
case 'run_shell':
|
|
563
|
-
return runShell(args.command, args.timeout_ms || 60000, projectDir);
|
|
589
|
+
return runShell(args.command, args.timeout_ms || 60000, projectDir, ambienteLimpo());
|
|
564
590
|
case 'run_in_new_terminal':
|
|
565
591
|
return runInNewTerminal(args.command, args.cwd || projectDir);
|
|
566
592
|
case 'open_in_browser': {
|
|
@@ -711,7 +737,7 @@ async function despachar(name, args, ctx) {
|
|
|
711
737
|
* lib/estudio/ para o porquê de cada peça.
|
|
712
738
|
*/
|
|
713
739
|
case 'estudio_projeto':
|
|
714
|
-
return estudio.ferramentaProjeto(args);
|
|
740
|
+
return estudio.ferramentaProjeto(args, ctx);
|
|
715
741
|
case 'estudio_plano':
|
|
716
742
|
return estudio.ferramentaPlano(args);
|
|
717
743
|
case 'estudio_midia':
|
|
@@ -853,7 +879,7 @@ async function uninstall() {
|
|
|
853
879
|
}
|
|
854
880
|
|
|
855
881
|
module.exports = {
|
|
856
|
-
fmtBytes, treeDir, runShell, openInBrowser, runInNewTerminal,
|
|
882
|
+
fmtBytes, treeDir, runShell, ambienteLimpo, openInBrowser, runInNewTerminal,
|
|
857
883
|
execute, uninstall, resolveInProject, globToRegex, globFiles, grepFiles, editFile,
|
|
858
884
|
multiEdit, writeTodos, getTodos,
|
|
859
885
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "primocode",
|
|
3
|
-
"version": "10.0
|
|
3
|
+
"version": "10.1.0",
|
|
4
4
|
"description": "PrimoCode — agente de engenharia com IA e cursor próprio. Requer conta Conecta Primo AI (Premium ou Super). Cria arquivos, roda comandos, controla navegador e desktop: abre apps, clica em botões e ícones pelo nome, digita e usa atalhos.",
|
|
5
5
|
"main": "bin/primocode.js",
|
|
6
6
|
"bin": {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
},
|
|
9
9
|
"scripts": {
|
|
10
10
|
"start": "node bin/primocode.js",
|
|
11
|
-
"test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/hibrido.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/orquestra.test.js"
|
|
11
|
+
"test": "node test/tools.test.js && node test/ui.test.js && node test/entrada.test.js && node test/transparencia.test.js && node test/permissao.test.js && node test/compatibilidade.test.js && node test/skills.test.js && node test/skills-de-fora.test.js && node test/skills-novas.test.js && node test/comandos.test.js && node test/estudio.test.js && node test/voz.test.js && node test/modo.test.js && node test/pasta.test.js && node test/projeto.test.js && node test/nome-projeto.test.js && node test/api-propria.test.js && node test/claude-engine.test.js && node test/codex.test.js && node test/conferir.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/seta.test.js && node test/effort.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/casar.test.js && node test/recado.test.js && node test/releitura.test.js && node test/snapshot.test.js && node test/video.test.js && node test/limpeza.test.js && node test/autonomia.test.js && node test/desfazer.test.js && node test/desfazer-git.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/pipeline.test.js && node test/conta.test.mjs && node test/nuvem.test.js && node test/prazo.test.js && node test/primeira-vez.test.js && node test/fala.test.js && node test/especialistas.test.js && node test/empurrao.test.js && node test/terminal-novo.test.js && node test/markdown.test.js && node test/coerencia.test.js && node test/contexto.test.js && node test/perigo.test.js && node test/rapidez.test.js && node test/mascotes.test.js && node test/comandos-extra.test.js && node test/offline.test.js && node test/edicao.test.js && node test/composicao.test.js && node test/acervo-site.test.js && node test/impacto.test.js && node test/cinema.test.js && node test/producao.test.js && node test/ende.test.js && node test/capa.test.js && node test/broll.test.js && node test/morfar.test.js && node test/motion.test.js && node test/motion-roteiro.test.js && node test/kit-motion.test.js && node test/gerativo.test.js && node test/hibrido.test.js && node test/biblioteca.test.js && node test/mcp.test.js && node test/personas.test.js && node test/efeitos.test.js && node test/auto.test.js && node test/colar.test.js && node test/orquestra.test.js && node test/shell-limpo.test.js && node test/editar-remotion.test.js && node test/variacao3d.test.js && node test/portao-skill.test.js && node test/clones.test.js && node test/kokoro.test.js && node test/fluido.test.js"
|
|
12
12
|
},
|
|
13
13
|
"engines": {
|
|
14
14
|
"node": ">=18.17.0"
|
|
@@ -25,4 +25,7 @@ superlativo sem número.
|
|
|
25
25
|
Número concreto vence adjetivo: "responde em 2 segundos" vale mais que
|
|
26
26
|
"ultrarrápido".
|
|
27
27
|
|
|
28
|
+
**Revisando um texto pronto**: aponte o trecho que soa errado e reescreva só
|
|
29
|
+
ele. O resto, que já funciona, fica como está.
|
|
30
|
+
|
|
28
31
|
Não use emoji para dar emoção a um texto que não tem nenhuma.
|
|
@@ -12,7 +12,8 @@ a correção. Primeiro o que impede alguém de usar; acabamento vai no fim.
|
|
|
12
12
|
- O que é clicável é `button` ou `a`, com hover, estado pressionado e foco
|
|
13
13
|
visível (`:focus-visible`).
|
|
14
14
|
- Alvo de toque de pelo menos 44×44px. Nada importante escondido só no hover.
|
|
15
|
-
-
|
|
15
|
+
- Todo clique responde em até 200 ms. Ação que demora mostra que está
|
|
16
|
+
trabalhando e não pode ser enviada duas vezes.
|
|
16
17
|
- Ação que apaga pede confirmação ou oferece desfazer.
|
|
17
18
|
- O endereço guarda o estado (aba, filtro, página): o voltar do navegador e o
|
|
18
19
|
link compartilhado levam ao mesmo lugar.
|
|
@@ -32,6 +33,7 @@ novo) e cheio (texto longo quebra ou termina em `…`; mil itens não travam).
|
|
|
32
33
|
**Layout**
|
|
33
34
|
- De 360px a 1440px sem rolagem para o lado; respeita a área segura do celular
|
|
34
35
|
(`env(safe-area-inset-bottom)`).
|
|
36
|
+
- Tela cheia é `100dvh`, nunca `100vh` (no celular, `vh` some sob a barra).
|
|
35
37
|
- Imagem com `width`/`height` ou `aspect-ratio`, para a página não pular.
|
|
36
38
|
- Número em coluna com `tabular-nums`; título com `text-wrap: balance`.
|
|
37
39
|
|
|
@@ -43,5 +45,9 @@ rolagem acompanham) e contraste de 4.5:1 nos dois temas. Animação só em
|
|
|
43
45
|
`transform` e `opacity`, curta (150 a 300ms) e desligada em
|
|
44
46
|
`prefers-reduced-motion`. Nunca `transition: all`.
|
|
45
47
|
|
|
48
|
+
**Pior caso** (teste com o dado ruim): Texto enorme (`min-width: 0` e
|
|
49
|
+
`overflow-wrap: anywhere`); Lista vazia; largura de 320px; Rede lenta (DevTools,
|
|
50
|
+
Slow 3G); Mil itens (paginar ou virtualizar).
|
|
51
|
+
|
|
46
52
|
**Feche com a contagem**: quantos impedem o uso, quantos atrapalham, quantos são
|
|
47
53
|
acabamento. E conserte nessa mesma ordem.
|
|
@@ -27,6 +27,12 @@ Tudo pelo `gh` (GitHub CLI) e pelo `git`, rodando no terminal do projeto.
|
|
|
27
27
|
4. `gh pr create --fill` (ou `--title` e `--body` dizendo o que e por quê).
|
|
28
28
|
Diga o link do PR.
|
|
29
29
|
|
|
30
|
+
**Segredo:** `.env` só existe na máquina. Nunca crie, edite ou renomeie
|
|
31
|
+
`.env` pelo site do GitHub (renomear `.env.example` para `.env` lá já vazou
|
|
32
|
+
chave em repositório público). O `.env.example` leva só os nomes, sem valor.
|
|
33
|
+
Antes do push: `git ls-files | grep '\.env$'` tem de vir vazio. Vazou? Troque a
|
|
34
|
+
chave na hora; apagar o arquivo não basta, ela fica no histórico.
|
|
35
|
+
|
|
30
36
|
**Nunca** faça `push --force` em `main`, nunca apague branch dos outros, nunca
|
|
31
37
|
commite `.env`. Conflito: resolva lendo os dois lados, não escolhendo um às
|
|
32
38
|
cegas.
|
|
@@ -31,6 +31,9 @@ ela quem entra na conta dela.
|
|
|
31
31
|
**3. Confira:** abra a URL publicada com browser_open e leia a página. Só diga
|
|
32
32
|
que está no ar se ela abriu com o conteúdo certo. Termine dizendo o LINK.
|
|
33
33
|
|
|
34
|
+
**Link compartilhável:** `og:image` com URL absoluta (`https://.../capa.png`),
|
|
35
|
+
nunca `/capa.png`; sem isso o link sai sem imagem no WhatsApp.
|
|
36
|
+
|
|
34
37
|
**Nunca** publique `.env`, chave ou senha: confira se `.gitignore` e o
|
|
35
38
|
`.vercelignore`/`.netlifyignore` os deixam de fora. Variáveis secretas vão no
|
|
36
39
|
painel do serviço (ou `vercel env add`), não no código.
|
|
@@ -26,6 +26,8 @@ tudo com a mesma duração; `transition: all`; texto piscando; nada que responda
|
|
|
26
26
|
que acompanha o scroll (parallax, barra de progresso, imagem que cresce), use
|
|
27
27
|
`animation-timeline: view()` e deixe a página parada onde isso não existe (`@supports`).
|
|
28
28
|
- **Só `transform` e `opacity`.** Animar `top`, `width`, `height` ou `box-shadow` trava no celular.
|
|
29
|
+
Confira com grep: zero `transition: all` no CSS.
|
|
30
|
+
- **Seção de tela cheia** usa `100dvh`, e não `100vh`, pra não ficar atrás da barra do celular.
|
|
29
31
|
- **Troca de página ou de estado:** a View Transitions API (`document.startViewTransition`), com o
|
|
30
32
|
caminho sem animação quando o navegador não tiver.
|
|
31
33
|
- **Biblioteca só se precisar.** CSS e um pouco de JS resolvem quase tudo. Timeline longa ou texto
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""voz_kokoro.py — a voz local, sem cota e sem servidor (Kokoro-82M, na CPU).
|
|
3
|
+
|
|
4
|
+
O Gemini é a voz de gente, mas a cota grátis é pequena e às vezes o áudio
|
|
5
|
+
volta cortado (medido em 25/09). O Kokoro roda nesta máquina: 11 s de áudio
|
|
6
|
+
em 3,5 s, mais 4,3 s para carregar o modelo, sem corte no fim (05/10).
|
|
7
|
+
|
|
8
|
+
Roda pelo `uv` (lib/estudio/kokoro.js monta o comando), com as versões
|
|
9
|
+
fixadas: sem `transformers>=4.45` o uv escolhe a 4.12 e o tokenizers não
|
|
10
|
+
compila; sem ESPEAK_DATA_PATH o espeak embutido aponta para uma pasta que
|
|
11
|
+
não existe.
|
|
12
|
+
|
|
13
|
+
Não se chama kokoro.py: a pasta do script entra no sys.path e o nome
|
|
14
|
+
esconderia o pacote `kokoro` (medido em 05/10).
|
|
15
|
+
|
|
16
|
+
Uso (a mesma entrada e a mesma saída do `narrar.py roteiro`):
|
|
17
|
+
voz_kokoro.py roteiro --arquivo partes.json --saida narracao.mp3 [--voz pf_dora]
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import subprocess
|
|
24
|
+
import sys
|
|
25
|
+
import tempfile
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
TAXA = 24000
|
|
29
|
+
RESPIRO = 0.12 # o mesmo respiro curto entre as partes do narrar.py
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _palavras(texto, de, ate):
|
|
33
|
+
"""O Kokoro em português não devolve o tempo de cada palavra: ele é
|
|
34
|
+
repartido pelo tamanho da palavra dentro da parte (melhor que nada para
|
|
35
|
+
a tipografia seguir a voz)."""
|
|
36
|
+
ws = texto.split()
|
|
37
|
+
total = sum(len(w) for w in ws) or 1
|
|
38
|
+
out, t = [], de
|
|
39
|
+
for w in ws:
|
|
40
|
+
d = (ate - de) * len(w) / total
|
|
41
|
+
out.append({"de": round(t, 3), "ate": round(t + d, 3), "texto": w})
|
|
42
|
+
t += d
|
|
43
|
+
return out
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def comando_roteiro(args):
|
|
47
|
+
import numpy as np
|
|
48
|
+
import soundfile as sf
|
|
49
|
+
from kokoro import KPipeline
|
|
50
|
+
|
|
51
|
+
partes = json.loads(Path(args.arquivo).read_text(encoding="utf-8"))
|
|
52
|
+
if not partes:
|
|
53
|
+
return {"ok": False, "error": "o roteiro veio vazio"}
|
|
54
|
+
pipe = KPipeline(lang_code="p") # português do Brasil
|
|
55
|
+
pausa = np.zeros(int(TAXA * RESPIRO), dtype=np.float32)
|
|
56
|
+
blocos, trechos, palavras, t = [], [], [], 0.0
|
|
57
|
+
for parte in partes:
|
|
58
|
+
texto = re.sub(r"\s+", " ", str(parte.get("texto") or "")).strip()
|
|
59
|
+
if not texto:
|
|
60
|
+
trechos.append({"de": round(t, 3), "ate": round(t, 3), "palavras": []})
|
|
61
|
+
continue
|
|
62
|
+
audio = np.concatenate([np.asarray(a, dtype=np.float32) for _, _, a in pipe(texto, voice=args.voz)])
|
|
63
|
+
dur = len(audio) / TAXA
|
|
64
|
+
ws = _palavras(texto, t, t + dur)
|
|
65
|
+
palavras += ws
|
|
66
|
+
trechos.append({"de": round(t, 3), "ate": round(t + dur, 3), "palavras": ws})
|
|
67
|
+
blocos += [audio, pausa]
|
|
68
|
+
t += dur + RESPIRO
|
|
69
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
70
|
+
wav = Path(tmp) / "voz.wav"
|
|
71
|
+
sf.write(str(wav), np.concatenate(blocos), TAXA)
|
|
72
|
+
r = subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(wav),
|
|
73
|
+
"-af", "loudnorm=I=-14:TP=-1.5:LRA=9", "-c:a", "libmp3lame", "-q:a", "3",
|
|
74
|
+
str(args.saida)], capture_output=True, timeout=180)
|
|
75
|
+
if r.returncode != 0:
|
|
76
|
+
return {"ok": False, "error": (r.stderr or b"").decode("utf-8", "ignore")[-300:]}
|
|
77
|
+
return {"ok": True, "arquivo": str(args.saida), "motor": "kokoro", "voz": f"kokoro {args.voz}",
|
|
78
|
+
"segundos": round(t, 3), "partes": trechos, "palavras": palavras}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main():
|
|
82
|
+
p = argparse.ArgumentParser(description="Narração local do PrimoCode Studio (Kokoro)")
|
|
83
|
+
sub = p.add_subparsers(dest="comando", required=True)
|
|
84
|
+
r = sub.add_parser("roteiro")
|
|
85
|
+
r.add_argument("--arquivo", required=True)
|
|
86
|
+
r.add_argument("--saida", required=True)
|
|
87
|
+
r.add_argument("--voz", default="pm_alex")
|
|
88
|
+
args = p.parse_args()
|
|
89
|
+
try:
|
|
90
|
+
saida = comando_roteiro(args)
|
|
91
|
+
except Exception as e: # nunca derruba a corrente: o JS passa para a próxima voz
|
|
92
|
+
saida = {"ok": False, "error": f"o kokoro falhou: {str(e)[:300]}"}
|
|
93
|
+
print(json.dumps(saida, ensure_ascii=False))
|
|
94
|
+
sys.exit(0 if saida.get("ok") else 1)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
if __name__ == "__main__":
|
|
98
|
+
main()
|