import { buildSkillMd, findSkillByName, isSkillReadOnly, mergeBuiltinSkills, parseSkillMd, type SkillRecord, type SkillSource, } from "@rakazo/core"; import type { PrismaClient } from "@rakazo/db"; import { BUILTIN_AGENT_SKILLS } from "./builtin-skills.js"; export const SKILL_TOOL_NAMES = new Set([ "skill_read", "skill_create", "skill_update", "skill_delete", ]); /** Match CreateAgentSkillInput / UpdateAgentSkillInput / parseSkillMd bounds. */ const MAX_SKILL_CONTENT_CHARS = 100_000; const MAX_SKILL_NAME_CHARS = 80; const MAX_SKILL_DESCRIPTION_CHARS = 2000; type SkillOwner = { spaceId: string; userId: string; }; type AgentSkillRow = { id: string; name: string; description: string; content: string; source: string; }; function asSource(value: string): SkillSource { if (value === "builtin" || value === "plugin" || value === "user") return value; return "user"; } function toRecord(row: AgentSkillRow): SkillRecord & { id: string } { const source = asSource(row.source); return { id: row.id, name: row.name, description: row.description, content: row.content, source, readOnly: isSkillReadOnly(source), }; } function builtinRecords(): Array { return BUILTIN_AGENT_SKILLS.map((skill) => ({ id: `builtin:${skill.name}`, name: skill.name, description: skill.description, content: skill.content, source: "builtin" as const, readOnly: true, })); } function rejectOversizedContent(content: string): string | undefined { if (content.length > MAX_SKILL_CONTENT_CHARS) { return `Skill content must be at most ${MAX_SKILL_CONTENT_CHARS} characters.`; } return undefined; } function rejectInvalidSkillFields(name: string, description: string): string | undefined { if (!name) return "Skill name is required."; if (!description) return "Skill description is required."; if (name.length > MAX_SKILL_NAME_CHARS) { return `Skill name must be at most ${MAX_SKILL_NAME_CHARS} characters.`; } if (description.length > MAX_SKILL_DESCRIPTION_CHARS) { return `Skill description must be at most ${MAX_SKILL_DESCRIPTION_CHARS} characters.`; } return undefined; } export async function listAgentSkillRecords( prisma: PrismaClient, owner: SkillOwner, ): Promise> { const rows = await prisma.agentSkill.findMany({ where: { spaceId: owner.spaceId, userId: owner.userId }, orderBy: [{ name: "asc" }, { id: "asc" }], }); return mergeBuiltinSkills(builtinRecords(), rows.map(toRecord)); } async function findOwnedSkill( prisma: PrismaClient, owner: SkillOwner, input: { skillId?: string; name?: string }, ): Promise<(SkillRecord & { id: string }) | null> { const skills = await listAgentSkillRecords(prisma, owner); if (input.skillId) { return skills.find((skill) => skill.id === input.skillId) ?? null; } if (input.name) return findSkillByName(skills, input.name) ?? null; return null; } export async function skillReadFromTool( prisma: PrismaClient, owner: SkillOwner, input: { name?: string; skillId?: string }, ): Promise> { const skill = await findOwnedSkill(prisma, owner, input); if (!skill) return { error: "Skill not found." }; return { name: skill.name, description: skill.description, source: skill.source, readOnly: skill.readOnly, content: skill.content, }; } export async function skillCreateFromTool( prisma: PrismaClient, owner: SkillOwner, input: { name?: string; description?: string; body?: string; content?: string }, ): Promise> { let name = ""; let description = ""; let content = ""; if (input.content?.trim()) { const parsed = parseSkillMd(input.content); if ("error" in parsed) return { error: parsed.error }; name = parsed.name; description = parsed.description; content = buildSkillMd(parsed); } else { name = String(input.name ?? "").trim(); description = String(input.description ?? "").trim(); const body = String(input.body ?? "").trim(); if (!name || !description) { return { error: "Provide name and description (and optional body), or full SKILL.md content.", }; } const invalid = rejectInvalidSkillFields(name, description); if (invalid) return { error: invalid }; content = buildSkillMd({ name, description, body }); } const oversized = rejectOversizedContent(content); if (oversized) return { error: oversized }; const existing = await findOwnedSkill(prisma, owner, { name }); if (existing) return { error: `A skill named "${existing.name}" already exists.` }; try { const row = await prisma.agentSkill.create({ data: { spaceId: owner.spaceId, userId: owner.userId, name, description, content, source: "user", }, }); return { ok: true, id: row.id, name: row.name, description: row.description, hint: `Created skill. Mention /${row.name} so the user can open it.`, }; } catch { return { error: "Could not create skill (name may already exist)." }; } } export async function skillUpdateFromTool( prisma: PrismaClient, owner: SkillOwner, input: { name?: string; skillId?: string; newName?: string; description?: string; body?: string; content?: string; }, ): Promise> { const existing = await findOwnedSkill(prisma, owner, { skillId: input.skillId, name: input.name, }); if (!existing) return { error: "Skill not found." }; if (existing.readOnly || existing.source !== "user" || existing.id.startsWith("builtin:")) { return { error: "Builtin and plugin skills are read-only." }; } let nextContent = existing.content; let nextName = existing.name; let nextDescription = existing.description; if (input.content?.trim()) { const parsed = parseSkillMd(input.content); if ("error" in parsed) return { error: parsed.error }; nextName = parsed.name; nextDescription = parsed.description; nextContent = buildSkillMd(parsed); } else { const prior = parseSkillMd(existing.content); if ("error" in prior) return { error: prior.error }; // `name` is only a lookup key (see findOwnedSkill above); renames require `newName`. nextName = String(input.newName ?? existing.name).trim() || existing.name; nextDescription = input.description !== undefined ? String(input.description).trim() : existing.description; const body = input.body !== undefined ? String(input.body) : prior.body; const invalidFields = rejectInvalidSkillFields(nextName, nextDescription); if (invalidFields) return { error: invalidFields }; nextContent = buildSkillMd({ name: nextName, description: nextDescription, body, frontmatter: prior.frontmatter, }); } const invalid = rejectInvalidSkillFields(nextName, nextDescription); if (invalid) return { error: invalid }; const oversized = rejectOversizedContent(nextContent); if (oversized) return { error: oversized }; if (nextName.toLowerCase() !== existing.name.toLowerCase()) { const clash = await findOwnedSkill(prisma, owner, { name: nextName }); if (clash && clash.id !== existing.id) { return { error: `A skill named "${clash.name}" already exists.` }; } } try { // Re-scope mutate to owner + user source so a stale id cannot cross tenants. const updated = await prisma.agentSkill.updateMany({ where: { id: existing.id, spaceId: owner.spaceId, userId: owner.userId, source: "user", }, data: { name: nextName, description: nextDescription, content: nextContent, }, }); if (updated.count !== 1) return { error: "Could not update skill." }; return { ok: true, id: existing.id, name: nextName, description: nextDescription }; } catch { return { error: "Could not update skill." }; } } export async function skillDeleteFromTool( prisma: PrismaClient, owner: SkillOwner, input: { name?: string; skillId?: string }, ): Promise> { const existing = await findOwnedSkill(prisma, owner, input); if (!existing) return { error: "Skill not found." }; if (existing.readOnly || existing.source !== "user" || existing.id.startsWith("builtin:")) { return { error: "Builtin and plugin skills are read-only." }; } const deleted = await prisma.agentSkill.deleteMany({ where: { id: existing.id, spaceId: owner.spaceId, userId: owner.userId, source: "user", }, }); if (deleted.count !== 1) return { error: "Could not delete skill." }; return { ok: true, name: existing.name }; }