primocode 8.11.1 → 8.12.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/lib/catalogo.js CHANGED
@@ -83,7 +83,9 @@ const GRUPOS = [
83
83
  args: 'ferramenta: prisma | tela | prosa | grade | corte | traco. titulo. doc: objeto no formato que studio_spec daquela ferramenta descreve.' },
84
84
  { tool: 'studio_ler', escreve: false, faz: 'lê algo que já foi criado', args: 'id.' },
85
85
  { tool: 'studio_atualizar', escreve: false, faz: 'ajusta o que já existe, sem refazer do zero',
86
- args: 'id. titulo (opcional). doc (opcional).' },
86
+ args: 'id. titulo (opcional). doc (opcional): só as chaves que MUDAM — o resto do documento fica intacto.' },
87
+ { tool: 'studio_versoes', escreve: false, faz: 'histórico de um arquivo, e volta a uma versão anterior',
88
+ args: 'id. restaurar (opcional): "anterior" desfaz a última mudança, ou o nome de uma versão da lista.' },
87
89
  { tool: 'studio_listar', escreve: false, faz: 'lista o que já existe no estúdio', args: null },
88
90
  { tool: 'studio_midia', escreve: false, faz: 'manda imagem, música ou vídeo seu para usar nas peças',
89
91
  args: 'arquivo (opcional): caminho no projeto — sem ele, lista a galeria. nome (opcional).' },
package/lib/studio.js CHANGED
@@ -39,7 +39,10 @@ const BASE = `http://127.0.0.1:${PORTA}`;
39
39
 
40
40
  // Só o código do Studio é atualizado; `arquivos/` e `midia/` (o que o usuário
41
41
  // criou) nunca são tocados.
42
- const COPIAVEIS = new Set(['server.py', 'web', 'ferramentas', 'README.md', 'PRIMOCODE.md']);
42
+ // voz.py é módulo do servidor, não enfeite: sem ele o server.py não importa
43
+ // e o Studio inteiro deixa de subir. Todo arquivo .py do Studio precisa
44
+ // entrar aqui.
45
+ const COPIAVEIS = new Set(['server.py', 'voz.py', 'web', 'ferramentas', 'README.md', 'PRIMOCODE.md']);
43
46
 
44
47
  /**
45
48
  * Onde está o Python 3 desta máquina.
@@ -112,13 +115,36 @@ function pedir(metodo, rota, corpo, timeout = 30000) {
112
115
  path: rota,
113
116
  method: metodo,
114
117
  timeout,
115
- headers: dados ? { 'Content-Type': 'application/json', 'Content-Length': dados.length } : {},
118
+ // Este cabeçalho diz ao Studio que quem fala é a IA, não o editor
119
+ // do navegador. O servidor valida o documento com rigor só neste
120
+ // caso: erro de formato tem de voltar para o agente como lição, e
121
+ // não pode fazer o salvamento de uma pessoa que está digitando
122
+ // falhar no meio da edição.
123
+ headers: Object.assign({ 'X-Primo-Agente': '1' },
124
+ dados ? { 'Content-Type': 'application/json', 'Content-Length': dados.length } : {}),
116
125
  }, (res) => {
117
126
  let texto = '';
118
127
  res.setEncoding('utf8');
119
128
  res.on('data', (c) => { texto += c; });
120
129
  res.on('end', () => {
121
- if (res.statusCode >= 400) return reject(new Error(`HTTP ${res.statusCode}: ${texto.slice(0, 200)}`));
130
+ if (res.statusCode >= 400) {
131
+ // A mensagem do servidor é escrita PARA o agente ("tema X
132
+ // não existe, os temas são..."). Envolver em "HTTP 400:
133
+ // {json}" só afasta a lição do olho dele.
134
+ let motivo = texto.slice(0, 400);
135
+ let corpoErro = null;
136
+ try {
137
+ corpoErro = JSON.parse(texto);
138
+ motivo = corpoErro.erro || motivo;
139
+ } catch { /* texto cru serve */ }
140
+ const erro = new Error(motivo);
141
+ erro.corpo = corpoErro;
142
+ // O código vem no objeto, e não mais embutido no texto:
143
+ // quem trata 404 (comIdReal) precisa saber que é 404 sem
144
+ // depender de a mensagem começar com "HTTP 404".
145
+ erro.status = res.statusCode;
146
+ return reject(erro);
147
+ }
122
148
  try { resolve(JSON.parse(texto)); }
123
149
  catch { resolve({ texto }); }
124
150
  });
