sagadeck 1.4.0 → 1.6.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.
Files changed (58) hide show
  1. package/README.md +59 -67
  2. package/SKILL.md +18 -11
  3. package/bin/sagadeck.js +12 -11
  4. package/docs/ANDAMENTO.md +355 -0
  5. package/docs/REFERENCIA.md +4 -4
  6. package/package.json +2 -3
  7. package/src/ai/bench.js +132 -0
  8. package/src/ai/context.js +4 -2
  9. package/src/ai/critique.js +114 -0
  10. package/src/ai/deck-ai.js +142 -32
  11. package/src/ai/document-visuals.js +7 -3
  12. package/src/ai/ia-config.js +101 -0
  13. package/src/ai/llm.js +62 -26
  14. package/src/ai/progress.js +23 -0
  15. package/src/ai/quality.js +30 -2
  16. package/src/api-client.js +2 -1
  17. package/src/build.js +3 -0
  18. package/src/decision-lab.js +2 -1
  19. package/src/deck-file.js +2 -1
  20. package/src/elements.js +10 -3
  21. package/src/figures/charts.js +7 -4
  22. package/src/figures/diagrams.js +15 -1
  23. package/src/figures/ufmap.js +2 -1
  24. package/src/fs-retry.js +19 -0
  25. package/src/layouts.js +1 -1
  26. package/src/library.js +75 -19
  27. package/src/locale.js +18 -0
  28. package/src/mcp/server.js +2 -2
  29. package/src/motion.js +5 -3
  30. package/src/preferences.js +5 -1
  31. package/src/research/research.js +6 -5
  32. package/src/runtime/calc.js +3 -3
  33. package/src/runtime/decision-lab.js +1 -1
  34. package/src/runtime/motion.js +5 -1
  35. package/src/runtime/runtime.js +2 -1
  36. package/src/studio/ai-chat-route.js +100 -4
  37. package/src/studio/banner.js +18 -0
  38. package/src/studio/code-version.js +35 -0
  39. package/src/studio/generate.js +53 -0
  40. package/src/studio/ia-routes.js +53 -0
  41. package/src/studio/project.js +8 -7
  42. package/src/studio/public/ai-settings.js +76 -0
  43. package/src/studio/public/app.js +11 -239
  44. package/src/studio/public/chat.js +71 -1
  45. package/src/studio/public/header-footer.js +140 -0
  46. package/src/studio/public/index.html +6 -0
  47. package/src/studio/public/library.html +3 -1
  48. package/src/studio/public/library.js +7 -5
  49. package/src/studio/public/preferences-ui.js +86 -0
  50. package/src/studio/public/provenance.js +13 -0
  51. package/src/studio/public/style.css +17 -0
  52. package/src/studio/public/ui-icons.js +1 -1
  53. package/src/studio/public/update-notice.js +25 -0
  54. package/src/studio/public-files.js +11 -1
  55. package/src/studio/server.js +26 -47
  56. package/src/studio/share-routes.js +2 -1
  57. package/src/studio/slide-copy.js +3 -1
  58. package/src/ai/relay.js +0 -45
package/src/library.js CHANGED
@@ -9,25 +9,75 @@
9
9
  // rascunho.yaml um .yaml solto no tópico também é uma apresentação
10
10
  // rascunho.yaml .yaml solto na raiz = apresentação sem tópico
11
11
  // Na raiz, pasta é SEMPRE tópico. "Nova sem tópico" vai para "Sem tópico"; importar sem tópico, para "Importados".
12
- // .lixeira/ excluídas (30 dias), com .origem.json
12
+ // .lixeira/ excluídas (1 dia), com .origem.json
13
13
  // .cache/ capas geradas
14
14
  //
15
15
  // O id de uma apresentação é o caminho do .yaml dela, relativo à biblioteca, com "/" (ex.: "Palestras/X/X.yaml").
16
16
  import fs from "node:fs";
17
+ import { renameRetry } from "./fs-retry.js";
17
18
  import os from "node:os";
19
+ import { execFileSync } from "node:child_process";
18
20
  import path from "node:path";
19
21
  import YAML from "yaml";
20
22
  import * as MASTER from "./master.js";
21
23
  import { unpackDeck } from "./package.js";
22
24
  import { writeDeckFile } from "./deck-file.js";
25
+ import { loadPreferences } from "./preferences.js";
23
26
 
24
- export const TRASH_DAYS = 30;
27
+ export const TRASH_DAYS = 30; // padrão; cada máquina escolhe em Preferências › Biblioteca (lixeiraDias)
25
28
  const TRASH = ".lixeira";
26
29
  const TOPIC_META = ".topico.json";
27
30
  const COLORS = ["#d33a2c", "#0f6cbd", "#e5a50a", "#8b5cf6", "#0e9f6e", "#e8590c", "#d6336c", "#495057"];
28
31
 
