use lago_core::{BranchId, EventEnvelope, EventId, EventPayload, SessionId};
use serde::{Deserialize, Serialize};
use serde_json::json;
use std::collections::HashMap;
pub mod event_types {
pub const SKILL_DISCOVERED: &str = "skill.discovered";
pub const SKILL_ACTIVATED: &str = "skill.activated";
pub const SKILL_DEACTIVATED: &str = "skill.deactivated";
pub const SKILL_MCP_CONNECTED: &str = "skill.mcp_connected";
pub const SKILL_MCP_DISCONNECTED: &str = "skill.mcp_disconnected";
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillDiscoveredData {
pub count: usize,
pub dirs: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillActivatedData {
pub name: String,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub tags: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub allowed_tools: Option<Vec<String>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mcp_servers: Option<Vec<String>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillDeactivatedData {
pub name: String,
pub duration_ms: u64,
pub reason: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SkillMcpData {
pub skill_name: String,
pub server_name: String,
pub tools_count: usize,
}
fn build_skill_event(
session_id: &SessionId,
branch_id: &BranchId,
event_type: &str,
data: serde_json::Value,
) -> EventEnvelope {
EventEnvelope {
event_id: EventId::new(),
session_id: session_id.clone(),
branch_id: branch_id.clone(),
run_id: None,
seq: 0, timestamp: EventEnvelope::now_micros(),
parent_id: None,
payload: EventPayload::Custom {
event_type: event_type.to_string(),
data,
},
metadata: HashMap::new(),
schema_version: 1,
}
}
pub async fn emit_skill_discovered(
journal: &dyn lago_core::Journal,
session_id: &SessionId,
branch_id: &BranchId,
count: usize,
dirs: &[std::path::PathBuf],
) -> Result<(), lago_core::LagoError> {
let data = SkillDiscoveredData {
count,
dirs: dirs.iter().map(|d| d.display().to_string()).collect(),
};
let event = build_skill_event(
session_id,
branch_id,
event_types::SKILL_DISCOVERED,
json!(data),
);
journal.append(event).await?;
Ok(())
}
pub async fn emit_skill_activated(
journal: &dyn lago_core::Journal,
session_id: &SessionId,
branch_id: &BranchId,
name: &str,
tags: &[String],
allowed_tools: Option<&[String]>,
mcp_server_names: Option<&[String]>,
) -> Result<(), lago_core::LagoError> {
let data = SkillActivatedData {
name: name.to_string(),
tags: tags.to_vec(),
allowed_tools: allowed_tools.map(<[String]>::to_vec),
mcp_servers: mcp_server_names.map(<[String]>::to_vec),
};
let event = build_skill_event(
session_id,
branch_id,
event_types::SKILL_ACTIVATED,
json!(data),
);
journal.append(event).await?;
Ok(())
}
pub async fn emit_skill_deactivated(
journal: &dyn lago_core::Journal,
session_id: &SessionId,
branch_id: &BranchId,
name: &str,
duration_ms: u64,
reason: &str,
) -> Result<(), lago_core::LagoError> {
let data = SkillDeactivatedData {
name: name.to_string(),
duration_ms,
reason: reason.to_string(),
};
let event = build_skill_event(
session_id,
branch_id,
event_types::SKILL_DEACTIVATED,
json!(data),
);
journal.append(event).await?;
Ok(())
}
pub async fn emit_skill_mcp_connected(
journal: &dyn lago_core::Journal,
session_id: &SessionId,
branch_id: &BranchId,
skill_name: &str,
server_name: &str,
tools_count: usize,
) -> Result<(), lago_core::LagoError> {
let data = SkillMcpData {
skill_name: skill_name.to_string(),
server_name: server_name.to_string(),
tools_count,
};
let event = build_skill_event(
session_id,
branch_id,
event_types::SKILL_MCP_CONNECTED,
json!(data),
);
journal.append(event).await?;
Ok(())
}
#[derive(Debug, Default, Clone)]
pub struct SkillProjection {
pub activations: HashMap<String, u64>,
pub last_activated: HashMap<String, u64>,
pub mcp_connections: u64,
}
impl SkillProjection {
pub fn new() -> Self {
Self::default()
}
pub fn fold(&mut self, event: &EventEnvelope) {
if let EventPayload::Custom {
ref event_type,
ref data,
} = event.payload
{
match event_type.as_str() {
event_types::SKILL_ACTIVATED => {
if let Some(name) = data.get("name").and_then(|v| v.as_str()) {
*self.activations.entry(name.to_string()).or_default() += 1;
self.last_activated
.insert(name.to_string(), event.timestamp);
}
}
event_types::SKILL_MCP_CONNECTED => {
self.mcp_connections += 1;
}
_ => {}
}
}
}
pub fn most_activated(&self) -> Option<(&str, u64)> {
self.activations
.iter()
.max_by_key(|(_, count)| *count)
.map(|(name, count)| (name.as_str(), *count))
}
}
pub fn ingest_skill_to_blob_store(
blob_store: &lago_store::BlobStore,
_skill_name: &str,
skill_content: &str,
) -> Result<lago_core::BlobHash, lago_core::LagoError> {
blob_store.put(skill_content.as_bytes())
}
pub fn skills_to_manifest_entries(
blob_store: &lago_store::BlobStore,
skills: &[(String, String)], ) -> Vec<(String, lago_core::BlobHash)> {
let mut entries = Vec::new();
for (name, content) in skills {
match ingest_skill_to_blob_store(blob_store, name, content) {
Ok(blob_hash) => {
let path = format!("skills/{}/SKILL.md", name);
entries.push((path, blob_hash));
tracing::debug!(skill = %name, "ingested skill into blob store");
}
Err(e) => {
tracing::warn!(
skill = %name,
error = %e,
"failed to ingest skill into blob store"
);
}
}
}
entries
}
#[cfg(test)]
mod tests {
use super::*;
fn make_custom_event(event_type: &str, data: serde_json::Value) -> EventEnvelope {
EventEnvelope {
event_id: EventId::new(),
session_id: SessionId::from_string("test"),
branch_id: BranchId::from_string("main"),
run_id: None,
seq: 1,
timestamp: EventEnvelope::now_micros(),
parent_id: None,
payload: EventPayload::Custom {
event_type: event_type.to_string(),
data,
},
metadata: HashMap::new(),
schema_version: 1,
}
}
#[test]
fn skill_projection_tracks_activations() {
let mut proj = SkillProjection::new();
proj.fold(&make_custom_event(
"skill.activated",
json!({ "name": "commit-helper", "tags": ["git"] }),
));
proj.fold(&make_custom_event(
"skill.activated",
json!({ "name": "commit-helper", "tags": ["git"] }),
));
proj.fold(&make_custom_event(
"skill.activated",
json!({ "name": "test-runner", "tags": ["testing"] }),
));
assert_eq!(proj.activations["commit-helper"], 2);
assert_eq!(proj.activations["test-runner"], 1);
assert_eq!(proj.most_activated(), Some(("commit-helper", 2)));
}
#[test]
fn skill_projection_tracks_mcp() {
let mut proj = SkillProjection::new();
proj.fold(&make_custom_event(
"skill.mcp_connected",
json!({ "skill_name": "db-admin", "server_name": "postgres", "tools_count": 3 }),
));
assert_eq!(proj.mcp_connections, 1);
}
#[test]
fn skill_projection_ignores_unrelated_events() {
let mut proj = SkillProjection::new();
proj.fold(&make_custom_event(
"autonomic.mode_changed",
json!({ "mode": "Sovereign" }),
));
assert!(proj.activations.is_empty());
assert_eq!(proj.mcp_connections, 0);
}
#[test]
fn skill_event_data_serialization() {
let data = SkillActivatedData {
name: "test".to_string(),
tags: vec!["a".to_string()],
allowed_tools: Some(vec!["read_file".to_string()]),
mcp_servers: None,
};
let json = serde_json::to_value(&data).unwrap();
assert_eq!(json["name"], "test");
assert_eq!(json["tags"], json!(["a"]));
assert_eq!(json["allowed_tools"], json!(["read_file"]));
assert!(json.get("mcp_servers").is_none());
}
#[test]
fn build_skill_event_structure() {
let event = build_skill_event(
&SessionId::from_string("sess-1"),
&BranchId::from_string("main"),
event_types::SKILL_ACTIVATED,
json!({ "name": "test" }),
);
assert_eq!(event.session_id.as_str(), "sess-1");
assert_eq!(event.branch_id.as_str(), "main");
assert!(event.schema_version == 1);
if let EventPayload::Custom {
event_type, data, ..
} = &event.payload
{
assert_eq!(event_type, "skill.activated");
assert_eq!(data["name"], "test");
} else {
panic!("expected Custom payload");
}
}
}