use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
use crate::error::Result;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum SkillLifecycle {
Active,
Stale,
Archived,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMeta {
pub name: String,
pub lifecycle: SkillLifecycle,
pub created_at: chrono::DateTime<chrono::Utc>,
pub last_used_at: chrono::DateTime<chrono::Utc>,
pub last_modified_at: chrono::DateTime<chrono::Utc>,
pub pinned: bool,
pub agent_created: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CuratorConfig {
pub stale_days: u64,
pub archive_days: u64,
pub enabled: bool,
}
impl Default for CuratorConfig {
fn default() -> Self {
Self {
stale_days: 30,
archive_days: 90,
enabled: true,
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CuratorState {
pub skills: HashMap<String, SkillMeta>,
pub last_run_at: Option<chrono::DateTime<chrono::Utc>>,
}
impl CuratorState {
pub fn load(path: &PathBuf) -> Self {
std::fs::read_to_string(path)
.ok()
.and_then(|data| serde_json::from_str(&data).ok())
.unwrap_or_default()
}
pub fn save(&self, path: &PathBuf) -> Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let data = serde_json::to_string_pretty(self)?;
std::fs::write(path, data)?;
Ok(())
}
}
pub struct Curator {
config: CuratorConfig,
state_path: PathBuf,
}
impl Curator {
pub fn new(config: CuratorConfig, state_path: impl Into<PathBuf>) -> Self {
Self {
config,
state_path: state_path.into(),
}
}
pub fn default_path(config: CuratorConfig) -> Self {
let home = std::env::var("HOME")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."));
let path = home.join(".echo-agent").join("curator_state.json");
Self::new(config, path)
}
pub fn load_state(&self) -> CuratorState {
CuratorState::load(&self.state_path)
}
pub fn save_state(&self, state: &CuratorState) -> Result<()> {
state.save(&self.state_path)
}
pub fn touch_skill(&self, name: &str, agent_created: bool) -> Result<()> {
let mut state = self.load_state();
let now = chrono::Utc::now();
if let Some(meta) = state.skills.get_mut(name) {
meta.last_used_at = now;
} else {
state.skills.insert(
name.to_string(),
SkillMeta {
name: name.to_string(),
lifecycle: SkillLifecycle::Active,
created_at: now,
last_used_at: now,
last_modified_at: now,
pinned: false,
agent_created,
},
);
}
self.save_state(&state)
}
pub fn pin_skill(&self, name: &str) -> Result<()> {
let mut state = self.load_state();
if let Some(meta) = state.skills.get_mut(name) {
meta.pinned = true;
self.save_state(&state)?;
}
Ok(())
}
pub fn unpin_skill(&self, name: &str) -> Result<()> {
let mut state = self.load_state();
if let Some(meta) = state.skills.get_mut(name) {
meta.pinned = false;
self.save_state(&state)?;
}
Ok(())
}
pub fn apply_transitions(&self) -> Result<Vec<(String, SkillLifecycle, SkillLifecycle)>> {
if !self.config.enabled {
return Ok(vec![]);
}
let mut state = self.load_state();
let now = chrono::Utc::now();
let mut transitions = Vec::new();
for meta in state.skills.values_mut() {
if meta.pinned || !meta.agent_created {
continue;
}
let idle_days = (now - meta.last_used_at).num_days() as u64;
let new_lifecycle = match meta.lifecycle {
SkillLifecycle::Active if idle_days >= self.config.stale_days => {
Some(SkillLifecycle::Stale)
}
SkillLifecycle::Stale if idle_days >= self.config.archive_days => {
Some(SkillLifecycle::Archived)
}
_ => None,
};
if let Some(new_lc) = new_lifecycle {
transitions.push((meta.name.clone(), meta.lifecycle, new_lc));
meta.lifecycle = new_lc;
}
}
state.last_run_at = Some(now);
self.save_state(&state)?;
Ok(transitions)
}
pub fn status(&self) -> CuratorStatus {
let state = self.load_state();
let mut active = 0;
let mut stale = 0;
let mut archived = 0;
let mut pinned = 0;
for meta in state.skills.values() {
if meta.pinned {
pinned += 1;
}
match meta.lifecycle {
SkillLifecycle::Active => active += 1,
SkillLifecycle::Stale => stale += 1,
SkillLifecycle::Archived => archived += 1,
}
}
CuratorStatus {
total: state.skills.len(),
active,
stale,
archived,
pinned,
last_run_at: state.last_run_at,
}
}
}
#[derive(Debug, Clone)]
pub struct CuratorStatus {
pub total: usize,
pub active: usize,
pub stale: usize,
pub archived: usize,
pub pinned: usize,
pub last_run_at: Option<chrono::DateTime<chrono::Utc>>,
}
#[cfg(test)]
mod tests {
use super::*;
fn temp_curator() -> Curator {
let dir = std::env::temp_dir().join(format!("echo_curator_test_{}", uuid::Uuid::new_v4()));
let path = dir.join("curator_state.json");
Curator::new(CuratorConfig::default(), path)
}
#[test]
fn test_touch_and_status() {
let curator = temp_curator();
curator.touch_skill("test-skill", true).unwrap();
let status = curator.status();
assert_eq!(status.total, 1);
assert_eq!(status.active, 1);
assert_eq!(status.stale, 0);
assert_eq!(status.archived, 0);
}
#[test]
fn test_pin_unpin() {
let curator = temp_curator();
curator.touch_skill("test-skill", true).unwrap();
curator.pin_skill("test-skill").unwrap();
let state = curator.load_state();
assert!(state.skills["test-skill"].pinned);
curator.unpin_skill("test-skill").unwrap();
let state = curator.load_state();
assert!(!state.skills["test-skill"].pinned);
}
#[test]
fn test_no_transition_when_fresh() {
let curator = temp_curator();
curator.touch_skill("fresh-skill", true).unwrap();
let transitions = curator.apply_transitions().unwrap();
assert!(transitions.is_empty());
let status = curator.status();
assert_eq!(status.active, 1);
}
#[test]
fn test_skip_non_agent_created() {
let curator = temp_curator();
curator.touch_skill("bundled-skill", false).unwrap();
let mut state = curator.load_state();
state.skills.get_mut("bundled-skill").unwrap().last_used_at =
chrono::Utc::now() - chrono::Duration::days(100);
curator.save_state(&state).unwrap();
let transitions = curator.apply_transitions().unwrap();
assert!(transitions.is_empty());
}
#[test]
fn test_stale_transition() {
let curator = temp_curator();
curator.touch_skill("old-skill", true).unwrap();
let mut state = curator.load_state();
state.skills.get_mut("old-skill").unwrap().last_used_at =
chrono::Utc::now() - chrono::Duration::days(31);
curator.save_state(&state).unwrap();
let transitions = curator.apply_transitions().unwrap();
assert_eq!(transitions.len(), 1);
assert_eq!(transitions[0].1, SkillLifecycle::Active);
assert_eq!(transitions[0].2, SkillLifecycle::Stale);
let status = curator.status();
assert_eq!(status.stale, 1);
}
#[test]
fn test_pinned_skips_transition() {
let curator = temp_curator();
curator.touch_skill("pinned-skill", true).unwrap();
curator.pin_skill("pinned-skill").unwrap();
let mut state = curator.load_state();
state.skills.get_mut("pinned-skill").unwrap().last_used_at =
chrono::Utc::now() - chrono::Duration::days(100);
curator.save_state(&state).unwrap();
let transitions = curator.apply_transitions().unwrap();
assert!(transitions.is_empty());
let status = curator.status();
assert_eq!(status.active, 1);
assert_eq!(status.pinned, 1);
}
}