sagadeck 1.7.1 → 1.9.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 (105) hide show
  1. package/README.md +55 -4
  2. package/SKILL.md +10 -3
  3. package/bin/sagadeck.js +23 -0
  4. package/docs/ANDAMENTO.md +483 -1
  5. package/docs/PLANO-MELHORIAS.md +180 -0
  6. package/docs/REFERENCIA.md +184 -105
  7. package/package.json +34 -2
  8. package/src/ai/commands.js +2 -2
  9. package/src/ai/deck-ai.js +14 -5
  10. package/src/ai/ia-check.js +127 -0
  11. package/src/ai/ia-config.js +2 -2
  12. package/src/ai/llm.js +5 -1
  13. package/src/ai/map-analysis-run.js +119 -0
  14. package/src/ai/map-data.js +221 -0
  15. package/src/ai/quality.js +1 -1
  16. package/src/ai/video-generation.js +70 -25
  17. package/src/build.js +51 -15
  18. package/src/elements.js +39 -1
  19. package/src/export/browser.js +23 -1
  20. package/src/export/pptx.js +9 -3
  21. package/src/export/shots.js +10 -3
  22. package/src/fiscal/normalize.js +19 -0
  23. package/src/fs-retry.js +37 -2
  24. package/src/import/office.js +5 -4
  25. package/src/layouts.js +114 -99
  26. package/src/lessons.js +1 -1
  27. package/src/library.js +7 -7
  28. package/src/map-analysis.js +109 -0
  29. package/src/map-config.js +89 -0
  30. package/src/map-routing.js +82 -0
  31. package/src/map-services.js +60 -0
  32. package/src/map.js +274 -0
  33. package/src/mcp/server.js +17 -0
  34. package/src/motion.js +20 -6
  35. package/src/purpose.js +1 -1
  36. package/src/runtime/base.css +105 -329
  37. package/src/runtime/fit.js +3 -2
  38. package/src/runtime/fonts/amatic-sc.css +3 -0
  39. package/src/runtime/fonts/bebas-neue.css +2 -0
  40. package/src/runtime/fonts/cabin-sketch.css +2 -0
  41. package/src/runtime/fonts/fraunces.css +3 -0
  42. package/src/runtime/fonts/gelasio.css +3 -0
  43. package/src/runtime/fonts/ibm-plex-mono.css +4 -0
  44. package/src/runtime/fonts/index.json +10 -1
  45. package/src/runtime/fonts/itim.css +2 -0
  46. package/src/runtime/fonts/montserrat.css +2 -0
  47. package/src/runtime/fonts/space-grotesk.css +2 -0
  48. package/src/runtime/map-background.js +91 -0
  49. package/src/runtime/map.js +266 -0
  50. package/src/runtime/runtime.js +81 -100
  51. package/src/runtime/skins/caderno.css +33 -0
  52. package/src/runtime/skins/cartaz.css +27 -0
  53. package/src/runtime/skins/infografico.css +29 -0
  54. package/src/runtime/skins/lousa.css +22 -0
  55. package/src/runtime/skins/musgo.css +42 -0
  56. package/src/runtime/vendor/LEAFLET-LICENSE.txt +26 -0
  57. package/src/runtime/vendor/MAPLIBRE-LEAFLET-LICENSE.txt +15 -0
  58. package/src/runtime/vendor/MAPLIBRE-LICENSE.txt +116 -0
  59. package/src/runtime/vendor/geo-contornos.json +1 -0
  60. package/src/runtime/vendor/leaflet.css +661 -0
  61. package/src/runtime/vendor/leaflet.js +6 -0
  62. package/src/runtime/vendor/maplibre-gl.css +1 -0
  63. package/src/runtime/vendor/maplibre-gl.js +825 -0
  64. package/src/runtime/vendor/maplibre-leaflet.js +234 -0
  65. package/src/runtime/vendor/maplibre-worker.js +10 -0
  66. package/src/studio/ai-chat-route.js +20 -0
  67. package/src/studio/assets/imagens/tour-mapa-botao.jpg +0 -0
  68. package/src/studio/assets/imagens/tour-mapa-editor.jpg +0 -0
  69. package/src/studio/command-approval.js +2 -3
  70. package/src/studio/demo-decks.js +6 -2
  71. package/src/studio/export-jobs.js +86 -0
  72. package/src/studio/exporting.js +36 -18
  73. package/src/studio/feature-demos.js +144 -0
  74. package/src/studio/geo-routes.js +138 -0
  75. package/src/studio/ia-routes.js +9 -0
  76. package/src/studio/layout-samples.js +78 -16
  77. package/src/studio/library-routes.js +0 -9
  78. package/src/studio/project.js +2 -1
  79. package/src/studio/public/ai-settings.js +6 -3
  80. package/src/studio/public/app.js +46 -52
  81. package/src/studio/public/explorer.js +23 -8
  82. package/src/studio/public/export-status.js +154 -0
  83. package/src/studio/public/index.html +17 -10
  84. package/src/studio/public/library.css +12 -3
  85. package/src/studio/public/library.html +3 -0
  86. package/src/studio/public/library.js +39 -12
  87. package/src/studio/public/map-data.js +158 -0
  88. package/src/studio/public/map-editor.js +676 -0
  89. package/src/studio/public/map-fields.js +21 -0
  90. package/src/studio/public/map-settings.js +86 -0
  91. package/src/studio/public/slide-form.js +19 -16
  92. package/src/studio/public/style.css +58 -0
  93. package/src/studio/public/ui-icons.js +1 -1
  94. package/src/studio/public/viewers.js +2 -1
  95. package/src/studio/public/visual-editor.js +1 -1
  96. package/src/studio/server.js +34 -9
  97. package/src/studio/snapshot.js +8 -4
  98. package/src/studio/template-collections.js +1 -1
  99. package/src/studio/tour.js +158 -0
  100. package/src/themes.js +154 -0
  101. package/templates/cenario-e-ciencia.yaml +1 -21
  102. package/templates/ensaio-api.yaml +3 -3
  103. package/templates/cenario/Texto no cen/303/241rio.yaml" +0 -171
  104. package/templates/cenario/imagens/cidade.svg +0 -30
  105. package/templates/cenario/imagens/pessoa.svg +0 -15
