//! Skills catalog — the second half of the two-layer agent model //! (skills = how to think, MCP = how to act). Slice 3.5a of the //! missions consolidation. //! //! Note: this module intentionally sits alongside the existing //! `crate::repo::skills` module (which is unrelated — that older //! module deals with a different concept). The two coexist until //! we get around to renaming the legacy one. use serde::{Deserialize, Serialize}; use serde_json::Value; use sqlx::PgPool; use sqlx::Row; use time::OffsetDateTime; use uuid::Uuid; use crate::DbError; // ── Types ──────────────────────────────────────────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Skill { pub id: Uuid, pub name: String, pub description: String, pub when_to_use: Option, pub tags: Vec, pub source_kind: String, pub workspace_id: Option, pub current_version: i32, pub body: String, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, #[serde(with = "time::serde::rfc3339")] pub updated_at: OffsetDateTime, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SkillVersion { pub skill_id: Uuid, pub version: i32, pub body: String, pub description: String, pub when_to_use: Option, pub promoted_from: Option, pub author_id: Option, pub approved_by: Option, #[serde(with = "time::serde::rfc3339")] pub created_at: OffsetDateTime, } /// Effective skill binding for a specific agent — the merged view of /// (template role skills) + (agent overrides). Slice 3.5b's MCP /// server reads this to figure out what to expose. #[derive(Debug, Clone, Serialize)] pub struct AgentSkillBinding { pub skill: Skill, pub pin_in_context: bool, /// Where the binding came from — helpful for the level-up UI. /// "template" | "agent_override" | "agent_only" pub source: String, } // ── Upsert builtins ────────────────────────────────────────────── #[derive(Debug, Clone)] pub struct UpsertBuiltinSkill<'a> { /// Caller-provided deterministic id (sha256-derived in the loader). pub id: Uuid, pub name: &'a str, pub description: &'a str, pub when_to_use: Option<&'a str>, pub tags: Vec, pub body: &'a str, } /// Idempotent upsert for builtin skills. Bumps `current_version` + /// appends to `skill_versions` only when `body` actually changes. /// Never touches non-builtin rows. pub async fn upsert_builtin(pool: &PgPool, b: UpsertBuiltinSkill<'_>) -> Result { let mut tx = pool.begin().await?; // Look up the current stored body (if any) to decide whether the // upsert should bump the version. Builtins have workspace_id NULL. let existing: Option<(i32, String)> = sqlx::query( "SELECT current_version, body FROM skills WHERE id = $1 AND source_kind = 'builtin'", ) .bind(b.id) .fetch_optional(&mut *tx) .await? .map(|r| { ( r.get::("current_version"), r.get::("body"), ) }); let (next_version, bump) = match &existing { Some((v, prev)) if prev == b.body => (*v, false), Some((v, _)) => (v + 1, true), None => (1, true), }; // Legacy `skills` table (0001_init.sql) still requires `title` + // `author` — populate from `name` + a system marker until Slice 9 // drops the legacy columns. sqlx::query( "INSERT INTO skills (id, name, title, author, description, when_to_use, tags, source_kind, workspace_id, current_version, body) VALUES ($1,$2,$2,'system',$3,$4,$5,'builtin',NULL,$6,$7) ON CONFLICT (id) DO UPDATE SET name = EXCLUDED.name, title = EXCLUDED.title, description = EXCLUDED.description, when_to_use = EXCLUDED.when_to_use, tags = EXCLUDED.tags, current_version = EXCLUDED.current_version, body = EXCLUDED.body, updated_at = now()", ) .bind(b.id) .bind(b.name) .bind(b.description) .bind(b.when_to_use) .bind(&b.tags) .bind(next_version) .bind(b.body) .execute(&mut *tx) .await?; if bump { sqlx::query( "INSERT INTO skill_versions (skill_id, version, body_md, description, when_to_use) VALUES ($1,$2,$3,$4,$5) ON CONFLICT DO NOTHING", ) .bind(b.id) .bind(next_version) .bind(b.body) .bind(b.description) .bind(b.when_to_use) .execute(&mut *tx) .await?; } tx.commit().await?; Ok(b.id) } // ── Reads ──────────────────────────────────────────────────────── /// All builtin + workspace-scoped skills the caller can see. pub async fn list_visible(pool: &PgPool, workspace_id: Uuid) -> Result, DbError> { let rows = sqlx::query( "SELECT id, name, description, when_to_use, tags, source_kind, workspace_id, current_version, body, created_at, updated_at FROM skills WHERE workspace_id IS NULL OR workspace_id = $1 ORDER BY workspace_id NULLS FIRST, name ASC", ) .bind(workspace_id) .fetch_all(pool) .await?; Ok(rows.into_iter().map(row_to_skill).collect()) } pub async fn get(pool: &PgPool, id: Uuid) -> Result, DbError> { let row = sqlx::query( "SELECT id, name, description, when_to_use, tags, source_kind, workspace_id, current_version, body, created_at, updated_at FROM skills WHERE id = $1", ) .bind(id) .fetch_optional(pool) .await?; Ok(row.map(row_to_skill)) } pub async fn get_by_name( pool: &PgPool, workspace_id: Option, name: &str, ) -> Result, DbError> { // COALESCE-based unique lookup mirroring the partial UNIQUE index // in the migration. let sentinel: Uuid = "00000000-0000-0000-0000-000000000000".parse().unwrap(); let row = sqlx::query( "SELECT id, name, description, when_to_use, tags, source_kind, workspace_id, current_version, body, created_at, updated_at FROM skills WHERE COALESCE(workspace_id, $1::uuid) = COALESCE($2::uuid, $1::uuid) AND name = $3", ) .bind(sentinel) .bind(workspace_id) .bind(name) .fetch_optional(pool) .await?; Ok(row.map(row_to_skill)) } // ── Template-role bindings ─────────────────────────────────────── #[derive(Debug, Clone)] pub struct AttachRoleSkill<'a> { pub template_id: Uuid, pub slot: &'a str, pub skill_id: Uuid, pub pin_in_context: bool, pub order_idx: i32, } pub async fn attach_role_skill(pool: &PgPool, a: AttachRoleSkill<'_>) -> Result<(), DbError> { sqlx::query( "INSERT INTO template_role_skills (template_id, slot, skill_id, pin_in_context, order_idx) VALUES ($1,$2,$3,$4,$5) ON CONFLICT (template_id, slot, skill_id) DO UPDATE SET pin_in_context = EXCLUDED.pin_in_context, order_idx = EXCLUDED.order_idx", ) .bind(a.template_id) .bind(a.slot) .bind(a.skill_id) .bind(a.pin_in_context) .bind(a.order_idx) .execute(pool) .await?; Ok(()) } /// Wipe all skill bindings for a template's roles. Used by the /// template loader before it re-attaches the current-truth set from /// TOML, so a role that no longer references a skill loses that /// binding without an explicit detach call. pub async fn clear_template_role_skills(pool: &PgPool, template_id: Uuid) -> Result<(), DbError> { sqlx::query("DELETE FROM template_role_skills WHERE template_id = $1") .bind(template_id) .execute(pool) .await?; Ok(()) } // ── Agent-level overlay ────────────────────────────────────────── pub async fn set_agent_skill( pool: &PgPool, agent_id: Uuid, skill_id: Uuid, included: bool, pin_in_context: bool, added_by: Option, added_reason: Option<&str>, ) -> Result<(), DbError> { sqlx::query( "INSERT INTO agent_skills_ext (agent_id, skill_id, included, pin_in_context, added_by, added_reason) VALUES ($1,$2,$3,$4,$5,$6) ON CONFLICT (agent_id, skill_id) DO UPDATE SET included = EXCLUDED.included, pin_in_context = EXCLUDED.pin_in_context, added_by = COALESCE(EXCLUDED.added_by, agent_skills_ext.added_by), added_reason = COALESCE(EXCLUDED.added_reason, agent_skills_ext.added_reason)", ) .bind(agent_id) .bind(skill_id) .bind(included) .bind(pin_in_context) .bind(added_by) .bind(added_reason) .execute(pool) .await?; Ok(()) } /// Effective binding for the specified agent — merges template /// defaults with per-agent overrides. Excluded skills (agent_skills_ext.included=false) /// are filtered out here so callers see one flat truth list. pub async fn effective_for_agent( pool: &PgPool, agent_id: Uuid, template_id: Option, role_slot: Option<&str>, ) -> Result, DbError> { // Two queries + client-side merge — the join is straightforward // but the "template default overridden by agent" precedence is // cleaner in Rust than in SQL. In-workspace call so N stays small // (dozens, not thousands). let mut template_bindings: Vec<(Uuid, bool, i32)> = Vec::new(); if let (Some(tid), Some(slot)) = (template_id, role_slot) { let rows = sqlx::query( "SELECT skill_id, pin_in_context, order_idx FROM template_role_skills WHERE template_id = $1 AND slot = $2 ORDER BY order_idx ASC", ) .bind(tid) .bind(slot) .fetch_all(pool) .await?; template_bindings = rows .into_iter() .map(|r| { ( r.get::("skill_id"), r.get::("pin_in_context"), r.get::("order_idx"), ) }) .collect(); } let overrides: Vec<(Uuid, bool, bool)> = sqlx::query( "SELECT skill_id, included, pin_in_context FROM agent_skills_ext WHERE agent_id = $1", ) .bind(agent_id) .fetch_all(pool) .await? .into_iter() .map(|r| { ( r.get::("skill_id"), r.get::("included"), r.get::("pin_in_context"), ) }) .collect(); use std::collections::HashMap; let override_map: HashMap = overrides .iter() .map(|(id, inc, pin)| (*id, (*inc, *pin))) .collect(); // Build the final id set + per-id (pin, source) tuple. let mut ordered: Vec<(Uuid, bool, &'static str)> = Vec::new(); let mut seen = std::collections::HashSet::new(); for (id, tpl_pin, _order) in &template_bindings { let (included, pin) = match override_map.get(id) { Some((false, _)) => continue, // agent explicitly excluded Some((true, agent_pin)) => (true, *agent_pin || *tpl_pin), None => (true, *tpl_pin), }; if included && seen.insert(*id) { ordered.push((*id, pin, "template")); } } for (id, included, agent_pin) in &overrides { if !included || seen.contains(id) { continue; } if seen.insert(*id) { ordered.push((*id, *agent_pin, "agent_only")); } } if ordered.is_empty() { return Ok(Vec::new()); } // Batch-fetch the skill bodies in one query. let ids: Vec = ordered.iter().map(|(id, _, _)| *id).collect(); let skill_rows = sqlx::query( "SELECT id, name, description, when_to_use, tags, source_kind, workspace_id, current_version, body, created_at, updated_at FROM skills WHERE id = ANY($1)", ) .bind(&ids) .fetch_all(pool) .await?; let skill_map: HashMap = skill_rows .into_iter() .map(|r| { let s = row_to_skill(r); (s.id, s) }) .collect(); Ok(ordered .into_iter() .filter_map(|(id, pin, source)| { skill_map.get(&id).cloned().map(|skill| AgentSkillBinding { skill, pin_in_context: pin, source: source.to_string(), }) }) .collect()) } // ── Helpers ────────────────────────────────────────────────────── fn row_to_skill(r: sqlx::postgres::PgRow) -> Skill { Skill { id: r.get("id"), name: r.get("name"), description: r.get("description"), when_to_use: r.get("when_to_use"), tags: r.get("tags"), source_kind: r.get("source_kind"), workspace_id: r.get("workspace_id"), current_version: r.get("current_version"), body: r.get("body"), created_at: r.get("created_at"), updated_at: r.get("updated_at"), } }