Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion app/internal_action_interface.py
Original file line number Diff line number Diff line change
Expand Up @@ -480,6 +480,16 @@ async def do_create_task(
# Merge: skill-recommended + LLM-selected (deduplicated)
all_action_sets = list(dict.fromkeys(skill_action_sets + llm_action_sets))
logger.info(f"[TASK] Pre-selected skills (via command): {selected_skills}")
try:
from app.ui_layer.metrics.collector import MetricsCollector
collector = MetricsCollector.get_instance()
if collector:
logger.info(f"[TASK] Pre-selected skills collector initialized")
for skill_name in selected_skills:
collector.record_skill_invocation(skill_name)
except Exception:
pass

else:
# Select skills and action sets in a single LLM call (optimized)
# Skills are selected first, then action sets with knowledge of skill recommendations
Expand Down Expand Up @@ -901,7 +911,6 @@ async def _select_skills_and_action_sets_via_llm(
collector.record_skill_invocation(skill_name)
except Exception:
pass # Don't fail skill selection if metrics recording fails

return valid_skills, valid_sets

except json.JSONDecodeError as e:
Expand Down
26 changes: 26 additions & 0 deletions app/ui_layer/metrics/collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,7 @@ def __init__(self, agent: Optional["AgentBase"] = None) -> None:
# Storage references for historical data
self._usage_storage = None
self._task_storage = None
self._skill_storage = None
self._init_storage()

def _init_storage(self) -> None:
Expand All @@ -484,6 +485,26 @@ def _init_storage(self) -> None:
except Exception:
# Storage may not be available in all contexts
pass
try:
from app.usage.skill_storage import get_skill_storage

self._skill_storage = get_skill_storage()
except Exception:
pass
self._load_skill_metrics()

def _load_skill_metrics(self) -> None:
"""Restore skill invocation counts from persistent storage on startup."""
if not self._skill_storage:
return
try:
totals = self._skill_storage.get_skill_totals()
with self._lock:
for skill_name, count in totals.items():
self._skill_usage[skill_name] = count
self._skill_total_invocations = sum(totals.values())
except Exception:
pass

def _get_period_bounds(
self, period: TimePeriod
Expand Down Expand Up @@ -654,6 +675,11 @@ def record_skill_invocation(self, skill_name: str) -> None:
with self._lock:
self._skill_usage[skill_name] += 1
self._skill_total_invocations += 1
if self._skill_storage:
try:
self._skill_storage.insert_invocation(skill_name)
except Exception:
pass

def get_top_skills(self, limit: int = 3) -> List[Tuple[str, int]]:
"""Get top N most used skills."""
Expand Down
8 changes: 8 additions & 0 deletions app/usage/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@
get_action_storage,
)

from app.usage.skill_storage import (
SkillStorage,
get_skill_storage,
)

__all__ = [
# Storage
"UsageEvent",
Expand All @@ -57,4 +62,7 @@
"StoredActionItem",
"ActionStorage",
"get_action_storage",
# Skill Storage
"SkillStorage",
"get_skill_storage",
]
134 changes: 134 additions & 0 deletions app/usage/skill_storage.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
# -*- coding: utf-8 -*-
"""
app.usage.skill_storage

SQLite-based storage for skill invocation events.
Provides local persistence for skill usage history across restarts.
"""

from __future__ import annotations

import logging
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Dict, Optional

try:
from app.logger import logger
except Exception:
logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")


class SkillStorage:
"""
SQLite-based storage for skill invocation events.

Provides local persistence for skill usage history.
Events are stored in a SQLite database in app/data/.usage/skills.db.
"""

def __init__(self, db_path: Optional[str] = None):
if db_path is None:
from app.config import APP_DATA_PATH

usage_dir = Path(APP_DATA_PATH) / ".usage"
usage_dir.mkdir(parents=True, exist_ok=True)
db_path = str(usage_dir / "skills.db")

self._db_path = db_path
self._init_db()
logger.info(f"[SkillStorage] Initialized at {self._db_path}")

def _init_db(self) -> None:
"""Initialize the database schema."""
with sqlite3.connect(self._db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
CREATE TABLE IF NOT EXISTS skill_invocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
timestamp TEXT NOT NULL,
skill_name TEXT NOT NULL
)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_skill_timestamp
ON skill_invocations(timestamp)
""")
cursor.execute("""
CREATE INDEX IF NOT EXISTS idx_skill_name
ON skill_invocations(skill_name)
""")
conn.commit()

def insert_invocation(self, skill_name: str) -> int:
"""
Record a single skill invocation.

Args:
skill_name: Name of the skill that was invoked.

Returns:
The row ID of the inserted record.
"""
with sqlite3.connect(self._db_path) as conn:
cursor = conn.cursor()
cursor.execute(
"INSERT INTO skill_invocations (timestamp, skill_name) VALUES (?, ?)",
(datetime.now().isoformat(), skill_name),
)
conn.commit()
return cursor.lastrowid

def get_skill_totals(self) -> Dict[str, int]:
"""
Get all-time invocation counts grouped by skill name.

Returns:
Dict mapping skill_name -> total invocation count.
"""
with sqlite3.connect(self._db_path) as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT skill_name, COUNT(*) FROM skill_invocations GROUP BY skill_name
""")
return {row[0]: row[1] for row in cursor.fetchall()}

def get_stats(self) -> Dict:
"""Get storage statistics."""
with sqlite3.connect(self._db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM skill_invocations")
total = cursor.fetchone()[0]
cursor.execute("SELECT MIN(timestamp), MAX(timestamp) FROM skill_invocations")
row = cursor.fetchone()
return {
"db_path": self._db_path,
"total_invocations": total,
"earliest": row[0] if row[0] else None,
"latest": row[1] if row[1] else None,
}

def clear_invocations(self) -> int:
"""Clear all invocation records. Returns number deleted."""
with sqlite3.connect(self._db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT COUNT(*) FROM skill_invocations")
count = cursor.fetchone()[0]
cursor.execute("DELETE FROM skill_invocations")
conn.commit()
logger.info(f"[SkillStorage] Cleared {count} invocation records")
return count


# Global storage instance
_skill_storage: Optional[SkillStorage] = None


def get_skill_storage() -> SkillStorage:
"""Get the global skill storage instance."""
global _skill_storage
if _skill_storage is None:
_skill_storage = SkillStorage()
return _skill_storage