@@ -0,0 +1,89 @@
1
+ // Os serviços do mapa desta máquina (~/.sagadeck/mapa.json, ou SAGADECK_MAPA): tiles do fundo, satélite, endereço↔
2
+ // coordenada, busca de lugares e rotas. Fora do código e fora da biblioteca, como a configuração da IA. Sem arquivo,
3
+ // valem os serviços públicos gratuitos (OpenStreetMap e companhia), com as regras de uso deles respeitadas pelo
4
+ // sagadeck (identificação, atribuição visível, um pedido por segundo no Nominatim, nada de baixar tiles fora da tela).
5
+ import fs from "node:fs";
6
+ import os from "node:os";
7
+ import path from "node:path";
8
+ import { renameRetry } from "./fs-retry.js";
9
+
10
+ export function mapaFile(env = process.env, home = os.homedir()) {
11
+ return env.SAGADECK_MAPA || path.join(home, ".sagadeck", "mapa.json");
12
+ }
13
+
14
+ const OSM = "© colaboradores do OpenStreetMap";
15
+ const OFM = '<a href="https://openfreemap.org/" target="_blank" rel="noopener">OpenFreeMap</a> · © <a href="https://openmaptiles.org/" target="_blank" rel="noopener">OpenMapTiles</a> · © <a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener">colaboradores do OpenStreetMap</a>';
16
+ export const MAP_DEFAULTS = {
17
+ // ruas conserva o OpenStreetMap; fundos de dados são vetoriais, sem a fronteira marítima desenhada no mar
18
+ tiles: { url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png", attribution: OSM, maxZoom: 19 },
19
+ claro: { url: "https://tiles.openfreemap.org/styles/positron", tipo: "vetorial", attribution: OFM, maxZoom: 20 },
20
+ escuro: { url: "https://tiles.openfreemap.org/styles/dark", tipo: "vetorial", attribution: OFM, maxZoom: 20 },
21
+ // satélite só com provedor configurado (costuma pedir chave); sem ele, o slide usa as ruas e avisa
22
+ satelite: null,
23
+ geocoder: { provedor: "nominatim", url: "https://nominatim.openstreetmap.org", porSegundo: 1 },
24
+ busca: { provedor: "overpass", url: "https://overpass-api.de/api/interpreter" },
25
+ rotas: { provedor: "osrm", url: "https://router.project-osrm.org" },
26
+ };
27
+
28
+ const str = (v, max = 500) => (typeof v === "string" ? v.trim().slice(0, max) : "");
29
+ const service = (raw, base) => {
30
+ if (raw === null) return null;
31
+ if (!raw || typeof raw !== "object") return base;
32
+ const out = { ...(base || {}) };
33
+ for (const [k, v] of Object.entries(raw)) if (typeof v === "string") out[k] = str(v, 1000); else if (typeof v === "number" && Number.isFinite(v)) out[k] = v;
34
+ return out.url ? out : base;
35
+ };
36
+
37
+ // a configuração que vale: o arquivo por cima do padrão, campo a campo (arquivo editado à mão não quebra nada). A
38
+ // chave pode vir de uma variável de ambiente indicada em keyEnv (no servidor), como no ia.json.
39
+ export function loadMapConfig(file = mapaFile(), env = process.env) {
40
+ let raw = {};
41
+ try { raw = JSON.parse(fs.readFileSync(file, "utf8")) || {}; } catch { raw = {}; }
42
+ return Object.fromEntries(Object.entries(MAP_DEFAULTS).map(([k, base]) => {
43
+ let s = service(raw[k], base);
44
+ // arquivo antigo com tiles próprios (ou desligados): respeita esse fundo nos três modos
45
+ if (["claro", "escuro"].includes(k) && !Object.hasOwn(raw, k) && Object.hasOwn(raw, "tiles") && (raw.tiles === null || raw.tiles?.url && raw.tiles.url !== MAP_DEFAULTS.tiles.url)) s = service(raw.tiles, MAP_DEFAULTS.tiles);
46
+ if (s && ["tiles", "claro", "escuro", "satelite"].includes(k)) s = { ...s, tipo: s.url.includes("{z}") ? "raster" : s.tipo || "raster" };
47
+ if (s && !s.key && s.keyEnv && env[s.keyEnv]) return [k, { ...s, key: env[s.keyEnv], keyFrom: "variável" }];
48
+ return [k, s];
49
+ }));
50
+ }
51
+
52
+ // grava o que veio da tela Configurar mapa por cima do arquivo (campo a campo; chave em branco mantém a gravada),
53
+ // de forma atômica e só para quem é dono da conta (as chaves moram aqui)
54
+ export function saveMapConfig(patch, file = mapaFile()) {
55
+ let cur = {};
56
+ try { cur = JSON.parse(fs.readFileSync(file, "utf8")) || {}; } catch { cur = {}; }
57
+ for (const [k, v] of Object.entries(patch || {})) {
58
+ if (!(k in MAP_DEFAULTS)) continue;
59
+ if (v === null) { cur[k] = null; continue; }
60
+ if (!v || typeof v !== "object") continue;
61
+ const next = { ...(cur[k] && typeof cur[k] === "object" ? cur[k] : {}) };
62
+ for (const [f, x] of Object.entries(v)) {
63
+ if (f === "key" && !String(x ?? "").trim()) continue; // em branco: mantém a chave gravada
64
+ if (typeof x === "string") next[f] = str(x, 1000); else if (typeof x === "number" && Number.isFinite(x)) next[f] = x;
65
+ }
66
+ cur[k] = next;
67
+ }
68
+ fs.mkdirSync(path.dirname(file), { recursive: true });
69
+ const tmp = `${file}.tmp-${process.pid}`;
70
+ fs.writeFileSync(tmp, JSON.stringify(cur, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
71
+ renameRetry(tmp, file);
72
+ return loadMapConfig(file);
73
+ }
74
+
75
+ // para a tela: a chave nunca sai inteira
76
+ export function maskedMapConfig(cfg) {
77
+ return Object.fromEntries(Object.entries(cfg).map(([k, v]) => {
78
+ if (!v) return [k, v];
79
+ const { key, ...rest } = v;
80
+ return [k, { ...rest, keySet: !!key, keyHint: key ? `••••${String(key).slice(-4)}` : "", keyFrom: key ? rest.keyFrom || "arquivo" : "" }];
81
+ }));
82
+ }
83
+
84
+ // o que pode ir para dentro do HTML da apresentação: só o fundo (o navegador busca os tiles). Chave de rotas e de
85
+ // geocodificação nunca vai para o deck.
86
+ export function tilesForDeck(cfg = loadMapConfig()) {
87
+ const pick = (t) => (t ? { url: t.url, tipo: t.tipo || "raster", attribution: t.attribution || OSM, maxZoom: t.maxZoom || 19 } : null);
88
+ return { tiles: pick(cfg.tiles), claro: pick(cfg.claro === undefined ? cfg.tiles : cfg.claro), escuro: pick(cfg.escuro === undefined ? cfg.tiles : cfg.escuro), satelite: pick(cfg.satelite) };
89
+ }
@@ -0,0 +1,82 @@
1
+ // Rotas pelas ruas e endereço de um ponto, para o editor do mapa (entrega 3) e as análises (entrega 5). Passam por
2
+ // callService (src/map-services.js): identificação do sagadeck e "uma tentativa e para".
3
+ // - rota: OpenRouteService quando configurado com chave (a pé, de carro, de bicicleta; também isócronas) ou OSRM
4
+ // (padrão público; o servidor de demonstração só tem carro de forma garantida);
5
+ // - endereço de um ponto: Nominatim /reverse (ou compatível).
6
+ import { loadMapConfig, mapaFile } from "./map-config.js";
7
+ import { callService } from "./map-services.js";
8
+
9
+ export const MODOS = { pe: "a pé", carro: "de carro", bicicleta: "de bicicleta" };
10
+ const ORS_PROFILE = { pe: "foot-walking", carro: "driving-car", bicicleta: "cycling-regular" };
11
+ const OSRM_PROFILE = { pe: "foot", carro: "driving", bicicleta: "bike" };
12
+
13
+ const isORS = (r) => r && (r.provedor === "openrouteservice" || /openrouteservice/i.test(r.url || ""));
14
+
15
+ // pontos: [[lat, lon], …] (2 ou mais). Devolve { coords: [[lon, lat], …], distancia (m), duracao (s), modo }
16
+ export async function route(pontos, { modo = "pe", cfg = loadMapConfig(mapaFile()) } = {}) {
17
+ if (!Array.isArray(pontos) || pontos.length < 2) throw new Error("Rota: preciso de pelo menos dois pontos.");
18
+ const pts = pontos.map(([lat, lon]) => [Number(lat), Number(lon)]);
19
+ if (pts.some(([a, b]) => !Number.isFinite(a) || !Number.isFinite(b))) throw new Error("Rota: ponto sem coordenada.");
20
+ const r = cfg.rotas;
21
+ const m = MODOS[modo] ? modo : "pe";
22
+ if (isORS(r) && r.key) {
23
+ const base = (r.url || "https://api.openrouteservice.org").replace(/\/+$/, "");
24
+ const res = await callService("rotas", `${base}/v2/directions/${ORS_PROFILE[m]}/geojson`, {
25
+ method: "POST", cfg, headers: { Authorization: r.key, "Content-Type": "application/json", Accept: "application/json, application/geo+json" },
26
+ body: JSON.stringify({ coordinates: pts.map(([lat, lon]) => [lon, lat]) }),
27
+ });
28
+ const j = await res.json();
29
+ const f = j.features?.[0];
30
+ if (!f) throw new Error("Rota: o serviço não achou caminho entre os pontos.");
31
+ return { coords: f.geometry.coordinates, distancia: f.properties?.summary?.distance ?? 0, duracao: f.properties?.summary?.duration ?? 0, modo: m };
32
+ }
33
+ const base = (r?.url || "https://router.project-osrm.org").replace(/\/+$/, "");
34
+ const res = await callService("rotas", `${base}/route/v1/${OSRM_PROFILE[m]}/${pts.map(([lat, lon]) => `${lon},${lat}`).join(";")}?overview=full&geometries=geojson`, { cfg });
35
+ const j = await res.json();
36
+ const rt = j.routes?.[0];
37
+ if (!rt) throw new Error("Rota: o serviço não achou caminho entre os pontos.");
38
+ return { coords: rt.geometry.coordinates, distancia: rt.distance, duracao: rt.duration, modo: m };
39
+ }
40
+
41
+ // área alcançável em X minutos a partir de um ponto (só OpenRouteService): GeoJSON Polygon
42
+ export async function isochrone([lat, lon], minutos, { modo = "pe", cfg = loadMapConfig(mapaFile()) } = {}) {
43
+ const r = cfg.rotas;
44
+ if (!isORS(r) || !r.key) throw new Error("Área alcançável precisa do OpenRouteService com chave (Configurar mapa).");
45
+ const base = (r.url || "https://api.openrouteservice.org").replace(/\/+$/, "");
46
+ const res = await callService("rotas", `${base}/v2/isochrones/${ORS_PROFILE[MODOS[modo] ? modo : "pe"]}`, {
47
+ method: "POST", cfg, headers: { Authorization: r.key, "Content-Type": "application/json", Accept: "application/json, application/geo+json" },
48
+ body: JSON.stringify({ locations: [[Number(lon), Number(lat)]], range: [Math.round(Number(minutos) * 60)], range_type: "time" }),
49
+ });
50
+ const j = await res.json();
51
+ const f = j.features?.[0];
52
+ if (!f) throw new Error("Área alcançável: o serviço não devolveu a área.");
53
+ return f.geometry;
54
+ }
55
+
56
+ // rastro de GPS tremido -> o caminho pelas ruas por onde passou (OSRM /match; com o OpenRouteService configurado
57
+ // para rotas, o encaixe usa o OSRM público). pontos: [[lat, lon], …] (até 100). Devolve [[lon, lat], …]
58
+ export async function matchTrace(pontos, { modo = "pe", cfg = loadMapConfig(mapaFile()) } = {}) {
59
+ const pts = pontos.slice(0, 100).map(([lat, lon]) => [Number(lat), Number(lon)]).filter(([a, b]) => Number.isFinite(a) && Number.isFinite(b));
60
+ if (pts.length < 2) throw new Error("Encaixar nas ruas: o rastro precisa de pelo menos dois pontos.");
61
+ const base = (!isORS(cfg.rotas) && cfg.rotas?.url ? cfg.rotas.url : "https://router.project-osrm.org").replace(/\/+$/, "");
62
+ const res = await callService("rotas", `${base}/match/v1/${OSRM_PROFILE[MODOS[modo] ? modo : "pe"]}/${pts.map(([lat, lon]) => `${lon},${lat}`).join(";")}?overview=full&geometries=geojson`, { cfg });
63
+ const j = await res.json();
64
+ const coords = (j.matchings || []).flatMap((m) => m.geometry?.coordinates || []);
65
+ if (coords.length < 2) throw new Error("Encaixar nas ruas: o serviço não achou as ruas desse rastro.");
66
+ return coords;
67
+ }
68
+
69
+ // endereço de um ponto (para preencher a coluna de endereço de um ponto novo)
70
+ export async function reverseGeocode(lat, lon, { cfg = loadMapConfig(mapaFile()) } = {}) {
71
+ const g = cfg.geocoder;
72
+ const res = await callService("geocoder", `${g.url.replace(/\/+$/, "")}/reverse?format=jsonv2&lat=${Number(lat)}&lon=${Number(lon)}${g.key ? `&key=${encodeURIComponent(g.key)}` : ""}`, { cfg });
73
+ const j = await res.json();
74
+ return j?.display_name || "";
75
+ }
76
+
77
+ // distância em metros entre dois pontos [lat, lon] (haversine): a régua e o "mais próximo" (entrega 5)
78
+ export function distance([lat1, lon1], [lat2, lon2]) {
79
+ const R = 6371008.8, rad = Math.PI / 180;
80
+ const a = Math.sin(((lat2 - lat1) * rad) / 2) ** 2 + Math.cos(lat1 * rad) * Math.cos(lat2 * rad) * Math.sin(((lon2 - lon1) * rad) / 2) ** 2;
81
+ return 2 * R * Math.asin(Math.sqrt(a));
82
+ }
@@ -0,0 +1,60 @@
1
+ // Chamadas aos serviços do mapa (endereço↔coordenada, busca de lugares, rotas), feitas pelo servidor do Studio com a
2
+ // identificação do sagadeck (as políticas de uso pedem). Regras:
3
+ // - uma tentativa e para: erro de autorização ou bloqueio (401, 403, 407, 429, recusa, certificado, tempo esgotado)
4
+ // marca o serviço indisponível nesta execução, e nenhum pedido sai até alguém pedir "Tentar de novo";
5
+ // - sem teste proativo: o estado começa "não usado" e só muda com uso de verdade;
6
+ // - o estado de cada serviço vai para a IA (ela decide o que chamar) e para a tela Configurar mapa.
7
+ import { loadMapConfig, mapaFile } from "./map-config.js";
8
+ import { createRequire } from "node:module";
9
+
10
+ const require = createRequire(import.meta.url);
11
+ const VERSION = (() => { try { return require("../package.json").version; } catch { return "?"; } })();
12
+ export const USER_AGENT = `sagadeck/${VERSION} (+https://github.com/naruminho/sagadeck)`;
13
+ export const SERVICES = { geocoder: "Endereço e coordenada", busca: "Busca de lugares", rotas: "Rotas" };
14
+
15
+ const state = new Map(); // serviço -> { status: "ok" | "bloqueado", motivo, quando }
16
+ export const serviceState = () => Object.fromEntries(Object.keys(SERVICES).map((k) => [k, state.get(k) || { status: "não usado" }]));
17
+ export const resetService = (k) => (k ? state.delete(k) : state.clear());
18
+
19
+ export class ServiceBlocked extends Error {
20
+ constructor(service, motivo) { super(`${SERVICES[service] || service}: ${motivo}`); this.service = service; this.motivo = motivo; this.code = "MAP_SERVICE_BLOCKED"; }
21
+ }
22
+
23
+ const hhmm = () => new Date().toLocaleTimeString("pt-BR", { hour: "2-digit", minute: "2-digit" });
24
+ const BLOCK = { 401: "recusou a autorização (401)", 403: "recusou o acesso (403)", 407: "o proxy da rede pediu autenticação (407)", 429: "pediu para diminuir o ritmo (429)" };
25
+
26
+ // um pedido a um serviço; se ele estiver bloqueado, nem tenta
27
+ export async function callService(service, url, { method = "GET", headers = {}, body, timeoutMs = 15000, cfg = loadMapConfig(mapaFile()) } = {}) {
28
+ const st = state.get(service);
29
+ if (st?.status === "bloqueado") throw new ServiceBlocked(service, `${st.motivo}; não tentei de novo`);
30
+ let res;
31
+ try {
32
+ res = await fetch(url, { method, body, headers: { "User-Agent": USER_AGENT, Accept: "application/json", ...headers }, signal: AbortSignal.timeout(timeoutMs) });
33
+ } catch (e) {
34
+ const motivo = e.name === "TimeoutError" ? "não respondeu a tempo" : /certificate|self[- ]signed|CERT/i.test(String(e.cause?.message || e.message)) ? "o certificado foi barrado pela rede" : "não foi possível conectar (a rede pode estar bloqueando)";
35
+ state.set(service, { status: "bloqueado", motivo: `${motivo} às ${hhmm()}`, quando: Date.now() });
36
+ throw new ServiceBlocked(service, motivo);
37
+ }
38
+ if (BLOCK[res.status]) {
39
+ state.set(service, { status: "bloqueado", motivo: `${BLOCK[res.status]} às ${hhmm()}`, quando: Date.now() });
40
+ throw new ServiceBlocked(service, BLOCK[res.status]);
41
+ }
42
+ if (!res.ok) throw new Error(`${SERVICES[service] || service}: HTTP ${res.status}`);
43
+ state.set(service, { status: "ok", quando: Date.now() });
44
+ return res;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------------------------- endereço -> ponto
48
+ // Nominatim (padrão) ou outro compatível com /search?format=jsonv2. Um pedido por vez, no ritmo do serviço
49
+ // (porSegundo; o Nominatim público aceita 1). O cache é de quem chama (o projeto guarda o seu).
50
+ export async function geocodeOne(q, { cfg = loadMapConfig(mapaFile()), signal } = {}) {
51
+ const g = cfg.geocoder;
52
+ const url = `${g.url.replace(/\/+$/, "")}/search?format=jsonv2&limit=1&addressdetails=0&q=${encodeURIComponent(q)}${g.key ? `&key=${encodeURIComponent(g.key)}` : ""}`;
53
+ if (signal?.aborted) throw new Error("cancelado");
54
+ const res = await callService("geocoder", url, { cfg });
55
+ const list = await res.json();
56
+ const hit = Array.isArray(list) ? list[0] : null;
57
+ return hit ? { lat: Number(hit.lat), lon: Number(hit.lon), nome: hit.display_name || "" } : null;
58
+ }
59
+
60
+ export const geocodeInterval = (cfg = loadMapConfig(mapaFile())) => Math.max(1, Math.ceil(1000 / Math.max(0.1, Number(cfg.geocoder?.porSegundo) || 1)));
package/src/map.js ADDED
@@ -0,0 +1,274 @@
1
+ // Slide de mapa (layout: map). A planilha é a fonte, o mapa é a vista: cada camada lê um arquivo do projeto (CSV,
2
+ // GeoJSON, GPX, KML) ou valores por estado/país, e vira GeoJSON com o estilo já decidido (cor por categoria ou por
3
+ // valor, tamanho, rótulo, campos do cartão). Os dados vão dentro do HTML; o mapa ao vivo (src/runtime/map.js) desenha
4
+ // com o Leaflet, e uma prévia em SVG (sem fundo) serve às miniaturas e de reserva.
5
+ import fs from "node:fs";
6
+ import path from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+ import { parseCSV } from "./csv.js";
9
+ import { toNum } from "./science.js";
10
+
11
+ const VENDOR = path.join(path.dirname(fileURLToPath(import.meta.url)), "runtime", "vendor");
12
+ let contornos;
13
+ export const geoContornos = () => (contornos ||= JSON.parse(fs.readFileSync(path.join(VENDOR, "geo-contornos.json"), "utf8")));
14
+
15
+ // cores das categorias: a paleta do tema (o runtime resolve --s1…), na ordem
16
+ // cores das categorias: a paleta do tema (c1…c5, as mesmas da tabela e dos gráficos), depois destaque e alerta
17
+ export const MAP_COLORS = ["c1", "c2", "c3", "c4", "c5", "em", "hi", "alert"];
18
+ // token de cor -> CSS: hex, paleta do tema (--c-c1… com recuo no destaque), ou variável do tom (s1, em, hi, fg…)
19
+ export const mapColorCSS = (c) => (/^#?[0-9a-f]{6}$/i.test(c) ? `#${String(c).replace("#", "")}` : /^(c\d|accent|alert|ink|paper)$/.test(c) ? `var(--c-${c},var(--em))` : `var(--${c})`);
20
+ const BASEMAPS = ["ruas", "claro", "escuro", "satelite", "nenhum"];
21
+
22
+ const norm = (s) => String(s ?? "").normalize("NFD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "");
23
+ const LAT = ["lat", "latitude", "y"], LON = ["lon", "lng", "long", "longitude", "x"], COORD = ["coordenadas", "coordenada", "coords", "latlon", "latlong", "posicao", "localizacao"];
24
+ const findCol = (cols, want, names) => {
25
+ if (want) { const i = cols.findIndex((c) => norm(c) === norm(want)); return i; }
26
+ return cols.findIndex((c) => names.includes(norm(c)));
27
+ };
28
+
29
+ // ---------------------------------------------------------------------------------------------- leitura dos arquivos
30
+ function readText(file, ctx, what) {
31
+ const full = path.resolve(ctx?.baseDir || process.cwd(), String(file).trim());
32
+ try { return fs.readFileSync(full, "utf8"); }
33
+ catch { ctx?.warnings?.push(`mapa: ${what} "${file}" não encontrado ao lado do deck`); return null; }
34
+ }
35
+
36
+ // coordenada não tem separador de milhar: "-46.702" é -46,702 (o leitor de números da planilha leria -46702) e
37
+ // "-23,55" é -23,55 (vírgula decimal)
38
+ export function coordNum(v) {
39
+ if (typeof v === "number") return v;
40
+ const s = String(v ?? "").trim().replace(/\s/g, "");
41
+ return /^[+-]?\d+([.,]\d+)?$/.test(s) ? Number(s.replace(",", ".")) : NaN;
42
+ }
43
+ // "lat, lon" numa coluna só: "-23.55, -46.63", "-23,55; -46,63", "-23.55 -46.63"
44
+ export function splitCoords(v) {
45
+ const s = String(v ?? "").trim();
46
+ const parts = s.includes(";") ? s.split(";") : /,\s/.test(s) ? s.split(/,\s+/) : /\s/.test(s) ? s.split(/\s+/) : s.split(",");
47
+ return parts.length === 2 ? parts.map(coordNum) : [NaN, NaN];
48
+ }
49
+
50
+ // planilha (CSV) -> pontos. Colunas de posição reconhecidas pelo nome (lat/latitude, lon/longitude) ou indicadas.
51
+ export function pointsFromRows(head, rows, layer = {}, warn = () => {}) {
52
+ const iLat = findCol(head, layer.lat, LAT), iLon = findCol(head, layer.lon, LON);
53
+ const iCoord = iLat < 0 || iLon < 0 ? findCol(head, layer.coords, COORD) : -1;
54
+ if ((iLat < 0 || iLon < 0) && iCoord < 0) {
55
+ warn(`mapa: a planilha não tem colunas de posição (latitude e longitude); colunas: ${head.join(", ")}`);
56
+ return { features: [], missing: rows.length };
57
+ }
58
+ const features = [];
59
+ let missing = 0;
60
+ rows.forEach((r, n) => {
61
+ if (!r.some((c) => String(c ?? "").trim())) return; // linha vazia não é ponto sem posição
62
+ let lat, lon;
63
+ if (iCoord >= 0) [lat, lon] = splitCoords(r[iCoord]);
64
+ else { lat = coordNum(r[iLat]); lon = coordNum(r[iLon]); }
65
+ if (!Number.isFinite(lat) || !Number.isFinite(lon) || Math.abs(lat) > 90 || Math.abs(lon) > 180) { missing++; return; }
66
+ // as colunas de posição não vão para o cartão (a posição já é o ponto no mapa)
67
+ const props = Object.fromEntries(head.map((h, i) => [h, r[i] ?? ""]).filter(([h], i) => h !== "" && i !== iLat && i !== iLon && i !== iCoord));
68
+ features.push({ type: "Feature", properties: { ...props, _linha: n + 2 }, geometry: { type: "Point", coordinates: [lon, lat] } });
69
+ });
70
+ if (missing) warn(`mapa: ${missing} linha${missing === 1 ? "" : "s"} da planilha sem posição válida ficaram de fora`);
71
+ return { features, missing };
72
+ }
73
+
74
+ const attr = (xml, name) => xml.match(new RegExp(`\\b${name}\\s*=\\s*"([^"]*)"`))?.[1];
75
+ const tag = (xml, name) => xml.match(new RegExp(`<${name}\\b[^>]*>([\\s\\S]*?)</${name}>`))?.[1]?.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, "$1").trim();
76
+ const tags = (xml, name) => [...xml.matchAll(new RegExp(`<${name}\\b[^>]*?(?:/>|>([\\s\\S]*?)</${name}>)`, "g"))].map((m) => m[0]);
77
+
78
+ // GPX: trilhas e rotas viram linhas; pontos marcados (wpt) viram pontos
79
+ export function fromGPX(xml) {
80
+ const pt = (s) => [Number(attr(s, "lon")), Number(attr(s, "lat"))];
81
+ const features = [];
82
+ for (const t of [...tags(xml, "trk"), ...tags(xml, "rte")]) {
83
+ const segs = tags(t, "trkseg").length ? tags(t, "trkseg").map((s) => tags(s, "trkpt").map(pt)) : [tags(t, "rtept").map(pt)];
84
+ const lines = segs.filter((l) => l.length > 1);
85
+ if (!lines.length) continue;
86
+ features.push({ type: "Feature", properties: { nome: tag(t, "name") || "" }, geometry: lines.length === 1 ? { type: "LineString", coordinates: lines[0] } : { type: "MultiLineString", coordinates: lines } });
87
+ }
88
+ for (const w of tags(xml, "wpt")) features.push({ type: "Feature", properties: { nome: tag(w, "name") || "" }, geometry: { type: "Point", coordinates: pt(w) } });
89
+ return features;
90
+ }
91
+
92
+ // KML: cada Placemark com nome, descrição e dados (ExtendedData); ponto, linha ou polígono (inclusive MultiGeometry)
93
+ export function fromKML(xml) {
94
+ const coords = (s) => String(s || "").trim().split(/\s+/).map((c) => c.split(",").slice(0, 2).map(Number)).filter((c) => c.length === 2 && c.every(Number.isFinite));
95
+ const features = [];
96
+ for (const pm of tags(xml, "Placemark")) {
97
+ const props = { nome: tag(pm, "name") || "" };
98
+ const desc = tag(pm, "description"); if (desc) props.descricao = desc.replace(/<[^>]+>/g, " ").trim();
99
+ for (const d of tags(pm, "Data")) props[attr(d, "name") || "campo"] = tag(d, "value") || "";
100
+ for (const d of tags(pm, "SimpleData")) props[attr(d, "name") || "campo"] = d.replace(/<[^>]+>/g, "").trim();
101
+ const geoms = [
102
+ ...tags(pm, "Point").map((p) => ({ type: "Point", coordinates: coords(tag(p, "coordinates"))[0] })),
103
+ ...tags(pm, "LineString").map((l) => ({ type: "LineString", coordinates: coords(tag(l, "coordinates")) })),
104
+ ...tags(pm, "Polygon").map((p) => ({ type: "Polygon", coordinates: [coords(tag(tag(p, "outerBoundaryIs") || p, "coordinates")), ...tags(p, "innerBoundaryIs").map((b) => coords(tag(b, "coordinates")))] })),
105
+ ].filter((g) => g.coordinates && (g.type !== "Point" || g.coordinates.length === 2));
106
+ for (const g of geoms) features.push({ type: "Feature", properties: props, geometry: g });
107
+ }
108
+ return features;
109
+ }
110
+
111
+ function fromGeoJSON(obj) {
112
+ if (!obj || typeof obj !== "object") return [];
113
+ // _i: a posição da forma no arquivo (o editor do Studio acha a forma clicada por ela)
114
+ if (obj.type === "FeatureCollection") return (obj.features || []).map((f, i) => (f && f.geometry ? { ...f, properties: { ...f.properties, _i: i } } : null)).filter(Boolean);
115
+ if (obj.type === "Feature") return obj.geometry ? [{ ...obj, properties: { ...obj.properties, _i: 0 } }] : [];
116
+ if (obj.type && obj.coordinates) return [{ type: "Feature", properties: { _i: 0 }, geometry: obj }];
117
+ return [];
118
+ }
119
+
120
+ // valores por estado (sigla) ou país (código de 2 letras): o contorno embutido, pintado pelo valor
121
+ function fromAreas(values, warn) {
122
+ const g = geoContornos();
123
+ const features = [];
124
+ for (const [k, v] of Object.entries(values || {})) {
125
+ const code = String(k).trim().toUpperCase();
126
+ const uf = g.uf[code], pais = g.paises[code];
127
+ if (!uf && !pais) { warn(`mapa: "${k}" não é uma UF (SP, RJ…) nem um país (BR, PT…)`); continue; }
128
+ const poly = uf || pais.poly;
129
+ features.push({ type: "Feature", properties: { nome: uf ? code : pais.nome, valor: v, _cod: String(k) }, geometry: { type: "MultiPolygon", coordinates: poly } });
130
+ }
131
+ return features;
132
+ }
133
+
134
+ // ---------------------------------------------------------------------------------------------- estilo das camadas
135
+ // cor: uma coluna (categoria -> cores da paleta; número -> escala da cor de destaque), uma cor fixa, ou nada
136
+ function styleOf(features, layer, warn) {
137
+ const style = { color: null, colorBy: null, categories: null, scale: null, sizeBy: null, sizeScale: null, label: layer.label || null, popup: null, icon: layer.icon || null };
138
+ const props = features.map((f) => f.properties || {});
139
+ const has = (col) => col && props.some((p) => Object.hasOwn(p, col));
140
+ const by = layer.color ?? (layer.areas ? "valor" : null);
141
+ if (by && !has(by)) {
142
+ if (/^(#?[0-9a-f]{6}|s[1-5]|c\d|em|hi|alert|accent|fg|muted)$/i.test(String(by))) style.color = String(by);
143
+ else warn(`mapa: a camada "${layer.name || ""}" pinta pela coluna "${by}", que não existe`);
144
+ } else if (by) {
145
+ style.colorBy = by;
146
+ const vals = props.map((p) => p[by]);
147
+ const nums = vals.map(toNum).filter((n) => n != null);
148
+ if (nums.length === vals.filter((v) => String(v ?? "").trim() !== "").length && nums.length && !layer.colors) {
149
+ style.scale = { min: Math.min(...nums), max: Math.max(...nums) };
150
+ } else {
151
+ const order = [...new Set(vals.map((v) => String(v ?? "").trim()).filter(Boolean))];
152
+ const given = layer.colors && typeof layer.colors === "object" ? layer.colors : {};
153
+ if (order.length > MAP_COLORS.length && !Object.keys(given).length) warn(`mapa: "${by}" tem ${order.length} categorias; as cores se repetem a partir da ${MAP_COLORS.length + 1}ª (agrupe ou dê colors:)`);
154
+ style.categories = order.map((v, i) => ({ value: v, color: String(given[v] ?? MAP_COLORS[i % MAP_COLORS.length]) }));
155
+ }
156
+ }
157
+ if (layer.size) {
158
+ if (!has(layer.size)) warn(`mapa: a camada "${layer.name || ""}" usa a coluna "${layer.size}" para o tamanho, que não existe`);
159
+ else {
160
+ const nums = props.map((p) => toNum(p[layer.size])).filter((n) => n != null);
161
+ if (nums.length) { style.sizeBy = layer.size; style.sizeScale = { min: Math.min(...nums), max: Math.max(...nums) }; }
162
+ }
163
+ }
164
+ if (style.label && !has(style.label)) { warn(`mapa: a coluna "${style.label}" do rótulo não existe`); style.label = null; }
165
+ const cols = [...new Set(props.flatMap((p) => Object.keys(p)))].filter((c) => !c.startsWith("_"));
166
+ style.popup = Array.isArray(layer.popup) ? layer.popup.filter((c) => cols.includes(c)) : layer.popup === false ? [] : cols.slice(0, 8);
167
+ return style;
168
+ }
169
+
170
+ // ---------------------------------------------------------------------------------------------- o slide inteiro
171
+ function bboxOf(layers) {
172
+ let s = 90, w = 180, n = -90, e = -180, any = false;
173
+ const visit = (c) => {
174
+ if (typeof c[0] === "number") { const [lon, lat] = c; if (Number.isFinite(lat) && Number.isFinite(lon)) { any = true; s = Math.min(s, lat); n = Math.max(n, lat); w = Math.min(w, lon); e = Math.max(e, lon); } return; }
175
+ c.forEach(visit);
176
+ };
177
+ for (const l of layers) for (const f of l.data.features) if (f.geometry?.coordinates) visit(f.geometry.coordinates);
178
+ return any ? [[s, w], [n, e]] : null;
179
+ }
180
+
181
+ const viewOf = (v) => {
182
+ if (v === "fit" || v === true) return "fit";
183
+ if (v && Array.isArray(v.center) && v.center.length === 2 && v.center.every(Number.isFinite)) return { center: v.center, zoom: Number.isFinite(v.zoom) ? v.zoom : 13 };
184
+ return null;
185
+ };
186
+
187
+ export function mapModel(s, ctx) {
188
+ const warn = (m) => ctx?.warnings?.push(m);
189
+ const list = Array.isArray(s.layers) ? s.layers : s.layers ? [s.layers] : [];
190
+ const layers = [];
191
+ list.forEach((layer, i) => {
192
+ if (!layer || typeof layer !== "object") return;
193
+ let features = [], kind = "shapes";
194
+ if (layer.points != null) {
195
+ kind = "points";
196
+ if (typeof layer.points === "string") {
197
+ const text = readText(layer.points, ctx, "planilha");
198
+ if (text != null) {
199
+ // sem tirar as linhas vazias: _linha é o número da linha na planilha (a seleção liga linha e ponto)
200
+ const { rows } = parseCSV(text);
201
+ features = pointsFromRows((rows[0] || []).map((h) => String(h).trim()), rows.slice(1), layer, warn).features;
202
+ }
203
+ } else if (Array.isArray(layer.points)) {
204
+ const rows = layer.points.filter((p) => p && typeof p === "object");
205
+ const head = [...new Set(rows.flatMap((p) => Object.keys(p)))];
206
+ features = pointsFromRows(head, rows.map((p) => head.map((h) => p[h] ?? "")), layer, warn).features;
207
+ }
208
+ } else if (layer.geojson && typeof layer.geojson === "object") {
209
+ // escrito no próprio slide (desenho pequeno, demonstração); dados de verdade ficam num arquivo do projeto
210
+ features = fromGeoJSON(layer.geojson);
211
+ if (!features.length) warn(`mapa: o GeoJSON da camada "${layer.name || i + 1}" não tem formas`);
212
+ } else if (layer.geojson) {
213
+ const text = readText(layer.geojson, ctx, "GeoJSON");
214
+ try { features = text == null ? [] : fromGeoJSON(JSON.parse(text)); } catch { warn(`mapa: "${layer.geojson}" não é um GeoJSON válido`); }
215
+ } else if (layer.gpx) {
216
+ const text = readText(layer.gpx, ctx, "GPX"); features = text == null ? [] : fromGPX(text);
217
+ } else if (layer.kml) {
218
+ const text = readText(layer.kml, ctx, "KML"); features = text == null ? [] : fromKML(text);
219
+ } else if (layer.areas) {
220
+ kind = "areas"; features = fromAreas(layer.areas, warn);
221
+ } else { warn(`mapa: a camada ${i + 1} não diz de onde vêm os dados (points, geojson, gpx, kml ou areas)`); return; }
222
+ if (!features.length && layer.areas == null) warn(`mapa: a camada "${layer.name || i + 1}" ficou vazia`);
223
+ const step = layer.step ?? (s.build ? i + 1 : undefined);
224
+ layers.push({
225
+ name: layer.name || layer.title || `Camada ${i + 1}`, kind, index: i, // index: a camada no slide (o editor do Studio)
226
+ source: typeof layer.points === "string" ? layer.points.trim() : (typeof layer.geojson === "string" ? layer.geojson : null) || layer.gpx || layer.kml || null, step: Number.isFinite(Number(step)) ? Number(step) : undefined,
227
+ view: viewOf(layer.view), legend: layer.legend || null, prefix: layer.prefix || "", suffix: layer.suffix || "",
228
+ style: styleOf(features, layer, warn), data: { type: "FeatureCollection", features },
229
+ });
230
+ });
231
+ const basemap = BASEMAPS.includes(s.basemap) ? s.basemap : "ruas";
232
+ if (s.basemap && !BASEMAPS.includes(s.basemap)) warn(`mapa: fundo "${s.basemap}" não existe (use ${BASEMAPS.join(", ")})`);
233
+ // camadas da mesma medida (a mesma coluna ou a mesma unidade na legenda) dividem uma escala: cada uma com a sua,
234
+ // a Bahia (2.900) saía tão forte quanto Minas (5.100) só por ser a maior da camada dela
235
+ const groups = new Map();
236
+ for (const l of layers) if (l.style.scale) { const k = `${l.style.colorBy}|${l.legend || ""}`; (groups.get(k) || groups.set(k, []).get(k)).push(l); }
237
+ for (const g of groups.values()) if (g.length > 1) {
238
+ const scale = { min: Math.min(...g.map((l) => l.style.scale.min)), max: Math.max(...g.map((l) => l.style.scale.max)) };
239
+ for (const l of g) l.style.scale = scale;
240
+ }
241
+ return { basemap, view: viewOf(s.view), bounds: bboxOf(layers), layers, tiles: ctx?.mapTiles || null };
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------------------------- prévia sem fundo
245
+ // SVG das camadas em Web Mercator, enquadradas: miniaturas do Studio (sem pedir tile nenhum) e reserva se o mapa ao
246
+ // vivo não montar
247
+ const merc = ([lon, lat]) => { const y = Math.log(Math.tan(Math.PI / 4 + (Math.max(-85, Math.min(85, lat)) * Math.PI) / 360)); return [lon / 360, -y / (2 * Math.PI)]; };
248
+ export function mapPreviewSVG(model, w = 1600, h = 760) {
249
+ if (!model.bounds) return `<svg class="map-preview" viewBox="0 0 ${w} ${h}" aria-hidden="true"></svg>`;
250
+ const [[s, wst], [n, e]] = model.bounds;
251
+ const [x0, y1] = merc([wst, s]), [x1, y0] = merc([e, n]);
252
+ const pad = 0.08, bw = Math.max(x1 - x0, 1e-6), bh = Math.max(y1 - y0, 1e-6);
253
+ const k = Math.min(w / (bw * (1 + 2 * pad)), h / (bh * (1 + 2 * pad)));
254
+ const ox = w / 2 - ((x0 + x1) / 2) * k, oy = h / 2 - ((y0 + y1) / 2) * k;
255
+ const P = (c) => { const [x, y] = merc(c); return `${(x * k + ox).toFixed(1)},${(y * k + oy).toFixed(1)}`; };
256
+ const out = [];
257
+ const color = (layer, f) => {
258
+ const st = layer.style, v = st.colorBy ? f.properties?.[st.colorBy] : null;
259
+ const cat = st.categories?.find((c) => c.value === String(v ?? "").trim());
260
+ return mapColorCSS(cat?.color || st.color || "c1");
261
+ };
262
+ for (const layer of model.layers) for (const f of layer.data.features) {
263
+ const g = f.geometry, col = color(layer, f);
264
+ const lines = (cs) => cs.map((l) => `M${l.map(P).join("L")}`).join("");
265
+ if (g.type === "Point") { const [x, y] = P(g.coordinates).split(","); out.push(`<circle cx="${x}" cy="${y}" r="8" fill="${col}" stroke="#fff" stroke-width="3"/>`); }
266
+ else if (g.type === "LineString") out.push(`<path d="${lines([g.coordinates])}" fill="none" stroke="${col}" stroke-width="5"/>`);
267
+ else if (g.type === "MultiLineString") out.push(`<path d="${lines(g.coordinates)}" fill="none" stroke="${col}" stroke-width="5"/>`);
268
+ else if (g.type === "Polygon") out.push(`<path d="${lines(g.coordinates)}Z" fill="${col}" fill-opacity=".35" stroke="${col}" stroke-width="2"/>`);
269
+ else if (g.type === "MultiPolygon") out.push(`<path d="${g.coordinates.map((p) => lines(p) + "Z").join("")}" fill="${col}" fill-opacity=".35" stroke="${col}" stroke-width="${f.properties?._faixa ? 0 : 2}"/>`);
270
+ }
271
+ return `<svg class="map-preview" viewBox="0 0 ${w} ${h}" preserveAspectRatio="xMidYMid meet" aria-hidden="true">${out.join("")}</svg>`;
272
+ }
273
+
274
+ export const mapSummary = (model) => model.layers.map((l) => `${l.name}: ${l.data.features.length}`).join(" · ");
package/src/mcp/server.js CHANGED
@@ -129,6 +129,18 @@ const TOOLS = [
129
129
  required: ["query"],
130
130
  },
131
131
  },
132
+ {
133
+ name: "sagadeck_ia_check",
134
+ description: "Testa a IA configurada nesta máquina, sem tela: texto, visão (o modelo precisa dizer as cores de uma imagem), geração de imagem, pesquisa na web e vídeo, cada um com uma chamada de verdade, em paralelo. Rode antes de usar recursos de IA. Status por recurso: ok, falhou, nao_configurado ou sem_prova (vídeo sem gerar). Vídeo só é gerado de verdade com video_real: true (é cobrado).",
135
+ inputSchema: {
136
+ type: "object",
137
+ properties: {
138
+ so: { type: "array", items: { type: "string", enum: ["texto", "visao", "imagem", "pesquisa", "video"] }, description: "Só estes recursos (padrão: todos)" },
139
+ rapido: { type: "boolean", description: "Pula geração de imagem e vídeo (os lentos e cobrados)" },
140
+ video_real: { type: "boolean", description: "Gera um clipe curto de verdade e confere o MP4 (cobrado; leva minutos)" },
141
+ },
142
+ },
143
+ },
132
144
  {
133
145
  name: "sagadeck_launch_studio",
134
146
  description: "Inicia o SagaDeck Studio (página web estilo PowerPoint com canvas visual, edição WYSIWYG e chat lateral com IA).",
@@ -221,6 +233,11 @@ export function runMCPServer() {
221
233
 
222
234
  async function handleToolCall(name, args) {
223
235
  switch (name) {
236
+ case "sagadeck_ia_check": {
237
+ const { iaCheck } = await import("../ai/ia-check.js");
238
+ return iaCheck({ only: args.so, rapido: !!args.rapido, videoReal: !!args.video_real });
239
+ }
240
+
224
241
  case "sagadeck_read_deck": {
225
242
  const spec = loadSpec(path.resolve(args.path));
226
243
  return {