diff --git a/src/routes/painel.skills.$id.tsx b/src/routes/painel.skills.$id.tsx
index 3792ea8..e998e57 100644
--- a/src/routes/painel.skills.$id.tsx
+++ b/src/routes/painel.skills.$id.tsx
@@ -3,7 +3,18 @@
import { createFileRoute, useNavigate, Link } from "@tanstack/react-router";
import { useState, useCallback, lazy, Suspense } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
-import { ArrowLeft, Check, Loader2, Play, Rocket, Save, MoreVertical, Copy, Archive, Trash2 } from "lucide-react";
+import {
+ ArrowLeft,
+ Check,
+ Loader2,
+ Play,
+ Rocket,
+ Save,
+ MoreVertical,
+ Copy,
+ Archive,
+ Trash2,
+} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { ptBR } from "date-fns/locale";
import ReactMarkdown from "react-markdown";
@@ -81,23 +92,20 @@ function SkillDetailPage() {
const [confirmArchive, setConfirmArchive] = useState(false);
// Sync markdown when versions load or selection changes
- const currentVersion = versions.data?.find((v) =>
- selectedVersionId ? v.id === selectedVersionId : v.is_live,
- ) ?? versions.data?.[0];
+ const currentVersion =
+ versions.data?.find((v) => (selectedVersionId ? v.id === selectedVersionId : v.is_live)) ??
+ versions.data?.[0];
if (currentVersion && markdown === "" && !editing) {
// initial load
setTimeout(() => setMarkdown(currentVersion.markdown_content), 0);
}
- const selectVersion = useCallback(
- (v: SkillVersion) => {
- setSelectedVersionId(v.id);
- setMarkdown(v.markdown_content);
- setEditing(false);
- },
- [],
- );
+ const selectVersion = useCallback((v: SkillVersion) => {
+ setSelectedVersionId(v.id);
+ setMarkdown(v.markdown_content);
+ setEditing(false);
+ }, []);
// Save new version
const saveVersion = useMutation({
@@ -106,15 +114,17 @@ function SkillDetailPage() {
const maxVer = Math.max(...versions.data.map((v) => v.version_number), 0);
const { data, error } = await supabase
.from("skill_versions")
- .insert([{
- skill_id: id,
- version_number: maxVer + 1,
- markdown_content: markdown,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- form_inputs: (currentVersion?.form_inputs ?? {}) as any,
- is_live: false,
- created_by: user.id,
- }])
+ .insert([
+ {
+ skill_id: id,
+ version_number: maxVer + 1,
+ markdown_content: markdown,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ form_inputs: (currentVersion?.form_inputs ?? {}) as any,
+ is_live: false,
+ created_by: user.id,
+ },
+ ])
.select("id, version_number")
.single();
if (error) throw error;
@@ -234,6 +244,11 @@ function SkillDetailPage() {
{skill.data.name}
+ {skill.data.is_default && (
+
+ Padrão
+
+ )}
{currentVersion && (
v{currentVersion.version_number}
@@ -323,7 +338,10 @@ function SkillDetailPage() {
}>
{ setMarkdown(v); if (!editing) setEditing(true); }}
+ onChange={(v) => {
+ setMarkdown(v);
+ if (!editing) setEditing(true);
+ }}
readOnly={!editing}
/>
@@ -336,15 +354,22 @@ function SkillDetailPage() {
- Editor
- Preview
+
+ Editor
+
+
+ Preview
+
}>
{ setMarkdown(v); if (!editing) setEditing(true); }}
+ onChange={(v) => {
+ setMarkdown(v);
+ if (!editing) setEditing(true);
+ }}
readOnly={!editing}
/>
diff --git a/src/routes/painel.skills.index.tsx b/src/routes/painel.skills.index.tsx
index 5c5f32b..9321788 100644
--- a/src/routes/painel.skills.index.tsx
+++ b/src/routes/painel.skills.index.tsx
@@ -14,11 +14,7 @@ import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
-import {
- Tooltip,
- TooltipContent,
- TooltipTrigger,
-} from "@/components/ui/tooltip";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
export const Route = createFileRoute("/painel/skills/")({
component: SkillsPage,
@@ -31,10 +27,8 @@ function SkillsPage() {
const skills = useSkills(showArchived);
const loading = limits.isLoading || agent.isLoading || skills.isLoading;
- const noSub = !limits.isLoading && (limits.data?.max_skills == null);
- const agentNotReady =
- !agent.isLoading &&
- (!agent.data || agent.data.status === "provisioning");
+ const noSub = !limits.isLoading && limits.data?.max_skills == null;
+ const agentNotReady = !agent.isLoading && (!agent.data || agent.data.status === "provisioning");
const atLimit =
limits.data != null &&
limits.data.max_skills != null &&
@@ -67,7 +61,7 @@ function SkillsPage() {
{limits.data && limits.data.max_skills != null && (
- {limits.data.current_skills_count} de {limits.data.max_skills} skills
+ {limits.data.current_skills_count} de {limits.data.max_skills} personalizadas
)}
@@ -102,7 +96,7 @@ function SkillsPage() {
{agentNotReady
? "Aguarde o provisionamento do agente terminar"
- : `Limite de ${limits.data?.max_skills} skills do plano ${limits.data?.plan_slug ?? ""}. Faça upgrade ou arquive uma skill.`}
+ : `Limite de ${limits.data?.max_skills} skills personalizadas do plano ${limits.data?.plan_slug ?? ""}. Faça upgrade ou arquive uma skill.`}
)}
diff --git a/src/routes/painel.skills.preview.tsx b/src/routes/painel.skills.preview.tsx
index 0c35c2b..3e389ea 100644
--- a/src/routes/painel.skills.preview.tsx
+++ b/src/routes/painel.skills.preview.tsx
@@ -45,109 +45,117 @@ function SkillPreviewPage() {
const hasContent = markdown.trim().length > 0;
// Helper: parse Supabase error codes
- const handleSupabaseError = useCallback((error: { code?: string; message?: string }) => {
- if (error.code === "P0001") {
- toast.error("Você precisa de uma assinatura ativa para criar skills.", {
- action: { label: "Ver planos", onClick: () => navigate({ to: "/", hash: "planos" }) },
- });
- return true;
- }
- if (error.code === "P0002") {
- toast.error("Você atingiu o limite de skills do seu plano. Faça upgrade ou arquive uma skill existente.");
- return true;
- }
- if (error.code === "23505") {
- toast.error("Você já tem uma skill com esse nome. Escolha outro.");
- return true;
- }
- return false;
- }, [navigate]);
-
- const createSkill = useCallback(async (publish: boolean) => {
- if (!user || !agentId) return;
- if (markdown.length > 50000) {
- toast.error("O conteúdo excede 50.000 caracteres. Reduza antes de salvar.");
- return;
- }
-
- const setter = publish ? setPublishing : setSaving;
- setter(true);
-
- try {
- const fi = formInputs as Record
;
- // 1. Create skill
- const { data: skill, error: skillErr } = await supabase
- .from("skills")
- .insert({
- user_id: user.id,
- agent_instance_id: agentId,
- name: (fi.name as string) || "Skill sem nome",
- description: (fi.description as string) || "",
- trigger_keywords: (fi.trigger_keywords as string) || "",
- status: "draft",
- })
- .select("id")
- .single();
-
- if (skillErr) {
- if (!handleSupabaseError(skillErr as { code?: string })) {
- toast.error(skillErr.message || "Erro ao criar skill");
- }
- return;
+ const handleSupabaseError = useCallback(
+ (error: { code?: string; message?: string }) => {
+ if (error.code === "P0001") {
+ toast.error("Você precisa de uma assinatura ativa para criar skills.", {
+ action: { label: "Ver planos", onClick: () => navigate({ to: "/", hash: "planos" }) },
+ });
+ return true;
}
-
- // 2. Create version
- const { data: ver, error: verErr } = await supabase
- .from("skill_versions")
- .insert([{
- skill_id: skill.id,
- version_number: 1,
- markdown_content: markdown,
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- form_inputs: formInputs as any,
- is_live: false,
- created_by: user.id,
- }])
- .select("id")
- .single();
-
- if (verErr) {
- toast.error(verErr.message || "Erro ao salvar versão");
- return;
- }
-
- // 3. Optionally publish
- if (publish) {
- const { data: pubData, error: pubErr } = await supabase.functions.invoke<{
- success?: boolean;
- synced?: boolean;
- sync_error?: string;
- }>(
- "publish-skill-version",
- { body: { skill_version_id: ver.id } },
+ if (error.code === "P0002") {
+ toast.error(
+ "Você atingiu o limite de skills do seu plano. Faça upgrade ou arquive uma skill existente.",
);
- if (pubErr) {
- toast.error("Skill salva, mas falha ao publicar: " + pubErr.message);
- } else if (pubData?.synced === false) {
- toast.warning("Skill publicada, mas o sync com o container falhou.", {
- description: pubData.sync_error || "Tente novamente após o próximo deploy.",
- });
- } else {
- toast.success("Skill publicada com sucesso!");
- }
- } else {
- toast.success("Rascunho salvo!");
+ return true;
+ }
+ if (error.code === "23505") {
+ toast.error("Você já tem uma skill com esse nome. Escolha outro.");
+ return true;
+ }
+ return false;
+ },
+ [navigate],
+ );
+
+ const createSkill = useCallback(
+ async (publish: boolean) => {
+ if (!user || !agentId) return;
+ if (markdown.length > 50000) {
+ toast.error("O conteúdo excede 50.000 caracteres. Reduza antes de salvar.");
+ return;
}
- qc.invalidateQueries({ queryKey: ["skills"] });
- qc.invalidateQueries({ queryKey: ["user-limits"] });
- navigate({ to: "/painel/skills/$id", params: { id: skill.id } });
- } catch {
- toast.error("Erro inesperado");
- } finally {
- setter(false);
- }
- }, [user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc]);
+ const setter = publish ? setPublishing : setSaving;
+ setter(true);
+
+ try {
+ const fi = formInputs as Record;
+ // 1. Create skill
+ const { data: skill, error: skillErr } = await supabase
+ .from("skills")
+ .insert({
+ user_id: user.id,
+ agent_instance_id: agentId,
+ name: (fi.name as string) || "Skill sem nome",
+ description: (fi.description as string) || "",
+ trigger_keywords: (fi.trigger_keywords as string) || "",
+ status: "draft",
+ })
+ .select("id")
+ .single();
+
+ if (skillErr) {
+ if (!handleSupabaseError(skillErr as { code?: string })) {
+ toast.error(skillErr.message || "Erro ao criar skill");
+ }
+ return;
+ }
+
+ // 2. Create version
+ const { data: ver, error: verErr } = await supabase
+ .from("skill_versions")
+ .insert([
+ {
+ skill_id: skill.id,
+ version_number: 1,
+ markdown_content: markdown,
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ form_inputs: formInputs as any,
+ is_live: false,
+ created_by: user.id,
+ },
+ ])
+ .select("id")
+ .single();
+
+ if (verErr) {
+ await supabase.from("skills").delete().eq("id", skill.id);
+ toast.error(verErr.message || "Erro ao salvar versão");
+ return;
+ }
+
+ // 3. Optionally publish
+ if (publish) {
+ const { data: pubData, error: pubErr } = await supabase.functions.invoke<{
+ success?: boolean;
+ synced?: boolean;
+ sync_error?: string;
+ }>("publish-skill-version", { body: { skill_version_id: ver.id } });
+ if (pubErr) {
+ toast.error("Skill salva, mas falha ao publicar: " + pubErr.message);
+ } else if (pubData?.synced === false) {
+ toast.warning("Skill publicada, mas o sync com o container falhou.", {
+ description: pubData.sync_error || "Tente novamente após o próximo deploy.",
+ });
+ } else {
+ toast.success("Skill publicada com sucesso!");
+ }
+ } else {
+ toast.success("Rascunho salvo!");
+ }
+
+ qc.invalidateQueries({ queryKey: ["skills"] });
+ qc.invalidateQueries({ queryKey: ["user-limits"] });
+ navigate({ to: "/painel/skills/$id", params: { id: skill.id } });
+ } catch {
+ toast.error("Erro inesperado");
+ } finally {
+ setter(false);
+ }
+ },
+ [user, agentId, markdown, formInputs, handleSupabaseError, navigate, qc],
+ );
if (!routerState.markdown_content) {
return (
@@ -175,8 +183,16 @@ function SkillPreviewPage() {
setTestOpen(true)} disabled={!hasContent}>
Testar antes
- createSkill(false)} disabled={saving || publishing || !hasContent}>
- {saving ? : }
+ createSkill(false)}
+ disabled={saving || publishing || !hasContent}
+ >
+ {saving ? (
+
+ ) : (
+
+ )}
Salvar rascunho
- {publishing ? : }
+ {publishing ? (
+
+ ) : (
+
+ )}
Publicar agora
diff --git a/supabase/functions/_shared/default-skills.ts b/supabase/functions/_shared/default-skills.ts
index 74ec5e9..85f48ca 100644
--- a/supabase/functions/_shared/default-skills.ts
+++ b/supabase/functions/_shared/default-skills.ts
@@ -220,11 +220,16 @@ export async function ensureDefaultSkillsForAgent(
for (const template of DEFAULT_HERMES_SKILLS) {
const { data: existing, error: existingErr } = await supabase
.from("skills")
- .select("id")
+ .select("id, current_version_id, is_default")
.eq("user_id", agent.user_id)
.eq("name", template.name)
.neq("status", "archived")
.maybeSingle();
+ const existingSkill = existing as {
+ id: string;
+ current_version_id: string | null;
+ is_default?: boolean | null;
+ } | null;
if (existingErr) {
result.errors.push(
@@ -233,11 +238,20 @@ export async function ensureDefaultSkillsForAgent(
continue;
}
- if (existing) {
+ if (existingSkill?.current_version_id) {
result.skipped_count += 1;
continue;
}
+ if (existingSkill) {
+ if (existingSkill.is_default) {
+ await supabase.from("skills").delete().eq("id", existingSkill.id);
+ } else {
+ result.skipped_count += 1;
+ continue;
+ }
+ }
+
const { data: skillData, error: skillErr } = await supabase
.from("skills")
.insert({
@@ -246,6 +260,7 @@ export async function ensureDefaultSkillsForAgent(
name: template.name,
description: template.description,
trigger_keywords: template.trigger_keywords,
+ is_default: true,
status: "draft",
})
.select("id")
@@ -274,6 +289,7 @@ export async function ensureDefaultSkillsForAgent(
const version = versionData as { id: string } | null;
if (versionErr || !version) {
+ await supabase.from("skills").delete().eq("id", skill.id);
result.errors.push(
`${template.name}: failed to create skill version (${versionErr?.message ?? "unknown"})`,
);
@@ -290,6 +306,7 @@ export async function ensureDefaultSkillsForAgent(
.eq("id", skill.id);
if (updateErr) {
+ await supabase.from("skills").delete().eq("id", skill.id);
result.errors.push(`${template.name}: failed to publish skill (${updateErr.message})`);
continue;
}
diff --git a/supabase/functions/test-skill-dry-run/index.ts b/supabase/functions/test-skill-dry-run/index.ts
index 648b2ff..2a2a0c8 100644
--- a/supabase/functions/test-skill-dry-run/index.ts
+++ b/supabase/functions/test-skill-dry-run/index.ts
@@ -11,6 +11,57 @@ const SYSTEM_PROMPT = `Você é o agente Mika executando uma skill em modo de te
const admin = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
+class DryRunError extends Error {
+ status: number;
+ clientMessage: string;
+
+ constructor(status: number, clientMessage: string, logMessage = clientMessage) {
+ super(logMessage);
+ this.status = status;
+ this.clientMessage = clientMessage;
+ }
+}
+
+async function generateDryRunOutput(markdownContent: string, testInput: string): Promise {
+ const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${LOVABLE_API_KEY}`,
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ model: MODEL,
+ messages: [
+ { role: "system", content: SYSTEM_PROMPT },
+ {
+ role: "user",
+ content: `Definição da skill:\n\`\`\`\n${markdownContent}\n\`\`\`\n\nInput do usuário: ${testInput}`,
+ },
+ ],
+ }),
+ });
+
+ if (aiRes.status === 429) {
+ throw new DryRunError(
+ 429,
+ "Muitas requisições. Aguarde 1 minuto e tente novamente.",
+ "Muitas requisições. Aguarde 1 minuto.",
+ );
+ }
+
+ if (!aiRes.ok) {
+ const txt = await aiRes.text();
+ throw new DryRunError(
+ 500,
+ "Falha ao executar teste.",
+ `AI error ${aiRes.status}: ${txt.slice(0, 200)}`,
+ );
+ }
+
+ const data = await aiRes.json();
+ return (data?.choices?.[0]?.message?.content ?? "").trim();
+}
+
Deno.serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
@@ -34,7 +85,7 @@ Deno.serve(async (req) => {
}
const userId = userData.user.id;
- let body: { skill_version_id?: string; test_input?: string };
+ let body: { skill_version_id?: string; markdown_content?: string; test_input?: string };
try {
body = await req.json();
} catch {
@@ -44,19 +95,57 @@ Deno.serve(async (req) => {
});
}
- const { skill_version_id, test_input } = body;
- if (!skill_version_id || !test_input || test_input.trim().length === 0) {
+ const skillVersionId =
+ typeof body.skill_version_id === "string" ? body.skill_version_id.trim() : "";
+ const markdownContent =
+ typeof body.markdown_content === "string" ? body.markdown_content.trim() : "";
+ const testInput = typeof body.test_input === "string" ? body.test_input.trim() : "";
+
+ if (testInput.length === 0) {
+ return new Response(JSON.stringify({ error: "test_input é obrigatório" }), {
+ status: 400,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+
+ if (!skillVersionId && !markdownContent) {
return new Response(
- JSON.stringify({ error: "skill_version_id e test_input são obrigatórios" }),
+ JSON.stringify({ error: "skill_version_id ou markdown_content é obrigatório" }),
{ status: 400, headers: { ...corsHeaders, "Content-Type": "application/json" } },
);
}
+ if (markdownContent.length > 50000) {
+ return new Response(JSON.stringify({ error: "markdown_content excede 50000 caracteres" }), {
+ status: 400,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+
+ if (markdownContent) {
+ const startedAt = Date.now();
+ try {
+ const test_output = await generateDryRunOutput(markdownContent, testInput);
+ return new Response(
+ JSON.stringify({ test_output, duration_ms: Date.now() - startedAt, status: "success" }),
+ { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
+ );
+ } catch (e) {
+ const msg = e instanceof DryRunError ? e.clientMessage : "Erro ao executar teste.";
+ const status = e instanceof DryRunError ? e.status : 500;
+ console.error("Stateless skill dry-run failed:", e instanceof Error ? e.message : String(e));
+ return new Response(JSON.stringify({ error: msg }), {
+ status,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
+ }
+ }
+
// Verifica ownership: skill_version -> skill -> user_id
const { data: versionRow, error: vErr } = await admin
.from("skill_versions")
.select("id, markdown_content, skills!inner(user_id)")
- .eq("id", skill_version_id)
+ .eq("id", skillVersionId)
.maybeSingle();
if (vErr || !versionRow) {
@@ -77,9 +166,9 @@ Deno.serve(async (req) => {
const { data: runRow, error: runErr } = await admin
.from("skill_test_runs")
.insert({
- skill_version_id,
+ skill_version_id: skillVersionId,
user_id: userId,
- test_input,
+ test_input: testInput,
status: "running",
test_type: "dry_run",
})
@@ -98,59 +187,7 @@ Deno.serve(async (req) => {
const startedAt = Date.now();
try {
- const aiRes = await fetch("https://ai.gateway.lovable.dev/v1/chat/completions", {
- method: "POST",
- headers: {
- Authorization: `Bearer ${LOVABLE_API_KEY}`,
- "Content-Type": "application/json",
- },
- body: JSON.stringify({
- model: MODEL,
- messages: [
- { role: "system", content: SYSTEM_PROMPT },
- {
- role: "user",
- content: `Definição da skill:\n\`\`\`\n${versionRow.markdown_content}\n\`\`\`\n\nInput do usuário: ${test_input}`,
- },
- ],
- }),
- });
-
- if (aiRes.status === 429) {
- const duration = Date.now() - startedAt;
- await admin
- .from("skill_test_runs")
- .update({
- status: "error",
- error_message: "Muitas requisições. Aguarde 1 minuto.",
- duration_ms: duration,
- })
- .eq("id", runId);
- return new Response(
- JSON.stringify({ error: "Muitas requisições. Aguarde 1 minuto e tente novamente." }),
- { status: 429, headers: { ...corsHeaders, "Content-Type": "application/json" } },
- );
- }
-
- if (!aiRes.ok) {
- const txt = await aiRes.text();
- const duration = Date.now() - startedAt;
- await admin
- .from("skill_test_runs")
- .update({
- status: "error",
- error_message: `AI error ${aiRes.status}: ${txt.slice(0, 200)}`,
- duration_ms: duration,
- })
- .eq("id", runId);
- return new Response(JSON.stringify({ error: "Falha ao executar teste." }), {
- status: 500,
- headers: { ...corsHeaders, "Content-Type": "application/json" },
- });
- }
-
- const data = await aiRes.json();
- const test_output: string = (data?.choices?.[0]?.message?.content ?? "").trim();
+ const test_output = await generateDryRunOutput(versionRow.markdown_content, testInput);
const duration = Date.now() - startedAt;
await admin
@@ -158,10 +195,10 @@ Deno.serve(async (req) => {
.update({ status: "success", test_output, duration_ms: duration })
.eq("id", runId);
- return new Response(
- JSON.stringify({ test_output, duration_ms: duration, status: "success" }),
- { status: 200, headers: { ...corsHeaders, "Content-Type": "application/json" } },
- );
+ return new Response(JSON.stringify({ test_output, duration_ms: duration, status: "success" }), {
+ status: 200,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ });
} catch (e) {
const duration = Date.now() - startedAt;
const msg = e instanceof Error ? e.message : "Erro desconhecido";
@@ -169,9 +206,14 @@ Deno.serve(async (req) => {
.from("skill_test_runs")
.update({ status: "error", error_message: msg, duration_ms: duration })
.eq("id", runId);
- return new Response(JSON.stringify({ error: "Erro ao executar teste." }), {
- status: 500,
- headers: { ...corsHeaders, "Content-Type": "application/json" },
- });
+ return new Response(
+ JSON.stringify({
+ error: e instanceof DryRunError ? e.clientMessage : "Erro ao executar teste.",
+ }),
+ {
+ status: e instanceof DryRunError ? e.status : 500,
+ headers: { ...corsHeaders, "Content-Type": "application/json" },
+ },
+ );
}
});
diff --git a/supabase/migrations/20260528203000_default_skills_quota.sql b/supabase/migrations/20260528203000_default_skills_quota.sql
new file mode 100644
index 0000000..8a2eb6a
--- /dev/null
+++ b/supabase/migrations/20260528203000_default_skills_quota.sql
@@ -0,0 +1,126 @@
+-- Mark platform-seeded Hermes skills separately from user-created skills.
+-- Default skills stay active and synced to the runtime, but do not consume the
+-- customer's custom skill quota.
+
+ALTER TABLE public.skills
+ ADD COLUMN IF NOT EXISTS is_default boolean NOT NULL DEFAULT false;
+
+COMMENT ON COLUMN public.skills.is_default IS
+ 'True for Mika/Hermes skills seeded by the platform. These do not count against the custom skill quota.';
+
+UPDATE public.skills
+SET is_default = true
+WHERE is_default = false
+ AND (
+ (
+ name = 'Resumo diario'
+ AND description = 'Gera um resumo curto do dia com compromissos, tarefas e proximas prioridades.'
+ )
+ OR (
+ name = 'Planejamento semanal'
+ AND description = 'Ajuda o usuario a transformar objetivos da semana em prioridades e proximas acoes.'
+ )
+ OR (
+ name = 'Preparar reuniao'
+ AND description = 'Monta um briefing rapido antes de reunioes com contexto, pauta e perguntas uteis.'
+ )
+ );
+
+CREATE OR REPLACE VIEW public.user_skill_limits
+WITH (security_invoker = true)
+AS
+SELECT
+ p.id AS user_id,
+ pl.slug AS plan_slug,
+ CASE
+ WHEN pl.slug IS NULL THEN NULL
+ WHEN pl.slug = 'basic' THEN 5
+ WHEN pl.slug = 'starter' THEN 15
+ WHEN pl.slug = 'professional' THEN 50
+ WHEN pl.slug = 'enterprise' THEN 999999
+ ELSE NULL
+ END AS max_skills,
+ (
+ SELECT count(*)::int
+ FROM public.skills sk
+ WHERE sk.user_id = p.id
+ AND sk.status != 'archived'
+ AND COALESCE(sk.is_default, false) = false
+ ) AS current_skills_count
+FROM public.profiles p
+LEFT JOIN public.subscriptions s
+ ON s.user_id = p.id
+ AND s.status IN ('active', 'trialing')
+LEFT JOIN public.plans pl
+ ON pl.id = s.plan_id;
+
+GRANT SELECT ON public.user_skill_limits TO authenticated;
+
+CREATE OR REPLACE FUNCTION public.protect_skill_default_flag()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_request_role text := COALESCE(current_setting('request.jwt.claim.role', true), '');
+BEGIN
+ IF v_request_role = 'service_role' THEN
+ RETURN NEW;
+ END IF;
+
+ IF TG_OP = 'UPDATE' THEN
+ NEW.is_default := OLD.is_default;
+ ELSE
+ NEW.is_default := false;
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS trigger_protect_skill_default_flag ON public.skills;
+CREATE TRIGGER trigger_protect_skill_default_flag
+ BEFORE INSERT OR UPDATE ON public.skills
+ FOR EACH ROW EXECUTE FUNCTION public.protect_skill_default_flag();
+
+CREATE OR REPLACE FUNCTION public.enforce_skill_limit()
+RETURNS trigger
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path = public
+AS $$
+DECLARE
+ v_max int;
+ v_count int;
+ v_request_role text := COALESCE(current_setting('request.jwt.claim.role', true), '');
+BEGIN
+ IF COALESCE(NEW.is_default, false) THEN
+ IF v_request_role = 'service_role' THEN
+ RETURN NEW;
+ END IF;
+
+ NEW.is_default := false;
+ END IF;
+
+ SELECT max_skills, current_skills_count
+ INTO v_max, v_count
+ FROM public.user_skill_limits
+ WHERE user_id = NEW.user_id;
+
+ IF v_max IS NULL THEN
+ RAISE EXCEPTION 'Sem assinatura ativa para criar skills'
+ USING ERRCODE = 'P0001';
+ END IF;
+
+ IF v_count >= v_max THEN
+ RAISE EXCEPTION 'Limite de skills atingido para o plano'
+ USING ERRCODE = 'P0002';
+ END IF;
+
+ RETURN NEW;
+END;
+$$;
+
+REVOKE EXECUTE ON FUNCTION public.protect_skill_default_flag() FROM anon, authenticated, public;
+REVOKE EXECUTE ON FUNCTION public.enforce_skill_limit() FROM anon, authenticated, public;