112 lines
3.6 KiB
Rust
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)
|
|
}
|
|
}
|