primocode 8.11.1 → 8.11.2

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
@@ -112,13 +112,36 @@ function pedir(metodo, rota, corpo, timeout = 30000) {
112
112
  path: rota,
113
113
  method: metodo,
114
114
  timeout,
115
- headers: dados ? { 'Content-Type': 'application/json', 'Content-Length': dados.length } : {},
115
+ // Este cabeçalho diz ao Studio que quem fala é a IA, não o editor
116
+ // do navegador. O servidor valida o documento com rigor só neste
117
+ // caso: erro de formato tem de voltar para o agente como lição, e
118
+ // não pode fazer o salvamento de uma pessoa que está digitando
119
+ // falhar no meio da edição.
120
+ headers: Object.assign({ 'X-Primo-Agente': '1' },
121
+ dados ? { 'Content-Type': 'application/json', 'Content-Length': dados.length } : {}),
116
122
  }, (res) => {
117
123
  let texto = '';
118
124
  res.setEncoding('utf8');
119
125
  res.on('data', (c) => { texto += c; });
120
126
  res.on('end', () => {
121
- if (res.statusCode >= 400) return reject(new Error(`HTTP ${res.statusCode}: ${texto.slice(0, 200)}`));
127
+ if (res.statusCode >= 400) {
128
+ // A mensagem do servidor é escrita PARA o agente ("tema X
129
+ // não existe, os temas são..."). Envolver em "HTTP 400:
130
+ // {json}" só afasta a lição do olho dele.
131
+ let motivo = texto.slice(0, 400);
132
+ let corpoErro = null;
133
+ try {
134
+ corpoErro = JSON.parse(texto);
135
+ motivo = corpoErro.erro || motivo;
136
+ } catch { /* texto cru serve */ }
137
+ const erro = new Error(motivo);
138
+ erro.corpo = corpoErro;
139
+ // O código vem no objeto, e não mais embutido no texto:
140
+ // quem trata 404 (comIdReal) precisa saber que é 404 sem
141
+ // depender de a mensagem começar com "HTTP 404".
142
+ erro.status = res.statusCode;
143
+ return reject(erro);
144
+ }
122
145
  try { resolve(JSON.parse(texto)); }
123
146
  catch { resolve({ texto }); }
124
147
  });
@@ -264,7 +287,7 @@ async function comIdReal(id, fn) {
264
287
  try {
265
288
  return await fn();
266
289
  } catch (e) {
267
- if (!/HTTP 404/.test(String(e && e.message))) throw e;
290
+ if (!(e && e.status === 404)) throw e;
268
291
  let existem = [];
269
292
  try { existem = (await pedir('GET', '/api/arquivos')).map((a) => a.id).slice(0, 8); } catch {}
270
293
  return {
@@ -284,13 +307,74 @@ const criar = ({ ferramenta, titulo, doc }) => comStudio(async () => {
284
307
  return { ok: true, ...r };
285
308
  });
286
309
 
310
+ // O que o agente recebe de volta depois de mexer num arquivo. Devolver o
311
+ // documento inteiro era caro e inútil: um vídeo de oito cenas volta como
312
+ // milhares de tokens que ele acabou de escrever. O que ele precisa saber é
313
+ // que gravou, quantas peças ficaram e como voltar atrás.
314
+ const resumoDoArquivo = (r) => {
315
+ const doc = r.doc || {};
316
+ const pecas = (doc.cenas || doc.blocos || doc.abas || []).length;
317
+ return {
318
+ id: r.id, titulo: r.titulo, ferramenta: r.ferramenta,
319
+ pecas, tema: doc.tema,
320
+ temNarracao: !!(doc.narracao || (doc.cenas || []).some((c) => c && c.narracao)),
321
+ temTrilha: !!doc.trilha,
322
+ // O mesmo link de sempre: ajustar não cria peça nova, e é isso que o
323
+ // agente precisa dizer ao usuário no finish.
324
+ // localhost, e não 127.0.0.1, para bater com o link que studio_criar
325
+ // já deu ao usuário — dois endereços para a mesma peça confundem.
326
+ ver: `http://localhost:${PORTA}/v/${r.id}`,
327
+ };
328
+ };
329
+
287
330
  const atualizar = ({ id, titulo, doc }) => comStudio(() => comIdReal(id, async () => {
288
331
  if (!id) return { ok: false, error: 'Falta o id do arquivo.' };
289
332
  const corpo = {};
290
333
  if (titulo != null) corpo.titulo = titulo;
291
334
  if (doc != null) corpo.doc = doc;
292
335
  const r = await pedir('PUT', `/api/arquivos/${encodeURIComponent(id)}`, corpo);
293
- return { ok: true, ...r };
336
+ const saida = { ok: true, ...resumoDoArquivo(r) };
337
+ // A mescla é a razão de isto não ter destruído nada: as chaves que você
338
+ // não mandou continuam lá. Dizer isso evita a rodada seguinte em que o
339
+ // agente "reconstrói" o que nunca se perdeu.
340
+ saida.nota = 'Só as chaves que você mandou mudaram; o resto do documento continua como estava.';
341
+ if (r.versao_anterior) saida.desfazer = `studio_versoes com id "${r.id}" e restaurar "${r.versao_anterior}" volta ao estado anterior.`;
342
+ return saida;
343
+ }));
344
+
345
+ // ── Histórico ────────────────────────────────────────────────────────────
346
+ // Existe porque uma vez não existiu: um ajuste apagou oito cenas de trabalho
347
+ // e não havia de onde tirar de volta.
348
+ const versoesDe = ({ id, restaurar }) => comStudio(() => comIdReal(id, async () => {
349
+ if (!id) return { ok: false, error: 'Falta o id do arquivo.' };
350
+ if (restaurar) {
351
+ const alvo = restaurar === true || restaurar === 'anterior' ? null : String(restaurar);
352
+ try {
353
+ const r = await pedir('POST', `/api/arquivos/${encodeURIComponent(id)}/restaurar`,
354
+ alvo ? { versao: alvo } : {});
355
+ return { ok: true, ...r };
356
+ } catch (e) {
357
+ // 404 aqui pode ser o arquivo OU a versão. Quando o servidor manda
358
+ // a lista junto, o arquivo existe e quem não existe é a versão —
359
+ // deixar comIdReal dizer "o id não existe" mandaria o agente
360
+ // caçar um problema que não é o dele.
361
+ if (e && e.status === 404 && e.corpo && Array.isArray(e.corpo.versoes)) {
362
+ return {
363
+ ok: false,
364
+ error: `A versão "${alvo}" não existe.`,
365
+ versoes: e.corpo.versoes,
366
+ comoCorrigir: 'Use um "versao" desta lista, copiado LITERALMENTE, ou "anterior" para desfazer a última mudança.',
367
+ };
368
+ }
369
+ throw e;
370
+ }
371
+ }
372
+ const r = await pedir('GET', `/api/arquivos/${encodeURIComponent(id)}/versoes`);
373
+ return {
374
+ ok: true,
375
+ versoes: r.versoes || [],
376
+ comoVoltar: 'Para voltar, chame de novo com restaurar: "<versao>" — ou restaurar: "anterior" para desfazer a última mudança.',
377
+ };
294
378
  }));
295
379
 
296
380
  // ── Mídia: a imagem de fundo e a música do usuário ───────────────────────
@@ -338,6 +422,6 @@ const midiaEnviar = ({ arquivo, nome }) => comStudio(async () => {
338
422
  module.exports = {
339
423
  BASE, PORTA, CASA,
340
424
  garantirNoAr, estaNoAr, prepararCasa, pythonCmd,
341
- manual, ferramentas, spec, listar, ler, criar, atualizar,
425
+ manual, ferramentas, spec, listar, ler, criar, atualizar, versoesDe,
342
426
  midiaListar, midiaEnviar,
343
427
  };
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.11.2",
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"
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
 
package/studio/server.py CHANGED
@@ -149,6 +149,106 @@ def gravar(arq):
149
149
  return arq
150
150
 
151
151
 
152
+ # ------------------------------------------------------------------ versões --
153
+ #
154
+ # Um pedido de ajuste apagou o trabalho de um usuário: ele tinha um vídeo de
155
+ # oito cenas, com narração e trilha, pediu "aplique a identidade visual" e o
156
+ # agente mandou um PUT com doc={"tema":"..."}. O handler trocava o documento
157
+ # INTEIRO pelo que chegou — as oito cenas, a narração e a trilha viraram um
158
+ # documento de uma chave só. Não havia como voltar.
159
+ #
160
+ # Daí saíram duas defesas. `mesclar` faz o pedido parcial ser parcial de
161
+ # verdade, que é o conserto. Este histórico é o que sobra quando o conserto
162
+ # não basta: guarda o estado anterior a cada gravação, para que sobrescrever
163
+ # nunca mais seja definitivo. Nada aqui apaga o arquivo vivo; o pior caso é
164
+ # ocupar disco, e vinte versões de um documento de texto não dão um megabyte.
165
+
166
+ VERSOES = os.path.join(ARQUIVOS, ".versoes")
167
+ MAX_VERSOES = 20
168
+
169
+
170
+ _seq_versao = 0
171
+
172
+
173
+ def guardar_versao(arq):
174
+ """Copia o estado atual para .versoes/<id>/<carimbo>.json antes de trocar.
175
+
176
+ O carimbo é ordenável como texto, e por isso precisa de milissegundo: o
177
+ editor do navegador salva a cada 600 ms, e duas versões do mesmo segundo
178
+ desempatadas por sorteio fariam "desfaz a última mudança" desfazer a
179
+ penúltima. O contador no fim resolve o empate que sobra."""
180
+ global _seq_versao
181
+ try:
182
+ pasta = os.path.join(VERSOES, arq["id"])
183
+ os.makedirs(pasta, exist_ok=True)
184
+ _seq_versao = (_seq_versao + 1) % 10000
185
+ agora = datetime.now(timezone.utc)
186
+ carimbo = "%s%03d-%04d" % (agora.strftime("%Y%m%dT%H%M%S"),
187
+ agora.microsecond // 1000, _seq_versao)
188
+ with open(os.path.join(pasta, carimbo + ".json"), "w", encoding="utf-8") as fh:
189
+ json.dump(arq, fh, ensure_ascii=False)
190
+ antigas = sorted(n for n in os.listdir(pasta) if n.endswith(".json"))
191
+ for velha in antigas[:-MAX_VERSOES]:
192
+ os.remove(os.path.join(pasta, velha))
193
+ return carimbo
194
+ except Exception:
195
+ # Histórico é rede de proteção, não requisito: se o disco recusar, a
196
+ # gravação principal segue. Falhar aqui seria trocar perder o passado
197
+ # por perder o presente.
198
+ return None
199
+
200
+
201
+ def versoes(arq_id):
202
+ """As versões guardadas, da mais nova para a mais velha."""
203
+ if not caminho(arq_id):
204
+ return []
205
+ pasta = os.path.join(VERSOES, arq_id)
206
+ if not os.path.isdir(pasta):
207
+ return []
208
+ saida = []
209
+ for nome in sorted(os.listdir(pasta), reverse=True):
210
+ if not nome.endswith(".json"):
211
+ continue
212
+ try:
213
+ with open(os.path.join(pasta, nome), encoding="utf-8") as fh:
214
+ a = json.load(fh)
215
+ except Exception:
216
+ continue
217
+ doc = a.get("doc") or {}
218
+ saida.append({
219
+ "versao": nome[:-5],
220
+ "titulo": a.get("titulo", ""),
221
+ "pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
222
+ "atualizado": a.get("atualizado", ""),
223
+ })
224
+ return saida
225
+
226
+
227
+ def ler_versao(arq_id, versao):
228
+ if not caminho(arq_id) or not re.fullmatch(r"[0-9A-Za-z\-]{1,40}", versao or ""):
229
+ return None
230
+ c = os.path.join(VERSOES, arq_id, versao + ".json")
231
+ if not os.path.exists(c):
232
+ return None
233
+ with open(c, encoding="utf-8") as fh:
234
+ return json.load(fh)
235
+
236
+
237
+ def mesclar(antigo, novo):
238
+ """O documento novo por cima do antigo, chave a chave.
239
+
240
+ Quem só quer trocar o tema manda {"tema":"papel"} — e é a coisa natural a
241
+ mandar, porque o pedido era esse. Trocar o documento inteiro por isso
242
+ apagava cenas, narração e trilha. Mesclando, o que não foi citado fica
243
+ onde estava e o que foi citado vale: mandar cenas novas ainda troca as
244
+ cenas, que é o ajuste de verdade."""
245
+ if not isinstance(antigo, dict) or not isinstance(novo, dict):
246
+ return novo
247
+ saida = dict(antigo)
248
+ saida.update(novo)
249
+ return saida
250
+
251
+
152
252
  def listar():
153
253
  saida = []
154
254
  for nome in os.listdir(ARQUIVOS):
@@ -311,6 +411,22 @@ def _texto_do_doc(doc):
311
411
  return " ".join(pedacos).lower()
312
412
 
313
413
 
414
+ # Os temas que o render.css de fato define. Fora desta lista não existe cor
415
+ # nenhuma — foi o que aconteceu com "noite", que passou como se fosse tema.
416
+ TEMAS_VALIDOS = {"claro", "meia-noite", "papel", "neon", "brasa", "floresta",
417
+ "retro", "oceano", "doce", "mono"}
418
+
419
+
420
+ def _tema_invalido(tema, onde=""):
421
+ if tema is None:
422
+ return None
423
+ if isinstance(tema, str) and tema in TEMAS_VALIDOS:
424
+ return None
425
+ return ('tema %r%s não existe — a peça sairia sem cor nenhuma. Os temas são: %s. '
426
+ 'Para escuro use "meia-noite".'
427
+ % (tema, onde, ", ".join(sorted(TEMAS_VALIDOS))))
428
+
429
+
314
430
  def validar_doc(modelo, doc):
315
431
  """None quando o doc serve; senão, a frase que ENSINA o formato certo.
316
432
 
@@ -332,6 +448,13 @@ def validar_doc(modelo, doc):
332
448
  TIPOS_VALIDOS = {"texto", "lista", "cartoes", "numeros", "tabela", "codigo",
333
449
  "citacao", "forma", "imagem", "icone", "grafico", "no", "conector"}
334
450
 
451
+ # Tema inventado tinha o mesmo destino silencioso do tipo inventado: o CSS
452
+ # não casa com [data-tema="noite"], nenhuma cor é definida e a peça sai
453
+ # sem identidade nenhuma. Recusar aqui devolve a lista certa ao agente.
454
+ problema = _tema_invalido(doc.get("tema"))
455
+ if problema:
456
+ return problema
457
+
335
458
  if modelo == "cena":
336
459
  cenas = doc.get("cenas")
337
460
  if not isinstance(cenas, list) or not cenas:
@@ -345,6 +468,9 @@ def validar_doc(modelo, doc):
345
468
  if not isinstance(els, list) or not els:
346
469
  return ('cena %d sem "elementos". Cada cena precisa de elementos:[...] — '
347
470
  'ex.: {"tipo":"texto","texto":"..."}. Leia studio_spec e refaça.' % (i + 1))
471
+ problema = _tema_invalido(c.get("tema"), " (cena %d)" % (i + 1))
472
+ if problema:
473
+ return problema
348
474
  dur = c.get("duracao")
349
475
  if dur is not None and not isinstance(dur, (int, float)):
350
476
  return ('cena %d com "duracao" que não é número (veio %r). Use número de segundos: '
@@ -600,6 +726,12 @@ class Handler(SimpleHTTPRequestHandler):
600
726
  a = ler(m.group(1))
601
727
  return self.js(a) if a else self.js({"erro": "não encontrado"}, 404)
602
728
 
729
+ m = re.fullmatch(r"/api/arquivos/([^/]+)/versoes", p)
730
+ if m:
731
+ if not ler(m.group(1)):
732
+ return self.js({"erro": "não encontrado"}, 404)
733
+ return self.js({"versoes": versoes(m.group(1))})
734
+
603
735
  m = re.fullmatch(r"/api/arquivos/([^/]+)/export", p)
604
736
  if m:
605
737
  a = ler(m.group(1))
@@ -838,6 +970,32 @@ class Handler(SimpleHTTPRequestHandler):
838
970
  threading.Thread(target=lambda: (time.sleep(0.3), os._exit(0)), daemon=True).start()
839
971
  return
840
972
 
973
+ m = re.fullmatch(r"/api/arquivos/([^/]+)/restaurar", rota)
974
+ if m:
975
+ a = ler(m.group(1))
976
+ if not a:
977
+ return self.js({"erro": "não encontrado"}, 404)
978
+ pedido = self.corpo()
979
+ disponiveis = versoes(m.group(1))
980
+ # Sem versão dita, a anterior — que é o que "desfaz" quer dizer
981
+ # quando alguém acabou de estragar o arquivo.
982
+ alvo = pedido.get("versao") or (disponiveis[0]["versao"] if disponiveis else None)
983
+ velha = ler_versao(m.group(1), alvo) if alvo else None
984
+ if not velha:
985
+ return self.js({"erro": "versão não encontrada",
986
+ "versoes": disponiveis}, 404)
987
+ # Restaurar também guarda versão: desfazer o desfazer tem de valer.
988
+ guardar_versao(a)
989
+ a["titulo"] = velha.get("titulo", a.get("titulo"))
990
+ a["doc"] = velha.get("doc") or a.get("doc")
991
+ gravar(a)
992
+ doc = a.get("doc") or {}
993
+ return self.js({
994
+ "ok": True, "id": a["id"], "restaurado": alvo,
995
+ "titulo": a.get("titulo"),
996
+ "pecas": len(doc.get("cenas") or doc.get("blocos") or doc.get("abas") or []),
997
+ })
998
+
841
999
  if rota == "/api/arquivos":
842
1000
  try:
843
1001
  a = criar(self.corpo())
@@ -873,10 +1031,36 @@ class Handler(SimpleHTTPRequestHandler):
873
1031
  return self.js({"erro": "titulo precisa ser texto"}, 400)
874
1032
  if "doc" in dados and dados["doc"] is not None and not isinstance(dados["doc"], dict):
875
1033
  return self.js({"erro": "doc precisa ser um objeto"}, 400)
876
- for chave in ("titulo", "doc"):
877
- if chave in dados:
878
- a[chave] = dados[chave]
879
- return self.js(gravar(a))
1034
+
1035
+ antes = a.get("doc") or {}
1036
+ novo = a.get("doc")
1037
+ if "doc" in dados and dados["doc"] is not None:
1038
+ # Mesclar em vez de trocar. Era aqui que o trabalho sumia: um doc
1039
+ # parcial substituía o inteiro. Quem quiser mesmo jogar fora o que
1040
+ # existe manda "substituir": true e assume.
1041
+ novo = dados["doc"] if dados.get("substituir") else mesclar(antes, dados["doc"])
1042
+
1043
+ # O agente precisa da validação: é a única forma de ele saber que
1044
+ # errou o formato. O editor do navegador, não — ali há uma pessoa
1045
+ # digitando, e uma cena momentaneamente vazia no meio da edição não
1046
+ # pode fazer o salvamento falhar calado.
1047
+ if self.headers.get("X-Primo-Agente"):
1048
+ problema = validar_doc(
1049
+ POR_SLUG.get(a.get("ferramenta"), {}).get("modelo"), novo)
1050
+ if problema:
1051
+ return self.js({"erro": problema}, 400)
1052
+
1053
+ versao = guardar_versao(a)
1054
+ if "titulo" in dados:
1055
+ a["titulo"] = dados["titulo"]
1056
+ if "doc" in dados:
1057
+ a["doc"] = novo
1058
+ salvo = gravar(a)
1059
+ if versao:
1060
+ # O agente vê isto no resultado da tool: se ele estragou algo, o
1061
+ # caminho de volta está na resposta, não numa doc que ele não leu.
1062
+ salvo = dict(salvo, versao_anterior=versao)
1063
+ return self.js(salvo)
880
1064
 
881
1065
  @seguro
882
1066
  def do_DELETE(self):