use std::path::Path;
use anyhow::{Context, Result};
use rsclaw_store::redb_store::RedbStore;
pub const RETIRE_AFTER_DAYS: i64 = 14;
fn key(name: &str) -> String {
format!("skillstat:{name}")
}
#[derive(serde::Serialize, serde::Deserialize, Default)]
struct SkillStat {
uses: u64,
last_used_ms: i64,
}
fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
pub fn record_skill_use(db: &RedbStore, name: &str) -> Result<()> {
let k = key(name);
let mut stat: SkillStat = db
.kv_get(&k)?
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default();
stat.uses += 1;
stat.last_used_ms = now_ms();
db.kv_set(&k, &serde_json::to_string(&stat)?)?;
Ok(())
}
pub fn retire_unused_auto_skills(db: &RedbStore, skills_dir: &Path) -> Result<Vec<String>> {
retire_unused_auto_skills_at(db, skills_dir, now_ms())
}
fn retire_unused_auto_skills_at(
db: &RedbStore,
skills_dir: &Path,
now: i64,
) -> Result<Vec<String>> {
let mut retired = Vec::new();
let entries = match std::fs::read_dir(skills_dir) {
Ok(e) => e,
Err(_) => return Ok(retired), };
let cutoff_ms = now - RETIRE_AFTER_DAYS * 24 * 3600 * 1000;
for entry in entries.flatten() {
let path = entry.path();
let Some(name) = path.file_name().and_then(|n| n.to_str()).map(str::to_owned) else {
continue;
};
if !path.is_dir() || !name.starts_with("auto-") {
continue;
}
let skill_md = path.join("SKILL.md");
if !skill_md.is_file() {
continue;
}
let last_used_ms = db
.kv_get(&key(&name))?
.and_then(|raw| serde_json::from_str::<SkillStat>(&raw).ok())
.map(|s| s.last_used_ms)
.unwrap_or(0);
let created_ms = skill_md
.metadata()
.and_then(|m| m.modified())
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_millis() as i64)
.unwrap_or(0);
if last_used_ms.max(created_ms) >= cutoff_ms {
continue;
}
let retired_dir = skills_dir.join(".retired");
std::fs::create_dir_all(&retired_dir)
.with_context(|| format!("create {}", retired_dir.display()))?;
let mut dest = retired_dir.join(&name);
let mut suffix = 1;
while dest.exists() {
dest = retired_dir.join(format!("{name}-{suffix}"));
suffix += 1;
}
std::fs::rename(&path, &dest)
.with_context(|| format!("retire {} -> {}", path.display(), dest.display()))?;
tracing::info!(skill = %name, dest = %dest.display(), "retired unused auto-skill");
retired.push(name);
}
Ok(retired)
}
#[cfg(test)]
mod tests {
use super::*;
fn store() -> (RedbStore, tempfile::TempDir) {
let tmp = tempfile::tempdir().unwrap();
let db = RedbStore::open(&tmp.path().join("kv.redb"), rsclaw_platform::MemoryTier::High).unwrap();
(db, tmp)
}
fn make_skill(dir: &Path, name: &str) {
let d = dir.join(name);
std::fs::create_dir_all(&d).unwrap();
std::fs::write(d.join("SKILL.md"), "---\nname: x\n---\nbody").unwrap();
}
#[test]
fn record_and_accumulate() {
let (db, _t) = store();
record_skill_use(&db, "auto-x").unwrap();
record_skill_use(&db, "auto-x").unwrap();
let raw = db.kv_get("skillstat:auto-x").unwrap().unwrap();
let s: SkillStat = serde_json::from_str(&raw).unwrap();
assert_eq!(s.uses, 2);
assert!(s.last_used_ms > 0);
}
#[test]
fn retire_only_stale_auto_skills() {
let (db, _t) = store();
let skills = tempfile::tempdir().unwrap();
make_skill(skills.path(), "auto-old-unused");
make_skill(skills.path(), "auto-recently-used");
make_skill(skills.path(), "hand-installed");
let future = now_ms() + (RETIRE_AFTER_DAYS + 1) * 24 * 3600 * 1000;
db.kv_set(
"skillstat:auto-recently-used",
&serde_json::to_string(&SkillStat { uses: 3, last_used_ms: future - 3_600_000 })
.unwrap(),
)
.unwrap();
let retired = retire_unused_auto_skills_at(&db, skills.path(), future).unwrap();
assert_eq!(retired, vec!["auto-old-unused".to_string()]);
assert!(!skills.path().join("auto-old-unused").exists());
assert!(
skills
.path()
.join(".retired")
.join("auto-old-unused")
.join("SKILL.md")
.is_file()
);
assert!(skills.path().join("hand-installed").exists());
assert!(skills.path().join("auto-recently-used").exists());
}
}