Files
IdeA/crates/domain/src/skill.rs

112 lines
3.6 KiB
Rust

//! Skill entity — reusable, model-agnostic workflows assignable to agents.
//!
//! A [`Skill`] is IdeA's universal equivalent of a CLI's slash-command, but
//! without any dependency on a particular model's `/command` syntax
//! (ARCHITECTURE §14.2). Assigned skills are injected as plain text into the
//! agent's generated convention file at activation — there is no proprietary
//! CLI mechanism involved.
use serde::{Deserialize, Serialize};
use crate::error::DomainError;
use crate::ids::SkillId;
use crate::markdown::MarkdownDoc;
/// Where a skill lives, which also selects the store used to resolve it.
///
/// - [`SkillScope::Global`] skills are stored in the global IDE store
/// (`<app_data>/IdeA/skills/`) and reusable across projects.
/// - [`SkillScope::Project`] skills are stored under `.ideai/skills/` and are
/// specific to one project.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum SkillScope {
/// Reusable across projects (global IDE store).
Global,
/// Specific to a single project (`.ideai/skills/`).
Project,
}
/// A reusable workflow assignable to one or more agents.
///
/// Invariants enforced here:
/// - `name` non-empty,
/// - `content_md` non-empty (an empty skill carries no behaviour).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Skill {
/// Stable identifier.
pub id: SkillId,
/// Display name (also used as the `.md` file stem on disk).
pub name: String,
/// Markdown body — the workflow injected into an agent's convention file.
pub content_md: MarkdownDoc,
/// Scope (selects the backing store).
pub scope: SkillScope,
}
impl Skill {
/// Builds a validated skill.
///
/// # Errors
/// - [`DomainError::EmptyField`] if `name` is empty,
/// - [`DomainError::EmptyField`] if `content_md` is empty.
pub fn new(
id: SkillId,
name: impl Into<String>,
content_md: MarkdownDoc,
scope: SkillScope,
) -> Result<Self, DomainError> {
let name = name.into();
crate::validation::non_empty(&name, "skill.name")?;
if content_md.is_empty() {
return Err(DomainError::EmptyField {
field: "skill.content_md",
});
}
Ok(Self {
id,
name,
content_md,
scope,
})
}
/// Returns a copy of this skill with replaced content, re-validating the
/// non-empty invariant.
///
/// # Errors
/// [`DomainError::EmptyField`] if `content_md` is empty.
pub fn with_content(&self, content_md: MarkdownDoc) -> Result<Self, DomainError> {
Skill::new(self.id, self.name.clone(), content_md, self.scope)
}
}
/// A reference from an agent to one assigned skill.
///
/// Stored in the [`crate::agent::ManifestEntry`]: an agent carries 0..N of these.
/// The `scope` is kept alongside the id so the application layer knows which
/// store to resolve the skill from without a global lookup.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SkillRef {
/// The assigned skill.
pub skill_id: SkillId,
/// Scope of the assigned skill (selects its store).
pub scope: SkillScope,
}
impl SkillRef {
/// Builds a reference to an assigned skill.
#[must_use]
pub const fn new(skill_id: SkillId, scope: SkillScope) -> Self {
Self { skill_id, scope }
}
}
impl From<&Skill> for SkillRef {
fn from(skill: &Skill) -> Self {
Self::new(skill.id, skill.scope)
}
}