use async_trait::async_trait;
use std::path::PathBuf;
use crate::base::{AgentHook, BaseHook, LifecycleCapabilities, SessionEndCallback};
use crate::error::{HookError, Result};
use crate::monitor::ProcessMonitor;
use crate::session::{FileAction, FileInfo, SessionContext};
use crate::types::{AgentType, SessionActivity, SkillMetadata, SupportTier};
pub struct PiSkillsHook {
base: BaseHook,
skills_dir: Option<PathBuf>,
process_monitor: ProcessMonitor,
skill_installed: bool,
detected_skills: Vec<SkillMetadata>,
}
impl PiSkillsHook {
pub const AGENT_TYPE: &'static str = "pi-skills";
pub const SKILL_DIRS: &'static [&'static str] = &[".pi-skills", ".pi/skills", ".omp/skills"];
pub const KNOWN_SKILLS: &'static [&'static str] = &[
"brave-search",
"browser-tools",
"gccli",
"gdcli",
"gmcli",
"transcribe",
"vscode",
"youtube-transcript",
];
pub fn new() -> Self {
Self::new_with_install(true)
}
pub fn new_readonly() -> Self {
Self::new_with_install(false)
}
fn new_with_install(auto_install: bool) -> Self {
let skills_dir = Self::find_skills_dir();
let skill_installed = skills_dir
.as_ref()
.is_some_and(|dir| Self::skill_file_path(dir).exists());
let mut hook = Self {
base: BaseHook::new(Self::AGENT_TYPE),
skills_dir: skills_dir.clone(),
process_monitor: ProcessMonitor::new(),
skill_installed,
detected_skills: Vec::new(),
};
if let Some(ref dir) = skills_dir {
hook.discover_skills(dir);
}
if auto_install && !hook.skill_installed {
if let Some(ref dir) = skills_dir {
if let Err(e) = hook.install_skill(dir) {
tracing::warn!("Failed to install pi-skills skill: {}", e);
}
}
}
hook
}
fn skill_file_path(skills_dir: &std::path::Path) -> PathBuf {
skills_dir.join("nexus-memory-extraction").join("SKILL.md")
}
fn find_skills_dir() -> Option<PathBuf> {
let home = dirs::home_dir()?;
for dir_name in Self::SKILL_DIRS {
let dir = home.join(dir_name);
if dir.exists() {
return Some(dir);
}
}
None
}
fn discover_skills(&mut self, skills_dir: &PathBuf) {
if !skills_dir.exists() {
return;
}
if let Ok(entries) = std::fs::read_dir(skills_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let skill_md = entry.path().join("SKILL.md");
if skill_md.exists() {
if let Ok(content) = std::fs::read_to_string(&skill_md) {
if let Some(metadata) = self.parse_skill_metadata(&content) {
self.detected_skills.push(metadata);
}
}
}
}
}
}
fn parse_skill_metadata(&self, content: &str) -> Option<SkillMetadata> {
let content = content.trim();
if !content.starts_with("---") {
return None;
}
let end = content[3..].find("---")?;
let frontmatter = &content[3..end + 3];
let mut metadata = SkillMetadata::default();
for line in frontmatter.lines() {
if let Some((key, value)) = line.split_once(':') {
let key = key.trim();
let value = value.trim().trim_matches('"');
match key {
"name" => metadata.name = value.to_string(),
"description" => metadata.description = Some(value.to_string()),
"version" => metadata.version = Some(value.to_string()),
"author" => metadata.author = Some(value.to_string()),
_ => {}
}
}
}
if !metadata.name.is_empty() {
Some(metadata)
} else {
None
}
}
fn install_skill(&mut self, skills_dir: &PathBuf) -> Result<()> {
std::fs::create_dir_all(skills_dir).map_err(|e| {
HookError::InstallationFailed(format!("Failed to create skills dir: {}", e))
})?;
let skill_dir = skills_dir.join("nexus-memory-extraction");
std::fs::create_dir_all(&skill_dir).map_err(|e| {
HookError::InstallationFailed(format!("Failed to create skill dir: {}", e))
})?;
let skill_md = skill_dir.join("SKILL.md");
let skill_content = r#"---
name: nexus-memory-extraction
description: Automatically extract session context to Nexus Memory System
version: 1.0.0
author: Nexus Memory System
triggers:
- on_session_end
- on_checkpoint
---
# Nexus Memory Extraction Skill
Cross-compatible skill for extracting session context.
## Compatible Platforms
- pi-mono
- oh-my-pi
- Claude Code
- Codex CLI
- Amp
- Droid
## Usage
This skill runs automatically when sessions end.
## Configuration
Helper files available at: {baseDir}/
Set environment variables:
- `NEXUS_AUTO_INGEST=true`
- `NEXUS_SERVER_URL=http://localhost:8768`
"#;
std::fs::write(&skill_md, skill_content)
.map_err(|e| HookError::InstallationFailed(format!("Failed to write skill: {}", e)))?;
self.skill_installed = true;
tracing::info!("Pi-skills skill installed at: {:?}", skill_dir);
Ok(())
}
pub fn available_skills(&self) -> &[SkillMetadata] {
&self.detected_skills
}
pub fn has_skill(&self, name: &str) -> bool {
self.detected_skills.iter().any(|s| s.name == name)
}
pub fn get_skill(&self, name: &str) -> Option<&SkillMetadata> {
self.detected_skills.iter().find(|s| s.name == name)
}
}
impl Default for PiSkillsHook {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl AgentHook for PiSkillsHook {
fn agent_type(&self) -> &str {
&self.base.agent_type
}
async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
self.base.add_callback(callback);
self.base.installed = true;
Ok(())
}
async fn install_compact_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
self.base.add_callback(callback);
self.base.installed = true;
Ok(())
}
async fn detect_session_activity(&self) -> Result<SessionActivity> {
let mut monitor = self.process_monitor.clone();
let processes = monitor.find_agent_processes(AgentType::PiSkills);
let mut activity = SessionActivity::new(AgentType::PiSkills);
if !processes.is_empty() {
activity.is_active = true;
activity.processes = processes;
}
if let Some(ref dir) = self.skills_dir {
if dir.exists() {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.filter_map(|e| e.ok()) {
let skill_md = entry.path().join("SKILL.md");
if skill_md.exists() {
if let Ok(metadata) = std::fs::metadata(&skill_md) {
if let Ok(modified) = metadata.modified() {
let age = std::time::SystemTime::now()
.duration_since(modified)
.unwrap_or(std::time::Duration::MAX);
if age.as_secs() < 300 {
activity.is_active = true;
break;
}
}
}
}
}
}
}
}
Ok(activity)
}
async fn extract_session_context(&self) -> Result<SessionContext> {
let mut context = SessionContext::new("pi-skills")
.with_source("native")
.with_reliability(1.0);
let skill_names: Vec<String> = self
.detected_skills
.iter()
.map(|s| s.name.clone())
.collect();
context.add_custom(
"available_skills",
serde_json::to_value(&skill_names).unwrap_or(serde_json::Value::Null),
);
for skill in &self.detected_skills {
if let Some(ref desc) = skill.description {
context.add_insight(format!("Skill '{}': {}", skill.name, desc));
}
}
for known_skill in Self::KNOWN_SKILLS {
let is_available = self.has_skill(known_skill);
context.add_custom(
format!("skill_{}_available", known_skill.replace('-', "_")),
serde_json::Value::Bool(is_available),
);
}
if let Some(ref dir) = self.skills_dir {
let git_status = std::process::Command::new("git")
.args(["status", "--porcelain"])
.current_dir(dir)
.output()
.ok();
if let Some(output) = git_status {
if output.status.success() {
let status = String::from_utf8_lossy(&output.stdout);
for line in status.lines() {
if line.len() > 3 {
let file_path = &line[3..];
context.add_file(FileInfo::new(file_path, FileAction::Modified));
}
}
}
}
}
context.complete();
Ok(context)
}
fn is_hook_installed(&self) -> bool {
self.skill_installed
}
fn reliability_score(&self) -> f32 {
if self.skill_installed {
1.0
} else {
0.95
}
}
fn lifecycle_capabilities(&self) -> LifecycleCapabilities {
LifecycleCapabilities {
session_start: false,
session_end: true,
checkpoint: true,
error_hook: false,
compact: true,
}
}
fn support_tier(&self) -> SupportTier {
SupportTier::NativeLifecycle
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn test_pi_skills_hook_new() {
let hook = PiSkillsHook::new();
assert_eq!(hook.agent_type(), "pi-skills");
}
#[tokio::test]
async fn test_pi_skills_hook_detect_activity() {
let hook = PiSkillsHook::new();
let activity = hook.detect_session_activity().await.unwrap();
assert_eq!(activity.agent_type, AgentType::PiSkills);
}
#[test]
fn test_pi_skills_hook_constants() {
assert_eq!(PiSkillsHook::AGENT_TYPE, "pi-skills");
let known_skills = PiSkillsHook::KNOWN_SKILLS;
assert!(known_skills.contains(&"brave-search"));
assert!(known_skills.contains(&"transcribe"));
assert!(known_skills.contains(&"youtube-transcript"));
}
#[test]
fn test_pi_skills_hook_has_skill() {
let hook = PiSkillsHook::new();
assert!(!hook.has_skill("nonexistent-skill"));
}
#[test]
fn test_pi_skills_hook_lifecycle_capabilities() {
let hook = PiSkillsHook::new();
let caps = hook.lifecycle_capabilities();
assert!(
!caps.session_start,
"pi-skills does not support session_start"
);
assert!(caps.session_end, "pi-skills should support session_end");
assert!(caps.checkpoint, "pi-skills should support checkpoint");
assert!(!caps.error_hook, "pi-skills does not support error_hook");
assert!(caps.compact, "pi-skills should support compact via skills");
}
#[tokio::test]
async fn test_pi_skills_hook_install_compact_hook() {
let mut hook = PiSkillsHook::new();
let cb: SessionEndCallback = Arc::new(|_ctx| ());
let result = hook.install_compact_hook(cb).await;
assert!(
result.is_ok(),
"pi-skills should accept compact hook via skills"
);
}
}