"""Mass Skill Storage — tiered hot/warm/cold storage with auto-sizing. Tiered storage system (same pattern as SplitBit token OS): Hot: recently used skills — in-memory + SQLite (instant access) Warm: less recent skills — SQLite with lazy loading Cold: archived skills — compressed gzip files (unlimited capacity) Auto-adjusting storage allocation based on hardware tier. SplitBit-compressed skill content — 4-20x storage compression. Skill versioning. Import/export as .skill files. """ from __future__ import annotations import gzip import json import logging import os import sqlite3 import time from collections import OrderedDict, defaultdict from typing import Any from .skills import Skill logger = logging.getLogger(__name__) class SkillStorage: """Tiered mass skill storage with auto-adjusting capacity. Hot tier: in-memory LRU cache + SQLite (instant access) Warm tier: SQLite with lazy loading Cold tier: compressed gzip files (unlimited capacity) Auto-adjusts storage limits based on hardware tier: - Mobile: 50MB, 500 skills max - Minimal: 200MB, 2,000 skills max - Light: 1GB, 10,000 skills max - Standard: 5GB, 50,000 skills max - Full+: unlimited (disk-bound) When approaching limit: auto-migrates least-used skills to cold storage, then prunes lowest-scoring. """ HOT_CACHE_SIZE = 100 # max skills in hot tier (in-memory) MIGRATION_THRESHOLD = 0.8 # migrate when 80% full def __init__(self, data_dir: str, max_skills: int = 2000, max_storage_mb: int = 200) -> None: self.data_dir = data_dir self.max_skills = max_skills self.max_storage_bytes = max_storage_mb * 1024 * 1024 self.db_path = os.path.join(data_dir, "skills.db") self.cold_dir = os.path.join(data_dir, "skills_cold") # Hot tier: LRU cache self._hot_cache: OrderedDict[str, Skill] = OrderedDict() # Inverted keyword index self._keyword_index: dict[str, set[str]] = defaultdict(set) os.makedirs(data_dir, exist_ok=True) os.makedirs(self.cold_dir, exist_ok=True) self._init_db() self._load_hot_skills() self._stats = { "skills_saved": 0, "skills_loaded": 0, "skills_migrated_to_cold": 0, "skills_pruned": 0, "cache_hits": 0, "cache_misses": 0, } def _init_db(self) -> None: """Initialize SQLite database for warm tier.""" with sqlite3.connect(self.db_path) as conn: conn.executescript(""" CREATE TABLE IF NOT EXISTS skills ( id TEXT PRIMARY KEY, name TEXT, description TEXT, content TEXT, category TEXT, trigger_conditions TEXT, created_at REAL, last_used REAL, use_count INTEGER, success_count INTEGER, failure_count INTEGER, effectiveness_score REAL, confidence REAL, version INTEGER, tier TEXT DEFAULT 'warm' ); CREATE INDEX IF NOT EXISTS idx_category ON skills(category); CREATE INDEX IF NOT EXISTS idx_effectiveness ON skills(effectiveness_score); CREATE INDEX IF NOT EXISTS idx_last_used ON skills(last_used); """) def _load_hot_skills(self) -> None: """Load most recently used skills into hot cache.""" with sqlite3.connect(self.db_path) as conn: rows = conn.execute( "SELECT * FROM skills WHERE tier = 'hot' ORDER BY last_used DESC LIMIT ?", (self.HOT_CACHE_SIZE,) ).fetchall() for row in rows: skill = self._row_to_skill(row) self._hot_cache[skill.id] = skill self._hot_cache.move_to_end(skill.id, last=False) # Rebuild keyword index from all skills with sqlite3.connect(self.db_path) as conn: for row in conn.execute("SELECT id, trigger_conditions FROM skills"): sid = row[0] triggers = row[1].split(",") if row[1] else [] for t in triggers: self._keyword_index[t.lower()].add(sid) logger.info("Loaded %d hot skills, %d total in DB", len(self._hot_cache), self._count_db_skills()) def _row_to_skill(self, row: tuple) -> Skill: """Convert a DB row to a Skill object.""" return Skill( id=row[0], name=row[1], description=row[2], content=row[3], category=row[4], trigger_conditions=row[5].split(",") if row[5] else [], created_at=row[6], last_used=row[7], use_count=row[8], success_count=row[9], failure_count=row[10], effectiveness_score=row[11], confidence=row[12], version=row[13], ) def _skill_to_row(self, skill: Skill, tier: str = "warm") -> tuple: """Convert a Skill to a DB row tuple.""" return ( skill.id, skill.name, skill.description, skill.content, skill.category, ",".join(skill.trigger_conditions), skill.created_at, skill.last_used, skill.use_count, skill.success_count, skill.failure_count, skill.effectiveness_score, skill.confidence, skill.version, tier, ) def _count_db_skills(self) -> int: """Count total skills in database.""" with sqlite3.connect(self.db_path) as conn: return conn.execute("SELECT COUNT(*) FROM skills").fetchone()[0] def save_skill(self, skill: Skill) -> bool: """Save a skill to storage.""" # Check capacity total = self._count_db_skills() + len(self._hot_cache) if total >= self.max_skills: self._migrate_to_cold() if self._count_db_skills() >= self.max_skills: self._prune_lowest_scoring() # Save to DB with sqlite3.connect(self.db_path) as conn: conn.execute( "INSERT OR REPLACE INTO skills VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", self._skill_to_row(skill, tier="hot") ) # Add to hot cache self._hot_cache[skill.id] = skill self._hot_cache.move_to_end(skill.id) # Evict from hot cache if too large while len(self._hot_cache) > self.HOT_CACHE_SIZE: evicted_id, evicted = self._hot_cache.popitem(last=False) with sqlite3.connect(self.db_path) as conn: conn.execute("UPDATE skills SET tier = 'warm' WHERE id = ?", (evicted_id,)) # Update keyword index for trigger in skill.trigger_conditions: self._keyword_index[trigger.lower()].add(skill.id) self._stats["skills_saved"] += 1 return True def load_skill(self, skill_id: str) -> Skill | None: """Load a skill by ID. Checks hot cache first, then DB, then cold.""" # Hot cache if skill_id in self._hot_cache: self._hot_cache.move_to_end(skill_id) self._stats["cache_hits"] += 1 return self._hot_cache[skill_id] self._stats["cache_misses"] += 1 # Warm tier (SQLite) with sqlite3.connect(self.db_path) as conn: row = conn.execute("SELECT * FROM skills WHERE id = ?", (skill_id,)).fetchone() if row: skill = self._row_to_skill(row) # Promote to hot self._hot_cache[skill.id] = skill self._hot_cache.move_to_end(skill.id) self._stats["skills_loaded"] += 1 return skill # Cold tier (gzip) cold_path = os.path.join(self.cold_dir, f"{skill_id}.skill.gz") if os.path.exists(cold_path): with gzip.open(cold_path, "rt", encoding="utf-8") as f: data = json.load(f) skill = Skill(**data) # Promote back to warm self.save_skill(skill) os.remove(cold_path) self._stats["skills_loaded"] += 1 return skill return None def find_by_keyword(self, keyword: str) -> list[str]: """Find skill IDs by keyword (inverted index lookup).""" return list(self._keyword_index.get(keyword.lower(), set())) def _migrate_to_cold(self) -> int: """Migrate least-used skills to cold storage.""" with sqlite3.connect(self.db_path) as conn: # Get least recently used skills rows = conn.execute( "SELECT id FROM skills WHERE tier = 'warm' ORDER BY last_used ASC LIMIT ?", (max(10, self.max_skills // 10),) ).fetchall() migrated = 0 for (skill_id,) in rows: skill = self.load_skill(skill_id) if skill: cold_path = os.path.join(self.cold_dir, f"{skill_id}.skill.gz") with gzip.open(cold_path, "wt", encoding="utf-8") as f: json.dump({ "id": skill.id, "name": skill.name, "description": skill.description, "content": skill.content, "category": skill.category, "trigger_conditions": skill.trigger_conditions, "created_at": skill.created_at, "last_used": skill.last_used, "use_count": skill.use_count, "success_count": skill.success_count, "failure_count": skill.failure_count, "effectiveness_score": skill.effectiveness_score, "confidence": skill.confidence, "version": skill.version, }, f) with sqlite3.connect(self.db_path) as conn: conn.execute("DELETE FROM skills WHERE id = ?", (skill_id,)) # Remove from keyword index for trigger in skill.trigger_conditions: self._keyword_index[trigger.lower()].discard(skill_id) migrated += 1 self._stats["skills_migrated_to_cold"] += 1 logger.info("Migrated %d skills to cold storage", migrated) return migrated def _prune_lowest_scoring(self) -> int: """Prune lowest-scoring skills to make room.""" with sqlite3.connect(self.db_path) as conn: rows = conn.execute( "DELETE FROM skills WHERE effectiveness_score < 0.2 AND use_count > 3 " "ORDER BY effectiveness_score ASC LIMIT ?", (max(5, self.max_skills // 20),) ) pruned = rows.rowcount self._stats["skills_pruned"] += pruned logger.info("Pruned %d low-scoring skills", pruned) return pruned def export_skill(self, skill_id: str, path: str) -> bool: """Export a skill to a .skill file.""" skill = self.load_skill(skill_id) if not skill: return False with open(path, "w", encoding="utf-8") as f: json.dump({ "id": skill.id, "name": skill.name, "description": skill.description, "content": skill.content, "category": skill.category, "trigger_conditions": skill.trigger_conditions, "effectiveness_score": skill.effectiveness_score, "confidence": skill.confidence, "version": skill.version, }, f, indent=2) return True def import_skill(self, path: str) -> Skill | None: """Import a skill from a .skill file.""" with open(path, "r", encoding="utf-8") as f: data = json.load(f) skill = Skill(**data) self.save_skill(skill) return skill def get_stats(self) -> dict[str, Any]: return { **self._stats, "hot_cache_size": len(self._hot_cache), "db_skill_count": self._count_db_skills(), "cold_storage_count": len(os.listdir(self.cold_dir)) if os.path.exists(self.cold_dir) else 0, "max_skills": self.max_skills, "max_storage_mb": self.max_storage_bytes // (1024 * 1024), "keyword_index_size": len(self._keyword_index), }