@@ -264,7 +290,7 @@ async function comIdReal(id, fn) {
264
290
  try {
265
291
  return await fn();
266
292
  } catch (e) {
267
- if (!/HTTP 404/.test(String(e && e.message))) throw e;
293
+ if (!(e && e.status === 404)) throw e;
268
294
  let existem = [];
269
295
  try { existem = (await pedir('GET', '/api/arquivos')).map((a) => a.id).slice(0, 8); } catch {}
270
296
  return {
@@ -277,20 +303,102 @@ async function comIdReal(id, fn) {
277
303
 
278
304
  const ler = (id) => comStudio(() => comIdReal(id, async () => ({ ok: true, arquivo: await pedir('GET', `/api/arquivos/${encodeURIComponent(id)}`) })));
279
305
 
306
+ // Quando o documento tem narração, o servidor começa a baixar a voz neural
307
+ // sozinho. São ~90 MB, e o usuário merece saber por que o primeiro vídeo sai
308
+ // com uma voz e o próximo com outra — melhor ele ouvir isso do agente do que
309
+ // achar que o programa está inconstante.
310
+ async function avisoDaVoz(doc) {
311
+ const temNarracao = !!(doc && (doc.narracao
312
+ || (doc.cenas || []).some((c) => c && typeof c.narracao === 'string' && c.narracao.trim())));
313
+ if (!temNarracao) return null;
314
+ try {
315
+ const s = await pedir('GET', '/api/voz/status', null, 3000);
316
+ if (s && s.piper && !s.piper.pronta && s.piper.suportado) {
317
+ return 'A voz neural (offline, ilimitada) está sendo baixada agora — ~90 MB, uma vez só. '
318
+ + 'Até terminar a narração sai por uma voz de reserva, mais simples. Diga isso ao usuário.';
319
+ }
320
+ } catch { /* status é enfeite; não pode atrapalhar a criação */ }
321
+ return null;
322
+ }
323
+
280
324
  const criar = ({ ferramenta, titulo, doc }) => comStudio(async () => {
281
325
  if (!ferramenta) return { ok: false, error: 'Diga a ferramenta: prisma, tela, prosa, grade, corte ou traco.' };
282
326
  if (!doc) return { ok: false, error: 'Falta o doc. Leia a spec com studio_spec antes de criar.' };
283
327
  const r = await pedir('POST', '/api/arquivos', { ferramenta, titulo: titulo || 'Sem título', doc });
284
- return { ok: true, ...r };
328
+ const saida = { ok: true, ...r };
329
+ const voz = await avisoDaVoz(doc);
330
+ if (voz) saida.voz = voz;
331
+ return saida;
285
332
  });
286
333
 
334
+ // O que o agente recebe de volta depois de mexer num arquivo. Devolver o
335
+ // documento inteiro era caro e inútil: um vídeo de oito cenas volta como
336
+ // milhares de tokens que ele acabou de escrever. O que ele precisa saber é
337
+ // que gravou, quantas peças ficaram e como voltar atrás.
338
+ const resumoDoArquivo = (r) => {
339
+ const doc = r.doc || {};
340
+ const pecas = (doc.cenas || doc.blocos || doc.abas || []).length;
341
+ return {
342
+ id: r.id, titulo: r.titulo, ferramenta: r.ferramenta,
343
+ pecas, tema: doc.tema,
344
+ temNarracao: !!(doc.narracao || (doc.cenas || []).some((c) => c && c.narracao)),
345
+ temTrilha: !!doc.trilha,
346
+ // O mesmo link de sempre: ajustar não cria peça nova, e é isso que o
347
+ // agente precisa dizer ao usuário no finish.
348
+ // localhost, e não 127.0.0.1, para bater com o link que studio_criar
349
+ // já deu ao usuário — dois endereços para a mesma peça confundem.
350
+ ver: `http://localhost:${PORTA}/v/${r.id}`,
351
+ };
352
+ };
353
+
287
354
  const atualizar = ({ id, titulo, doc }) => comStudio(() => comIdReal(id, async () => {
288
355
  if (!id) return { ok: false, error: 'Falta o id do arquivo.' };
289
356
  const corpo = {};
290
357
  if (titulo != null) corpo.titulo = titulo;
291
358
  if (doc != null) corpo.doc = doc;
292
359
  const r = await pedir('PUT', `/api/arquivos/${encodeURIComponent(id)}`, corpo);
293
- return { ok: true, ...r };
360
+ const saida = { ok: true, ...resumoDoArquivo(r) };
361
+ // A mescla é a razão de isto não ter destruído nada: as chaves que você
362
+ // não mandou continuam lá. Dizer isso evita a rodada seguinte em que o
363
+ // agente "reconstrói" o que nunca se perdeu.
364
+ saida.nota = 'Só as chaves que você mandou mudaram; o resto do documento continua como estava.';
365
+ if (r.versao_anterior) saida.desfazer = `studio_versoes com id "${r.id}" e restaurar "${r.versao_anterior}" volta ao estado anterior.`;
366
+ return saida;
367
+ }));
368
+
369
+ // ── Histórico ────────────────────────────────────────────────────────────
370
+ // Existe porque uma vez não existiu: um ajuste apagou oito cenas de trabalho
371
+ // e não havia de onde tirar de volta.
372
+ const versoesDe = ({ id, restaurar }) => comStudio(() => comIdReal(id, async () => {
373
+ if (!id) return { ok: false, error: 'Falta o id do arquivo.' };
374
+ if (restaurar) {
375
+ const alvo = restaurar === true || restaurar === 'anterior' ? null : String(restaurar);
376
+ try {
377
+ const r = await pedir('POST', `/api/arquivos/${encodeURIComponent(id)}/restaurar`,
378
+ alvo ? { versao: alvo } : {});
379
+ return { ok: true, ...r };
380
+ } catch (e) {
381
+ // 404 aqui pode ser o arquivo OU a versão. Quando o servidor manda
382
+ // a lista junto, o arquivo existe e quem não existe é a versão —
383
+ // deixar comIdReal dizer "o id não existe" mandaria o agente
384
+ // caçar um problema que não é o dele.
385
+ if (e && e.status === 404 && e.corpo && Array.isArray(e.corpo.versoes)) {
386
+ return {
387
+ ok: false,
388
+ error: `A versão "${alvo}" não existe.`,
389
+ versoes: e.corpo.versoes,
390
+ comoCorrigir: 'Use um "versao" desta lista, copiado LITERALMENTE, ou "anterior" para desfazer a última mudança.',
391
+ };
392
+ }
393
+ throw e;
394
+ }
395
+ }
396
+ const r = await pedir('GET', `/api/arquivos/${encodeURIComponent(id)}/versoes`);
397
+ return {
398
+ ok: true,
399
+ versoes: r.versoes || [],
400
+ comoVoltar: 'Para voltar, chame de novo com restaurar: "<versao>" — ou restaurar: "anterior" para desfazer a última mudança.',
401
+ };
294
402
  }));
295
403
 
296
404
  // ── Mídia: a imagem de fundo e a música do usuário ───────────────────────
@@ -338,6 +446,6 @@ const midiaEnviar = ({ arquivo, nome }) => comStudio(async () => {
338
446
  module.exports = {
339
447
  BASE, PORTA, CASA,
340
448
  garantirNoAr, estaNoAr, prepararCasa, pythonCmd,
341
- manual, ferramentas, spec, listar, ler, criar, atualizar,
449
+ manual, ferramentas, spec, listar, ler, criar, atualizar, versoesDe,
342
450
  midiaListar, midiaEnviar,
343
451
  };
package/lib/tools.js CHANGED
@@ -535,6 +535,8 @@ async function despachar(name, args, ctx) {
535
535
  return studio.ler(args.id);
536
536
  case 'studio_atualizar':
537
537
  return studio.atualizar({ id: args.id, titulo: args.titulo, doc: args.doc });
538
+ case 'studio_versoes':
539
+ return studio.versoesDe({ id: args.id, restaurar: args.restaurar });
538
540
  case 'studio_listar':
539
541
  return studio.listar();
540
542
  case 'studio_midia':
package/lib/ui.js CHANGED
@@ -278,6 +278,7 @@ function actionHeader(name, args) {
278
278
  desktop_double_click:'Desktop·2click', desktop_open_app:'Desktop·app',
279
279
  studio_manual:'Estúdio·manual', studio_spec:'Estúdio·spec', studio_criar:'Estúdio·criar',
280
280
  studio_ler:'Estúdio·ler', studio_atualizar:'Estúdio·ajustar', studio_listar:'Estúdio·lista',
281
+ studio_versoes:'Estúdio·histórico',
281
282
  desktop_windows:'Desktop·janelas', desktop_focus:'Desktop·foco',
282
283
  desktop_elements:'Desktop·elementos', desktop_click_element:'Desktop·click',
283
284
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "primocode",
3
- "version": "8.11.1",
3
+ "version": "8.12.0",
4
4
  "description": "PrimoCode — agente de engenharia com IA e cursor próprio. Modelos grátis. 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/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js"
11
+ "test": "node test/tools.test.js && node test/ui.test.js && node test/modo.test.js && node test/pasta.test.js && node test/catalogo.test.js && node test/desktop.test.js && node test/effort.test.js && node test/claude-engine.test.js && node test/regressao.test.js && node test/memoria-conversa.test.js && node test/repeticao.test.js && node test/referencia.test.js && node test/parar.test.js && node test/continuar.test.js && node test/studio-spec.test.js && node test/studio-versoes.test.js && node test/voz.test.js"
12
12
  },
13
13
  "engines": {
14
14
  "node": ">=18.17.0"
@@ -106,6 +106,19 @@ A resposta do POST traz:
106
106
 
107
107
  ## Iterar
108
108
 
109
- Para ajustar algo que o usuário pediu depois, faça `GET` do arquivo, altere só o que
110
- mudou e `PUT` de volta. Não recrie do zero — o link já está com o usuário e deve
109
+ Para ajustar algo que o usuário pediu depois, mande no `PUT` **só as chaves que
110
+ mudam**. Elas entram por cima do documento; o que você não citou fica como estava.
111
+ Trocar o tema de um vídeo pronto é `{"doc":{"tema":"papel"}}` — as cenas, a narração
112
+ e a trilha continuam lá. Não recrie do zero: o link já está com o usuário e deve
111
113
  continuar valendo.
114
+
115
+ Se quiser mesmo jogar fora o documento inteiro e pôr outro no lugar, mande
116
+ `{"doc": {...}, "substituir": true}`. Sem essa chave, nada é apagado.
117
+
118
+ | Método | Rota | O que faz |
119
+ |---|---|---|
120
+ | `GET` | `/api/arquivos/<id>/versoes` | as últimas 20 versões, a mais nova primeiro |
121
+ | `POST` | `/api/arquivos/<id>/restaurar` | `{versao?}` — sem `versao`, desfaz a última mudança |
122
+
123
+ Toda gravação guarda a versão anterior antes de trocar. Estragou um arquivo pronto?
124
+ `POST /api/arquivos/<id>/restaurar` traz de volta.
@@ -136,6 +136,8 @@ Sem declarar nada, os elementos já entram em cascata na ordem em que aparecem.
136
136
 
137
137
  `meia-noite` `claro` `papel` `neon` `brasa` `floresta` `retro` `oceano` `doce` `mono`
138
138
 
139
+ São esses dez, e só. Nome fora da lista é recusado: escuro é `meia-noite`, não
140
+ `noite`. Cor de marca não vira tema — ela entra em `cor` nos elementos.
139
141
  Vale no documento ou por cena. Uma cena com tema diferente no meio cria respiro —
140
142
  use uma vez, não cinco.
141
143
 
@@ -138,12 +138,28 @@ na hora de tocar — sem gravar nada, sem arquivo:
138
138
  "elementos": [ ... ] }
139
139
  ```
140
140
 
141
- Ajuste da voz no **documento** (opcional):
141
+ Escolha da voz no **documento** (opcional):
142
142
 
143
143
  ```json
144
- "voz": { "velocidade": 1, "tom": 1, "volume": 1, "lang": "pt-BR" }
144
+ "voz": { "voz": "jeff", "grave": true, "velocidade": 1 }
145
145
  ```
146
146
 
147
+ A síntese é neural e roda na máquina do usuário — offline, sem limite. As
148
+ vozes pt-BR, com a frequência de cada uma medida (menor = mais grave):
149
+
150
+ | id | timbre | Hz |
151
+ |---|---|---|
152
+ | `jeff` | grave, de locução — **é a padrão** | 139 |
153
+ | `cadu` | grave, mais solta | 140 |
154
+ | `edresson` | média, mais leve de baixar | 151 |
155
+ | `faber` | clara, de leitura | 167 |
156
+ | `tugao` | português de Portugal | — |
157
+
158
+ `"grave": true` abaixa mais uns 10% — é o registro de abertura de vídeo. Não
159
+ há voz feminina em pt-BR neste motor; se pedirem uma, diga isso em vez de
160
+ prometer. Na primeira vez o motor é baixado sozinho (~90 MB); enquanto isso
161
+ a narração sai por uma voz de reserva, e o vídeo nunca fica mudo.
162
+
147
163
  Regras que fazem diferença:
148
164
 
149
165
  - **Escreva para o ouvido, não para o olho.** A narração NÃO repete o texto da