29
- export function defaultLibraryRoot(env = process.env) {
30
- return path.resolve(env.SAGADECK_HOME || path.join(os.homedir(), "sagadeck"));
32
+ // Pasta Documentos do Windows como o Explorer a mostra: a pasta conhecida "Personal" do registro, que pode estar
33
+ // redirecionada (OneDrive\Documents, outro disco, "Documentos"). Sem ela, %USERPROFILE%\Documents. Fora do Windows, null.
34
+ let documentsCache;
35
+ export function documentsDir({ platform = process.platform, home = os.homedir() } = {}) {
36
+ if (platform !== "win32") return null;
37
+ if (documentsCache !== undefined && home === os.homedir()) return documentsCache;
38
+ let found = null;
39
+ try {
40
+ const out = execFileSync("reg", ["query", "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\Shell Folders", "/v", "Personal"], { encoding: "utf8", windowsHide: true, timeout: 5000 });
41
+ found = out.match(/Personal\s+REG_\w+\s+(.+)/)?.[1]?.trim() || null;
42
+ } catch { /* sem registro: o padrão abaixo */ }
43
+ const dir = found && path.isAbsolute(found) ? found : path.join(home, "Documents");
44
+ if (home === os.homedir()) documentsCache = dir;
45
+ return dir;
46
+ }
47
+
48
+ // A biblioteca: SAGADECK_HOME, senão <Documentos>\sagadeck no Windows e ~/sagadeck fora dele. Enquanto a biblioteca
49
+ // antiga (~/sagadeck, o padrão até a 1.4) não foi movida para Documentos, ela continua valendo: nunca abre vazia.
50
+ export function defaultLibraryRoot(env = process.env, { platform = process.platform, home = os.homedir(), documents } = {}) {
51
+ if (env.SAGADECK_HOME) return path.resolve(env.SAGADECK_HOME);
52
+ const legacy = path.join(home, "sagadeck");
53
+ const docs = documents !== undefined ? documents : documentsDir({ platform, home });
54
+ if (!docs) return path.resolve(legacy);
55
+ const target = path.join(docs, "sagadeck");
56
+ if (!fs.existsSync(target) && hasContent(legacy) && !isLink(legacy)) return path.resolve(legacy);
57
+ return path.resolve(target);
58
+ }
59
+
60
+ const isLink = (p) => { try { return fs.lstatSync(p).isSymbolicLink(); } catch { return false; } };
61
+ const hasContent = (p) => { try { return fs.readdirSync(p).length > 0; } catch { return false; } };
62
+
63
+ // Leva a biblioteca antiga (~/sagadeck) para <Documentos>\sagadeck, uma vez: move a pasta inteira (mesma unidade:
64
+ // instantâneo) e deixa no lugar antigo uma junção para a nova, para atalhos e configurações antigas continuarem
65
+ // valendo. Não mexe se SAGADECK_HOME manda, se a nova já tem conteúdo (não mistura bibliotecas) ou se a antiga já
66
+ // é a junção. Falhou (arquivo aberto, outra unidade): a antiga continua sendo a biblioteca e tenta de novo depois.
67
+ export function migrateLegacyLibrary(env = process.env, { platform = process.platform, home = os.homedir(), documents } = {}) {
68
+ if (env.SAGADECK_HOME) return { skipped: "SAGADECK_HOME" };
69
+ const docs = documents !== undefined ? documents : documentsDir({ platform, home });
70
+ if (!docs) return { skipped: "fora do Windows" };
71
+ const legacy = path.join(home, "sagadeck"), target = path.join(docs, "sagadeck");
72
+ if (path.resolve(legacy) === path.resolve(target) || isLink(legacy) || !hasContent(legacy)) return { skipped: "nada a mover" };
73
+ if (hasContent(target)) return { skipped: "as duas têm conteúdo", legacy, target };
74
+ try {
75
+ fs.mkdirSync(docs, { recursive: true });
76
+ if (fs.existsSync(target)) fs.rmdirSync(target); // pasta vazia criada antes
77
+ renameRetry(legacy, target);
78
+ } catch (e) { return { error: e.message, legacy, target }; }
79
+ try { fs.symlinkSync(target, legacy, "junction"); } catch { /* sem a junção, a nova pasta já é a biblioteca */ }
80
+ return { moved: true, legacy, target };
31
81
  }
32
82
 
33
83
  // Onde uma apresentação NOVA é gravada: sempre na biblioteca. Um caminho dentro dela é respeitado; qualquer
@@ -65,8 +115,11 @@ const isYaml = (name) => /\.ya?ml$/i.test(name) && !hidden(name);
65
115
 
66
116
  // nome de pasta/arquivo válido no Windows (e no Linux): sem <>:"/\|?*, sem ponto/espaço no fim
67
117
  export function safeName(s, fallback = "Sem título") {
68
- const clean = String(s || "").normalize("NFC").replace(/[<>:"/\\|?*\u0000-\u001f]/g, " ").replace(/\s+/g, " ").trim()
69
- .replace(/[. ]+$/, "").slice(0, 80).trim();
118
+ let clean = String(s || "").normalize("NFC").replace(/[<>:"/\\|?*\u0000-\u001f]/g, " ").replace(/\s+/g, " ").trim()
119
+ .replace(/[. ]+$/, "");
120
+ // nome longo corta no último espaço antes de 80 (a pasta saía "…and Urban Flood Predic")
121
+ if (clean.length > 80) { const cut = clean.slice(0, 81).lastIndexOf(" "); clean = clean.slice(0, cut >= 40 ? cut : 80); }
122
+ clean = clean.replace(/[\s.,;:—–-]+$/, "").trim();
70
123
  return /^(con|prn|aux|nul|com\d|lpt\d)$/i.test(clean) || !clean ? fallback : clean;
71
124
  }
72
125
 
@@ -76,7 +129,9 @@ function uniquePath(dir, name, ext = "") {
76
129
  return p;
77
130
  }
78
131
 
79
- export function openLibrary(root) {
132
+ // trashDays: quantos dias a lixeira guarda (padrão: o das Preferências desta máquina, lido a cada limpeza)
133
+ export function openLibrary(root, { trashDays } = {}) {
134
+ const keepDays = () => trashDays ?? loadPreferences().biblioteca?.lixeiraDias ?? TRASH_DAYS;
80
135
  root = path.resolve(root);
81
136
  fs.mkdirSync(root, { recursive: true });
82
137
 
@@ -139,7 +194,7 @@ export function openLibrary(root) {
139
194
  topics.push({ id: e.name, name: e.name, color: meta.color || COLORS[topics.length % COLORS.length], count: mine.length, created: meta.created || 0 });
140
195
  }
141
196
  topics.sort((a, b) => a.name.localeCompare(b.name, "pt-BR"));
142
- return { root, topics, decks, trash: listTrash() };
197
+ return { root, topics, decks, trash: listTrash(), trashDays: keepDays() };
143
198
  }
144
199
 
145
200
  // ---- tópicos ----
@@ -164,7 +219,7 @@ export function openLibrary(root) {
164
219
  if (color) fs.writeFileSync(path.join(dir, TOPIC_META), JSON.stringify({ ...topicMeta(dir), color }));
165
220
  if (name && safeName(name) !== path.basename(dir)) {
166
221
  const to = uniquePath(root, safeName(name));
167
- fs.renameSync(dir, to);
222
+ renameRetry(dir, to);
168
223
  dir = to;
169
224
  }
170
225
  return path.basename(dir);
@@ -208,7 +263,7 @@ export function openLibrary(root) {
208
263
  if (path.dirname(unit) === dest) return id;
209
264
  const ext = unit === file ? path.extname(file) : "";
210
265
  const to = uniquePath(dest, path.basename(unit, ext), ext);
211
- fs.renameSync(unit, to);
266
+ renameRetry(unit, to);
212
267
  return idOf(unit === file ? to : path.join(to, path.basename(file)));
213
268
  }
214
269
 
@@ -220,12 +275,12 @@ export function openLibrary(root) {
220
275
  if (unit === file) return id; // .yaml solto: só o título
221
276
  const to = uniquePath(path.dirname(unit), safeName(title));
222
277
  if (path.basename(to) === path.basename(unit)) return id;
223
- fs.renameSync(unit, to);
278
+ renameRetry(unit, to);
224
279
  // o .yaml com o nome da pasta acompanha a pasta
225
280
  let yaml = path.join(to, path.basename(file));
226
281
  if (path.basename(file).replace(/\.ya?ml$/i, "") === path.basename(unit)) {
227
282
  const renamed = path.join(to, path.basename(to) + path.extname(file));
228
- fs.renameSync(yaml, renamed);
283
+ renameRetry(yaml, renamed);
229
284
  yaml = renamed;
230
285
  }
231
286
  return idOf(yaml);
@@ -240,7 +295,7 @@ export function openLibrary(root) {
240
295
  let yaml = unit === file ? to : path.join(to, path.basename(file));
241
296
  if (unit !== file && path.basename(file).replace(/\.ya?ml$/i, "") === path.basename(unit)) {
242
297
  const renamed = path.join(to, path.basename(to) + path.extname(file));
243
- fs.renameSync(yaml, renamed);
298
+ renameRetry(yaml, renamed);
244
299
  yaml = renamed;
245
300
  }
246
301
  const spec = YAML.parse(fs.readFileSync(yaml, "utf8")) || {};
@@ -283,9 +338,9 @@ export function openLibrary(root) {
283
338
  if (!Array.isArray(spec.slides) || !spec.slides.length) throw new Error("a geração não produziu slides: nada foi publicado");
284
339
  const dest = uniquePath(base, safeName(title || spec.title || "Nova apresentação"));
285
340
  spec.title = title || spec.title || path.basename(dest);
286
- fs.renameSync(abs, dest);
341
+ renameRetry(abs, dest);
287
342
  const final = path.join(dest, path.basename(dest) + ".yaml");
288
- fs.renameSync(path.join(dest, "deck.yaml"), final);
343
+ renameRetry(path.join(dest, "deck.yaml"), final);
289
344
  writeDeckFile(final, spec); // só o título muda no arquivo
290
345
  return idOf(final);
291
346
  }
@@ -296,7 +351,7 @@ export function openLibrary(root) {
296
351
  fs.mkdirSync(tdir, { recursive: true });
297
352
  const slot = path.join(tdir, `${Date.now()}-${STAGING}`);
298
353
  fs.mkdirSync(slot);
299
- fs.renameSync(abs, path.join(slot, path.basename(abs)));
354
+ renameRetry(abs, path.join(slot, path.basename(abs)));
300
355
  const yaml = path.join(slot, path.basename(abs), "deck.yaml");
301
356
  let slides = 0, title = reason;
302
357
  try { const spec = YAML.parse(fs.readFileSync(yaml, "utf8")) || {}; slides = Array.isArray(spec.slides) ? spec.slides.length : 0; if (spec.title && spec.title !== "Gerando…") title = spec.title; } catch {}
@@ -327,7 +382,7 @@ export function openLibrary(root) {
327
382
  fs.mkdirSync(tdir, { recursive: true });
328
383
  const slot = path.join(tdir, `${Date.now()}-${path.basename(unit)}`);
329
384
  fs.mkdirSync(slot);
330
- fs.renameSync(unit, path.join(slot, path.basename(unit)));
385
+ renameRetry(unit, path.join(slot, path.basename(unit)));
331
386
  fs.writeFileSync(path.join(slot, ".origem.json"), JSON.stringify({
332
387
  topic: info.topic, title: info.title, deleted: Date.now(), yaml: path.relative(unit === file ? path.dirname(file) : unit, file),
333
388
  unit: path.basename(unit), slides: info.slides,
@@ -348,7 +403,7 @@ export function openLibrary(root) {
348
403
  const isFile = /\.ya?ml$/i.test(o.unit);
349
404
  const ext = isFile ? path.extname(o.unit) : "";
350
405
  const to = uniquePath(dest, path.basename(o.unit, ext), ext);
351
- fs.renameSync(path.join(slot, o.unit), to);
406
+ renameRetry(path.join(slot, o.unit), to);
352
407
  fs.rmSync(slot, { recursive: true, force: true });
353
408
  return idOf(isFile ? to : path.join(to, o.yaml));
354
409
  }
@@ -362,7 +417,8 @@ export function openLibrary(root) {
362
417
  return all.length;
363
418
  }
364
419
  function purgeOld(now = Date.now()) {
365
- for (const t of listTrash()) if (now - t.deleted > TRASH_DAYS * 864e5) purgeDeck(t.id);
420
+ const days = keepDays();
421
+ for (const t of listTrash()) if (now - t.deleted > days * 864e5) purgeDeck(t.id);
366
422
  }
367
423
 
368
424
  // ---- importar .sagadeck / .zip ----
package/src/locale.js ADDED
@@ -0,0 +1,18 @@
1
+ // Idioma dos números do deck (`lang`, padrão pt-BR): contador, gráficos e mapas formatavam sempre em português, e um
2
+ // deck em inglês mostrava "1.403" (mil quatrocentos e três) e "0,93". A renderização é síncrona, slide a slide: quem
3
+ // monta o deck (build.js) define o idioma antes e os desenhos leem daqui.
4
+ let current = "pt-BR";
5
+
6
+ export function normalizeLocale(lang) {
7
+ const l = String(lang || "").trim();
8
+ if (!l) return "pt-BR";
9
+ if (/^pt/i.test(l)) return "pt-BR";
10
+ if (/^en/i.test(l)) return /^en-GB/i.test(l) ? "en-GB" : "en-US";
11
+ try { return Intl.getCanonicalLocales(l)[0]; } catch { return "pt-BR"; }
12
+ }
13
+
14
+ export function setDeckLocale(lang) { current = normalizeLocale(lang); return current; }
15
+ export const deckLocale = () => current;
16
+ export const formatNumber = (v, opts) => Number(v).toLocaleString(current, opts);
17
+ // separador decimal do idioma atual ("," em pt-BR, "." em en-US)
18
+ export const decimalSeparator = (locale = current) => (1.5).toLocaleString(locale).replace(/\d/g, "")[0] || ",";
package/src/mcp/server.js CHANGED
@@ -82,7 +82,7 @@ const TOOLS = [
82
82
  inputSchema: {
83
83
  type: "object",
84
84
  properties: {
85
- path: { type: "string", description: "Nome da apresentação (ou caminho dentro da biblioteca SAGADECK_HOME/~/sagadeck). Toda apresentação é gravada na biblioteca: caminho de fora vira só o nome, e nada é sobrescrito" },
85
+ path: { type: "string", description: "Nome da apresentação (ou caminho dentro da biblioteca SAGADECK_HOME ou Documentos/sagadeck). Toda apresentação é gravada na biblioteca: caminho de fora vira só o nome, e nada é sobrescrito" },
86
86
  title: { type: "string", description: "Título provisório da apresentação" },
87
87
  theme: {
88
88
  type: "string",
@@ -105,7 +105,7 @@ const TOOLS = [
105
105
  inputSchema: {
106
106
  type: "object",
107
107
  properties: {
108
- path: { type: "string", description: "Nome da apresentação (ou caminho dentro da biblioteca SAGADECK_HOME/~/sagadeck). Toda apresentação é gravada na biblioteca: caminho de fora vira só o nome, e nada é sobrescrito" },
108
+ path: { type: "string", description: "Nome da apresentação (ou caminho dentro da biblioteca SAGADECK_HOME ou Documentos/sagadeck). Toda apresentação é gravada na biblioteca: caminho de fora vira só o nome, e nada é sobrescrito" },
109
109
  title: { type: "string", description: "Título da apresentação" },
110
110
  theme: {
111
111
  type: "string",
package/src/motion.js CHANGED
@@ -22,11 +22,11 @@ function drawTrace(ctx,o,t,w,h){
22
22
  ctx.restore();
23
23
  }
24
24
  function scene(o){
25
- let active=o.type!=='chat',started=false,epoch=performance.now(),serial=0,phase='idle',elapsed=0,previous=0;let reduced=matchMedia('(prefers-reduced-motion:reduce)').matches;const main=document.querySelector('main'),canvas=document.querySelector('canvas'),ctx=canvas.getContext('2d');
25
+ let active=o.type!=='chat',started=false,epoch=performance.now(),serial=0,phase='idle',elapsed=0,previous=0;const reduceQuery=matchMedia('(prefers-reduced-motion:reduce)');let still=false,reduced=!!o.still||reduceQuery.matches;const recompute=()=>{reduced=!!o.still||still||reduceQuery.matches;};reduceQuery.addEventListener?.('change',recompute);const main=document.querySelector('main'),canvas=document.querySelector('canvas'),ctx=canvas.getContext('2d');
26
26
  const message=(kind)=>parent.postMessage({sagaMotion:kind},'*');
27
27
  const chat=document.querySelector('.chat'),input=document.querySelector('.input'),thread=document.querySelector('.thread'),status=document.querySelector('.status');
28
28
  function reset(){started=false;phase='idle';elapsed=0;chat.hidden=true;input.textContent='';thread.replaceChildren();status.textContent='';}
29
- message('ready');window.addEventListener('message',e=>{if(e.source!==parent)return;if(e.data?.sagaMotion==='poster'){reduced=true;if(o.type==='terminal')document.querySelector('.feed').textContent=o.lines.join('\n');}if(e.data?.sagaMotion==='active')active=!!e.data.active;if(e.data?.sagaMotion==='reset')reset();if(e.data?.sagaMotion==='start'){reset();started=true;active=true;phase='recognize';chat.hidden=false;}});
29
+ window.__sagaMotion=()=>({active,reduced,started,serial,phase});message('ready');window.addEventListener('message',e=>{if(e.source!==parent)return;if(e.data?.sagaMotion==='poster'){still=e.data.active!==false;recompute();if(reduced&&o.type==='terminal')document.querySelector('.feed').textContent=o.lines.join('\n');}if(e.data?.sagaMotion==='active')active=!!e.data.active;if(e.data?.sagaMotion==='reset')reset();if(e.data?.sagaMotion==='start'){reset();started=true;active=true;phase='recognize';chat.hidden=false;}});
30
30
  if(o.type==='chat'&&o.manual){const request=e=>{if(!active)return;if(e.type==='keydown'&&![' ','Enter','ArrowRight','PageDown'].includes(e.key))return;e.preventDefault();message('start-request');};document.addEventListener('click',request);document.addEventListener('keydown',request);}
31
31
  if(o.type==='chat'&&!o.manual){started=true;phase='recognize';chat.hidden=false;}
32
32
  function bubble(text,kind){const el=document.createElement('div');el.className='bubble '+kind;el.textContent=text;thread.append(el);return el;}
@@ -51,6 +51,8 @@ function scene(o){
51
51
  }
52
52
  requestAnimationFrame(draw);
53
53
  }
54
- export function motionHTML(value,colors){const o=motionOptions(value,colors);const css=`html,body{height:100%;background:${o.type==='chat'?'transparent':o.background};color:${o.foreground};overflow:hidden}main{height:100%;position:relative}canvas{position:absolute;inset:0;width:100%;height:100%}.caption{position:absolute;top:12px;left:16px;color:${o.secondary};font:11px monospace;letter-spacing:1.5px}.feed{position:absolute;top:36px;left:16px;right:16px;bottom:12px;overflow:hidden;font:${o.type==='terminal'?o.fontSize:12}px/1.5 monospace;white-space:pre}.chat{position:absolute;inset:0;border:1px solid ${o.secondary};border-radius:16px;padding:18px;background:${o.background};display:flex;flex-direction:column;gap:12px}.chat[hidden]{display:none}.thread{flex:1;overflow:auto;display:flex;flex-direction:column;gap:12px}.bubble{font:18px/1.45 system-ui;max-width:90%;padding:10px 14px;border-radius:10px;white-space:pre-wrap}.user{align-self:flex-end;background:#263a51}.assistant{align-self:flex-start}.status{font:14px system-ui;color:${o.secondary};min-height:18px}.input{border:1px solid #51617b;border-radius:9px;min-height:48px;padding:12px;font:17px system-ui;white-space:pre-wrap}.chat-title{font:12px system-ui;letter-spacing:2px;color:${o.secondary}}`;
54
+ // still: o deck pediu movimento nenhum (motion: none). A cena já nasce parada e completa; antes ela só sabia pela
55
+ // mensagem da página, que sob carga chegava depois de o terminal começar a digitar.
56
+ export function motionHTML(value,colors,{still=false}={}){const o={...motionOptions(value,colors),...(still?{still:true}:{})};const css=`html,body{height:100%;background:${o.type==='chat'?'transparent':o.background};color:${o.foreground};overflow:hidden}main{height:100%;position:relative}canvas{position:absolute;inset:0;width:100%;height:100%}.caption{position:absolute;top:12px;left:16px;color:${o.secondary};font:11px monospace;letter-spacing:1.5px}.feed{position:absolute;top:36px;left:16px;right:16px;bottom:12px;overflow:hidden;font:${o.type==='terminal'?o.fontSize:12}px/1.5 monospace;white-space:pre}.chat{position:absolute;inset:0;border:1px solid ${o.secondary};border-radius:16px;padding:18px;background:${o.background};display:flex;flex-direction:column;gap:12px}.chat[hidden]{display:none}.thread{flex:1;overflow:auto;display:flex;flex-direction:column;gap:12px}.bubble{font:18px/1.45 system-ui;max-width:90%;padding:10px 14px;border-radius:10px;white-space:pre-wrap}.user{align-self:flex-end;background:#263a51}.assistant{align-self:flex-start}.status{font:14px system-ui;color:${o.secondary};min-height:18px}.input{border:1px solid #51617b;border-radius:9px;min-height:48px;padding:12px;font:17px system-ui;white-space:pre-wrap}.chat-title{font:12px system-ui;letter-spacing:2px;color:${o.secondary}}`;
55
57
  const html=`<main><canvas aria-hidden="true"></canvas><div class="caption">${esc(o.type==='chat'?'':o.caption)}</div><div class="feed" aria-hidden="true"></div><section class="chat" hidden><div class="chat-title">${esc(o.caption||'ASSISTENTE')}</div><div class="thread" aria-live="polite"></div><div class="status"></div><div class="input" aria-label="Mensagem digitada"></div></section></main>`;
56
58
  const doc=playgroundDocument({html,css,javascript:`${drawHologram.toString()}\n${drawTrace.toString()}\n(${scene.toString()})(${JSON.stringify(o)});`});return `<iframe data-motion="${o.type}" data-motion-manual="${o.manual}" data-motion-finish="${o.finish}" title="${esc(o.caption||'Detalhe animado')}" sandbox="allow-scripts" style="width:100%;height:100%;border:0" srcdoc="${esc(doc)}"></iframe>`;}
@@ -1,6 +1,7 @@
1
1
  // Preferências do sagadeck nesta máquina: ~/.sagadeck/preferencias.json (ou SAGADECK_PREFERENCIAS), ao lado do
2
2
  // ambientes.yaml e do identidades.yaml. Valem para o Studio inteiro; o deck (fit:, author…) vence quando define.
3
3
  import fs from "node:fs";
4
+ import { renameRetry } from "./fs-retry.js";
4
5
  import os from "node:os";
5
6
  import path from "node:path";
6
7
 
@@ -21,6 +22,9 @@ export const PREF_SCHEMA = {
21
22
  exportacao: {
22
23
  pdfClaro: { def: false }, // PDF e impressão na versão clara do tema (quando o tema tem par claro/escuro)
23
24
  },
25
+ biblioteca: {
26
+ lixeiraDias: { def: 30, min: 1, max: 365 }, // excluída fica restaurável por tantos dias, depois é apagada de vez
27
+ },
24
28
  };
25
29
 
26
30
  export function preferencesFile(env = process.env, home = os.homedir()) {
@@ -56,6 +60,6 @@ export function savePreferences(patch, file = preferencesFile()) {
56
60
  fs.mkdirSync(path.dirname(file), { recursive: true });
57
61
  const tmp = `${file}.tmp-${process.pid}`;
58
62
  fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + "\n", "utf8");
59
- fs.renameSync(tmp, file);
63
+ renameRetry(tmp, file);
60
64
  return out;
61
65
  }
@@ -11,7 +11,7 @@
11
11
  // (fontes.json, notas.md e o texto de cada fonte). Sem internet (a rede do banco): avisa e segue sem inventar.
12
12
  import fs from "node:fs";
13
13
  import path from "node:path";
14
- import { chat } from "../ai/llm.js";
14
+ import { chat, llmConfig } from "../ai/llm.js";
15
15
  import { fetchUrlDoc } from "../ai/context.js";
16
16
  import { searchDuckDuckGo } from "./duckduckgo.js";
17
17
  import { searchWikipedia } from "./wikipedia.js";
@@ -66,11 +66,12 @@ export async function arxivSearch(q, { limit = 5 } = {}) {
66
66
  return { title: tag("title"), url: abs, pdf: abs.replace("/abs/", "/pdf/"), snippet: clip(tag("summary"), 300), date: tag("published").slice(0, 10), arxiv: true };
67
67
  });
68
68
  }
69
- // Busca pelo modelo "search" do modelrelay (no OpenRouter, um modelo com ":online": a busca na web dele, por API
70
- // oficial). O DuckDuckGo direto passou a responder com desafio anti-robô; ele fica como segunda tentativa. Sem o
71
- // apelido "search" no modelrelay (a rede do banco), não há buscador: a pesquisa avisa e segue sem inventar.
69
+ // Busca pelo modelo de busca configurado (Configurar IA › busca; no OpenRouter, um modelo com ":online": a busca na
70
+ // web dele, por API oficial). O DuckDuckGo direto passou a responder com desafio anti-robô; ele fica como segunda
71
+ // tentativa. Sem modelo de busca (a rede do banco), não há buscador: a pesquisa avisa e segue sem inventar.
72
72
  export async function llmSearch(q, { limit = 8 } = {}) {
73
- const model = process.env.SAGADECK_SEARCH_MODEL || "search";
73
+ const model = llmConfig().searchModel;
74
+ if (!model) throw new Error("sem modelo de busca configurado");
74
75
  const r = await chat([{ role: "user", content: `Hoje é ${today()}. Pesquise na web e liste as ${limit} melhores fontes para: ${q}\nPrefira fontes primárias (site oficial, documentação, artigo, imprensa séria); para humor, memes e opiniões sobre cultura, blogs e fóruns também podem ser pertinentes. Para cada uma, o título, a URL completa e exata (a página, não o site), uma frase do que ela traz e a data, se houver.\nResponda só JSON: [{"title": "…", "url": "https://…", "snippet": "…", "date": "…"}]` }], { model, maxTokens: 3000, temperature: 0, reasoningOff: true });
75
76
  const t = r.text || "", m = t.match(/```(?:json)?\s*([\s\S]*?)```/), raw = m ? m[1] : t.slice(t.indexOf("["), t.lastIndexOf("]") + 1);
76
77
  return (JSON.parse(raw) || []).filter((x) => /^https?:\/\//.test(x?.url || "")).slice(0, limit).map((x) => ({ title: String(x.title || x.url), url: String(x.url), snippet: String(x.snippet || ""), date: String(x.date || "") }));
@@ -6,10 +6,10 @@
6
6
  if (v == null || !Number.isFinite(v)) return "—";
7
7
  if (o.sci || Math.abs(v) >= 1e7 || (Math.abs(v) < 1e-3 && v !== 0)) {
8
8
  const [m, e] = v.toExponential(2).split("e");
9
- return `${m.replace(".", ",")} × 10${String(Number(e)).split("").map((c) => SUP[c] ?? c).join("")}`;
9
+ return `${(1.5).toLocaleString((typeof document !== "undefined" && document.documentElement.lang) || "pt-BR").includes(",") ? m.replace(".", ",") : m} × 10${String(Number(e)).split("").map((c) => SUP[c] ?? c).join("")}`;
10
10
  }
11
- if (o.decimals != null) return v.toLocaleString("pt-BR", { minimumFractionDigits: o.decimals, maximumFractionDigits: o.decimals });
12
- return v.toLocaleString("pt-BR", { maximumSignificantDigits: Math.abs(v) >= 1000 ? 7 : 4 });
11
+ if (o.decimals != null) return v.toLocaleString((typeof document !== "undefined" && document.documentElement.lang) || "pt-BR", { minimumFractionDigits: o.decimals, maximumFractionDigits: o.decimals });
12
+ return v.toLocaleString((typeof document !== "undefined" && document.documentElement.lang) || "pt-BR", { maximumSignificantDigits: Math.abs(v) >= 1000 ? 7 : 4 });
13
13
  }
14
14
  // todas as saídas na ordem (uma saída usa as anteriores); `cases` escolhe texto/cor/fórmula pela faixa de `of`
15
15
  function evaluate(model, values = {}) {
@@ -17,7 +17,7 @@
17
17
  return { ...s, automatic, caught, introduced, reviewed: automatic - caught + introduced,
18
18
  hours: s.volume * r * s.seconds / 3600, delta: introduced - caught };
19
19
  }
20
- const fmt = n => n.toLocaleString('pt-BR', { maximumFractionDigits: 1 });
20
+ const fmt = n => n.toLocaleString((typeof document !== "undefined" && document.documentElement.lang) || "pt-BR", { maximumFractionDigits: 1 });
21
21
  function mount(container = document) {
22
22
  container.querySelectorAll('[data-decision-lab]').forEach(el => {
23
23
  if (el.dataset.mounted) return;
@@ -10,6 +10,10 @@ for(const frame of document.querySelectorAll('iframe[data-motion]')){
10
10
  document.addEventListener('keydown',begin,true);slide.addEventListener('click',begin,true);
11
11
  }
12
12
  window.addEventListener('message',e=>{if(e.source!==frame.contentWindow)return;if(e.data?.sagaMotion==='done')finish();if(e.data?.sagaMotion==='ready')activate();if(e.data?.sagaMotion==='start-request'&&slide?.classList.contains('current')&&!done){if(started)finish();else{started=true;send('start');}}});
13
- const activate=()=>{const active=slide?.classList.contains('current');send('active',active);if(document.documentElement.classList.contains('export')||document.documentElement.dataset.motion==='none'||matchMedia('(prefers-reduced-motion: reduce)').matches)send('poster');if(!active){started=false;done=false;frame.style.visibility='';send('reset');}};
13
+ const activate=()=>{const active=slide?.classList.contains('current');send('active',active);send('poster',document.documentElement.classList.contains('export')||document.documentElement.dataset.motion==='none'||matchMedia('(prefers-reduced-motion: reduce)').matches);if(!active){started=false;done=false;frame.style.visibility='';send('reset');}};
14
14
  frame.addEventListener('load',activate);if(slide)new MutationObserver(activate).observe(slide,{attributes:true,attributeFilter:['class']});
15
+ // a cena pode ter carregado e dito 'ready' antes deste script escutar: fala com ela já (senão ela não sabe do slide
16
+ // atual nem do movimento reduzido e anima sem parar)
17
+ activate();
18
+ matchMedia('(prefers-reduced-motion: reduce)').addEventListener?.('change',activate);
15
19
  }
@@ -65,7 +65,8 @@
65
65
  }
66
66
 
67
67
  // ---------- contador ----------
68
- function fmtNum(v, dec) { return v.toLocaleString("pt-BR", { minimumFractionDigits: dec, maximumFractionDigits: dec }); }
68
+ // no idioma do deck (<html lang>, que vem do lang do deck): em inglês "1,403" e "0.93"
69
+ function fmtNum(v, dec) { return v.toLocaleString((typeof document !== "undefined" && document.documentElement.lang) || "pt-BR", { minimumFractionDigits: dec, maximumFractionDigits: dec }); }
69
70
  function runCounter(c, instant) {
70
71
  const to = +c.dataset.to, from = +c.dataset.from, dec = +c.dataset.dec, cv = $(".cv", c);
71
72
  const pre = c.dataset.prefix, suf = c.dataset.suffix;
@@ -11,11 +11,31 @@ import { reviewExperience } from "../ai/quality.js";
11
11
  import { maybeResearch } from "../research/chat-research.js";
12
12
  import { chatErrorResult } from "./errors.js";
13
13
  import { cancellable, respond } from "./ai-response.js";
14
- import { prepareDocumentMaterials, takeMaterials } from "../ai/document-materials.js";
14
+ import { prepareDocumentMaterials, takeMaterials, storedDocumentMaterials } from "../ai/document-materials.js";
15
+ import { critiqueMaterials, critiqueMarkdown, saveCritique, loadCritique, critiqueMaterial, markCritiqueItem } from "../ai/critique.js";
15
16
  import { styleAction } from "./style-routes.js";
16
17
  import * as Project from "./project.js";
17
18
  import { carryVisualEdits } from "./visual-keys.js";
18
19
 
20
+ // Arquivos que a IA pode pedir para ver (ver: […]): imagens DENTRO da pasta da apresentação (os recortes e as
21
+ // páginas do material ficam em contexto/visuais/). Caminho que sai da pasta, que não existe ou que não é imagem fica
22
+ // de fora; no máximo 6.
23
+ export function lookableFiles(deckDir, wanted = []) {
24
+ if (!deckDir) return [];
25
+ const root = path.resolve(deckDir);
26
+ const out = [];
27
+ for (const w of wanted) {
28
+ const rel = String(w || "").replace(/\\/g, "/").replace(/^\.\//, "").trim();
29
+ if (!rel || !/\.(png|jpe?g|webp|gif)$/i.test(rel)) continue;
30
+ const abs = path.resolve(root, rel);
31
+ if (abs !== root && !abs.startsWith(root + path.sep)) continue;
32
+ if (!fs.existsSync(abs) || !fs.statSync(abs).isFile()) continue;
33
+ if (!out.some((o) => o.abs === abs)) out.push({ rel, abs });
34
+ if (out.length >= 6) break;
35
+ }
36
+ return out;
37
+ }
38
+
19
39
  export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, staleTab, llmAvailable, llmConfig, lookAt, withBase, apiBlocked, ensureEnsaio, readPastedLinks, imageOptions, apiContextFor, commandRunner, diagramCheck, slideSnapshots, persist, isBundledTemplate, runTransform, transforms, apiEnv }) {
20
40
  if (pathname === "/api/ai/chat/cancel" && req.method === "POST") {
21
41
  const body = await readJSON(req);
@@ -25,6 +45,52 @@ export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, stale
25
45
  res.end(JSON.stringify({ ok: true, stopped: !!job }));
26
46
  return true;
27
47
  }
48
+ // Leitura crítica do material da apresentação aberta (botão do chat): src/ai/critique.js. Nada muda nos slides; o
49
+ // resultado fica em .sagadeck/leitura-critica.json (acompanha os próximos pedidos ao chat) e volta como conversa.
50
+ if (pathname === "/api/ai/critique" && req.method === "POST") {
51
+ const body = await readJSON(req);
52
+ if (staleTab(body, W, res)) return true;
53
+ const deckDir = W.file && !isBundledTemplate(W.file) ? path.dirname(W.file) : null;
54
+ const spec = body.spec || W.spec;
55
+ await respond(res, body.stream, async (emit) => {
56
+ const P = deckDir ? Project.projectOf(W.file) : null;
57
+ let materials = deckDir ? storedDocumentMaterials(deckDir) : [];
58
+ if (!materials.length && P) materials = (await Project.contextMaterials(P)).filter((m) => String(m.text || "").length > 1500);
59
+ if (!materials.length) return { talk: true, reply: "Esta apresentação não tem material anexado para ler criticamente. Anexe o documento (PDF, Word) no chat e peça de novo.", options: [] };
60
+ if (!(await llmAvailable({ force: true }))) return { talk: true, reply: `A IA está desligada (nenhum LLM respondendo em ${llmConfig().url}).`, options: [] };
61
+ emit({ phase: "step", text: `Lendo ${materials.map((m) => m.name).join(", ")} com olho crítico…` });
62
+ const c = await critiqueMaterials(materials, { spec, briefing: spec?.context ? JSON.stringify(spec.context) : "" });
63
+ saveCritique(deckDir, c);
64
+ const autor = spec?.context?.autoria !== "livre";
65
+ const options = c.itens.length
66
+ ? (autor
67
+ ? ["Leve para os slides os achados que estão no material", "Ponha as perguntas prováveis e as inconsistências nas notes", "Corrija os slides apontados", "Só queria ler, obrigado"]
68
+ : ["Transforme os achados em conteúdo, marcando o que é do sagadeck", "Crie um slide de discussão crítica", "Corrija os slides apontados", "Só queria ler, obrigado"])
69
+ : [];
70
+ const head = c.itens.length
71
+ ? `Li ${c.material.join(", ")} como um revisor da área. ${c.itens.length} ponto(s), cada um com o trecho do material que o sustenta (conferido no texto). ${autor ? "Como a apresentação é do próprio autor, nada disso entra nos slides sem você pedir: o que é achado do material pode ir para os slides; crítica e perguntas, para as notes." : "Como a apresentação é livre, posso transformar isso em conteúdo, marcando o que é do sagadeck."}`
72
+ : "Não achei nada com trecho conferido no material para apontar.";
73
+ // os pontos vão também estruturados: o chat mostra cada um como cartão, ligado ao slide, com Aplicar e Ignorar
74
+ return { talk: true, reply: `${head}\n\n${critiqueMarkdown(c)}`, critique: { itens: c.itens, autor, summary: `${head}${c.naoConfirmados.length ? `
75
+
76
+ _${c.naoConfirmados.length} observação(ões) descartada(s): o trecho citado não foi encontrado no material._` : ""}` }, options, actions: [`Leitura crítica: ${c.itens.length} ponto(s) conferido(s)${c.naoConfirmados.length ? `, ${c.naoConfirmados.length} descartado(s) sem trecho no material` : ""}`] };
77
+ });
78
+ return true;
79
+ }
80
+ // cartão da leitura crítica: a pessoa aplicou ou ignorou o ponto (ignorado deixa de acompanhar os pedidos)
81
+ if (pathname === "/api/ai/critique/item" && req.method === "POST") {
82
+ const body = await readJSON(req);
83
+ const deckDir = W.file && !isBundledTemplate(W.file) ? path.dirname(W.file) : null;
84
+ try {
85
+ const item = markCritiqueItem(deckDir, String(body.id || ""), String(body.status || ""));
86
+ res.writeHead(200, { "Content-Type": "application/json" });
87
+ res.end(JSON.stringify({ ok: true, item }));
88
+ } catch (e) {
89
+ res.writeHead(400, { "Content-Type": "application/json" });
90
+ res.end(JSON.stringify({ error: e.message }));
91
+ }
92
+ return true;
93
+ }
28
94
  if (pathname === "/api/ai/chat" && req.method === "POST") {
29
95
  const body = await readJSON(req);
30
96
  if (staleTab(body, W, res)) return true;
@@ -53,7 +119,7 @@ export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, stale
53
119
  // Sem modelo, ninguém decide nada: nem "o que é X?" nem "resuma" viram edição por palavra-chave
54
120
  // (as regras antigas trocavam o layout e enfiavam texto genérico no slide). O deck fica como está.
55
121
  return {
56
- reply: `A IA está desligada (nenhum LLM respondendo em ${llmConfig().url}), então não mexi em nada. Rode "modelrelay serve" ou defina SAGADECK_LLM_URL e mande de novo.`,
122
+ reply: `A IA está desligada (${llmConfig().url ? `nenhum LLM respondendo em ${llmConfig().url}` : "não configurada"}), então não mexi em nada. Configure em Configurar IA (provedor e chave) e mande de novo.`,
57
123
  spec, actions: [], targetSlide: body.targetSlide, talk: true, mode: "off",
58
124
  };
59
125
  }
@@ -69,6 +135,9 @@ export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, stale
69
135
  // arquivos do projeto (contexto/): material para a IA, junto com os anexos da mensagem
70
136
  const P = taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? Project.projectOf(taskWorkspace.file) : null;
71
137
  if (P) for (const doc of await Project.contextMaterials(P)) if (!materials.some((m) => m.name === doc.name || m.name === doc.name.split("/").pop())) materials.push(doc);
138
+ // a leitura crítica já feita (geração ou botão) acompanha o pedido, rotulada como do sagadeck
139
+ const critique = critiqueMaterial(loadCritique(P ? path.dirname(taskWorkspace.file) : null));
140
+ if (critique) materials.push(critique);
72
141
  // links colados na mensagem: o servidor lê sozinho e conta nas ações
73
142
  const linkActions = [];
74
143
  for (const doc of await readPastedLinks(taskWorkspace, prompt, linkActions)) materials.push(doc);
@@ -77,9 +146,10 @@ export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, stale
77
146
  const chatResearch = await maybeResearch({ prompt, materials, saveDir: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? path.dirname(taskWorkspace.file) : null, onProgress: (s) => emit({ phase: "step", text: s }) });
78
147
  materials = chatResearch.materials;
79
148
  if (chatResearch.report?.fontes?.length) linkActions.push(`Pesquisa na web: ${chatResearch.report.fontes.length} fonte(s) (${chatResearch.report.fontes.map((f) => f.site).join(", ")})`);
80
- result = await editDeck({
149
+ const firstInstruction = chatResearch.instruction ? `${chatResearch.instruction}\n${prompt}` : prompt;
150
+ const editOpts = (extra = {}) => ({
81
151
  spec: withBase(taskWorkspace, spec), signal,
82
- instruction: chatResearch.instruction ? `${chatResearch.instruction}\n${prompt}` : prompt,
152
+ instruction: firstInstruction,
83
153
  targetSlide: target,
84
154
  issues,
85
155
  images: true, // a IA decide (regra no prompt: só quando pedirem ou aceitarem)
@@ -94,8 +164,34 @@ export async function aiChatRoutes({ req, res, pathname, url, W, readJSON, stale
94
164
  runCommand: commandRunner(req, emit, body, taskWorkspace, signal),
95
165
  reviewCheck: (deck, indices) => reviewExperience(deck, indices, { snapshot: slideSnapshots, onProgress: emit, signal, briefing:prompt }),
96
166
  styles: taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? { list: taskWorkspace.library.listStyles(), current: taskWorkspace.spec?.style?.name || null } : null,
167
+ ...extra,
97
168
  });
169
+ result = await editDeck(editOpts());
98
170
  signal.throwIfAborted();
171
+ // A IA pediu para VER o material (ver: [recortes, páginas]): do documento ela só recebe o texto e o
172
+ // inventário. O Studio carrega as imagens (só arquivos da pasta desta apresentação) e chama de novo, com elas
173
+ // anexadas; até 2 rodadas. Antes ela respondia "não tenho acesso ao PDF" ou dizia ter visto o que não viu.
174
+ const deckDir = taskWorkspace.file && !isBundledTemplate(taskWorkspace.file) ? path.dirname(taskWorkspace.file) : null;
175
+ const seen = [];
176
+ for (let round = 1; result.look?.length && round <= 2; round++) {
177
+ const files = lookableFiles(deckDir, result.look);
178
+ const missing = result.look.filter((f) => !files.some((x) => x.rel === f.replace(/\\/g, "/").replace(/^\.\//, "")));
179
+ emit({ phase: "step", text: files.length ? `Olhando o material: ${files.map((f) => path.basename(f.rel)).join(", ")}…` : "Os arquivos pedidos não estão na pasta da apresentação." });
180
+ const { imagesAsDataUrls } = await import("../import/crop.js");
181
+ const urls = files.length ? await imagesAsDataUrls(files.map((f) => f.abs), { width: 1600, quality: 0.9 }) : [];
182
+ signal.throwIfAborted();
183
+ files.forEach((f, k) => urls[k] && seen.push({ label: `material: ${f.rel}`, dataUrl: urls[k] }));
184
+ result.actions = [...(result.actions || []), files.length ? `Olhei no material: ${files.map((f) => f.rel).join(", ")}` : "Pedido de ver o material sem arquivo válido"];
185
+ const before = result;
186
+ result = await editDeck(editOpts({
187
+ visuals: [...visuals, ...seen],
188
+ history: [...history, { role: "user", text: prompt }, { role: "assistant", text: before.reply }],
189
+ instruction: `${files.length ? `Aqui estão as imagens do material que você pediu para ver (${files.map((f) => f.rel).join(", ")}), anexadas com o rótulo "material: <arquivo>".` : "Nenhuma das imagens pedidas existe na pasta da apresentação."}${missing.length ? ` Não encontrei: ${missing.join(", ")} (use os caminhos exatos do inventário: recortes em items[].image e páginas inteiras em pages).` : ""} Agora atenda o pedido da pessoa já vendo, sem pedir de novo o que já está aqui:\n"""\n${firstInstruction}\n"""`,
190
+ }));
191
+ signal.throwIfAborted();
192
+ result.actions = [...before.actions, ...(result.actions || [])];
193
+ }
194
+ if (result.look?.length) result = { ...result, look: undefined, talk: true, reply: `${result.reply}\n(Já mostrei as imagens duas vezes; me diga o que procurar nelas.)` };
99
195
  // documento anexado NESTA mensagem e a resposta montou/reescreveu a maior parte do deck (criar a apresentação
100
196
  // do paper pelo chat): a mesma conferência de cobertura da geração. Pedido pontual ("use a figura 3 no slide
101
197
  // 5") não dispara: mexe em poucos slides.
@@ -0,0 +1,18 @@
1
+ // O que o `sagadeck studio` escreve ao subir. A primeira linha é o endereço do Studio, a página que a pessoa abre: antes
2
+ // vinha primeiro o endereço de um serviço de IA à parte, e um agente pedido para "deixar pronto para usar" abria a
3
+ // página dele ou, sem achar o Studio, saía criando uma interface do zero. A IA se configura dentro do Studio.
4
+ import { PROVIDERS } from "../ai/ia-config.js";
5
+
6
+ const providerName = (ai) => Object.values(PROVIDERS).find((p) => p.url === ai.url)?.label || ai.url;
7
+
8
+ export function studioBanner({ version, url, library, ai = null, multiuser = false, usersDir = "", pid = process.pid, isLibrary = true }) {
9
+ const iaLine = !ai || !(ai.url || ai.adaptador)
10
+ ? " IA: não configurada. No Studio, clique em IA desligada (ou Configurar IA na biblioteca) e informe provedor e chave."
11
+ : ` IA: ${providerName(ai)} · texto ${ai.textModel}${ai.source === "variável" ? " (pelas variáveis SAGADECK_LLM_*)" : ""}`;
12
+ return [
13
+ `✓ SagaDeck Studio ${version} pronto. Abra no navegador: ${url}${isLibrary ? " (biblioteca)" : ""}`,
14
+ multiuser ? ` multiusuário: bibliotecas em ${usersDir}` : ` biblioteca: ${library}`,
15
+ iaLine,
16
+ ` processo ${pid}`,
17
+ ];
18
+ }
@@ -0,0 +1,35 @@
1
+ // Impressão do código do sagadeck (caminho, data e tamanho de cada arquivo). O Studio guarda a de quando subiu e a
2
+ // página consulta de tempos em tempos: se o código mudou no disco (git pull, npm update, merge), o servidor que está
3
+ // rodando ainda é o velho e a página avisa "reinicie". Antes, um Studio aberto antes de uma atualização recebia
4
+ // arquivos novos do disco com o servidor velho e quebrava em silêncio (nenhum slide abria).
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import crypto from "node:crypto";
8
+
9
+ const CODE = /\.(m?js|html|css|json)$/i;
10
+
11
+ export function codeFingerprint(root) {
12
+ const h = crypto.createHash("sha1");
13
+ const walk = (dir) => {
14
+ let entries;
15
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
16
+ for (const e of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
17
+ if (e.name === "node_modules" || e.name.startsWith(".")) continue;
18
+ const p = path.join(dir, e.name);
19
+ if (e.isDirectory()) walk(p);
20
+ else if (CODE.test(e.name)) { try { const st = fs.statSync(p); h.update(`${path.relative(root, p)}|${st.mtimeMs}|${st.size}\n`); } catch {} }
21
+ }
22
+ };
23
+ walk(root);
24
+ return h.digest("hex").slice(0, 16);
25
+ }
26
+
27
+ // a que vale para o servidor: a de quando ele subiu × a de agora (recalculada no máximo a cada poucos segundos)
28
+ export function codeWatch(root, { every = 3000 } = {}) {
29
+ const started = codeFingerprint(root);
30
+ let now = started, at = Date.now();
31
+ return () => {
32
+ if (Date.now() - at > every) { now = codeFingerprint(root); at = Date.now(); }
33
+ return { started, now, changed: now !== started };
34
+ };
35
+ }