Skip to main content

nexus_memory_hooks/agents/
pi_skills.rs

1//! Pi-Skills cross-compatible hook implementation (MANDATORY)
2//!
3//! Cross-compatible skills repository supporting multiple agent platforms.
4//!
5//! Repository: https://github.com/badlogic/pi-skills
6//! Compatible with: pi-mono, oh-my-pi, Claude Code, Codex CLI, Amp, Droid
7//!
8//! Available Skills:
9//! - brave-search
10//! - browser-tools
11//! - gccli, gdcli, gmcli
12//! - transcribe
13//! - vscode
14//! - youtube-transcript
15
16use async_trait::async_trait;
17use std::path::PathBuf;
18
19use crate::base::{AgentHook, BaseHook, LifecycleCapabilities, SessionEndCallback};
20use crate::error::{HookError, Result};
21use crate::monitor::ProcessMonitor;
22use crate::session::{FileAction, FileInfo, SessionContext};
23use crate::types::{AgentType, SessionActivity, SkillMetadata, SupportTier};
24
25/// Pi-Skills cross-compatible hook
26///
27/// Supports skills from the badlogic/pi-skills repository.
28/// Compatible with multiple agent platforms including pi-mono, oh-my-pi,
29/// Claude Code, Codex CLI, Amp, and Droid.
30///
31/// # Skills Format
32///
33/// Uses SKILL.md format with `{baseDir}` placeholder:
34/// ```markdown
35/// ---
36/// name: skill-name
37/// description: Short description
38/// ---
39///
40/// # Instructions
41/// Helper files at: {baseDir}/
42/// ```
43///
44/// # Available Skills
45///
46/// - brave-search: Web search via Brave API
47/// - browser-tools: Browser automation tools
48/// - gccli: Google Cloud CLI integration
49/// - gdcli: Google Drive CLI integration
50/// - gmcli: Gmail CLI integration
51/// - transcribe: Audio transcription
52/// - vscode: VS Code integration
53/// - youtube-transcript: YouTube video transcripts
54pub struct PiSkillsHook {
55    /// Base hook functionality
56    base: BaseHook,
57
58    /// Skills directory (may be None if not found)
59    skills_dir: Option<PathBuf>,
60
61    /// Process monitor
62    process_monitor: ProcessMonitor,
63
64    /// Whether skill is installed
65    skill_installed: bool,
66
67    /// Detected skills
68    detected_skills: Vec<SkillMetadata>,
69}
70
71impl PiSkillsHook {
72    /// Agent type string
73    pub const AGENT_TYPE: &'static str = "pi-skills";
74
75    /// Skills directory names to check
76    pub const SKILL_DIRS: &'static [&'static str] = &[".pi-skills", ".pi/skills", ".omp/skills"];
77
78    /// Known skills from pi-skills repository
79    pub const KNOWN_SKILLS: &'static [&'static str] = &[
80        "brave-search",
81        "browser-tools",
82        "gccli",
83        "gdcli",
84        "gmcli",
85        "transcribe",
86        "vscode",
87        "youtube-transcript",
88    ];
89
90    /// Create a new Pi-Skills hook
91    pub fn new() -> Self {
92        Self::new_with_install(true)
93    }
94
95    /// Create a new Pi-Skills hook without mutating user state.
96    pub fn new_readonly() -> Self {
97        Self::new_with_install(false)
98    }
99
100    fn new_with_install(auto_install: bool) -> Self {
101        let skills_dir = Self::find_skills_dir();
102        let skill_installed = skills_dir
103            .as_ref()
104            .is_some_and(|dir| Self::skill_file_path(dir).exists());
105
106        let mut hook = Self {
107            base: BaseHook::new(Self::AGENT_TYPE),
108            skills_dir: skills_dir.clone(),
109            process_monitor: ProcessMonitor::new(),
110            skill_installed,
111            detected_skills: Vec::new(),
112        };
113
114        // Discover available skills
115        if let Some(ref dir) = skills_dir {
116            hook.discover_skills(dir);
117        }
118
119        if auto_install && !hook.skill_installed {
120            if let Some(ref dir) = skills_dir {
121                if let Err(e) = hook.install_skill(dir) {
122                    tracing::warn!("Failed to install pi-skills skill: {}", e);
123                }
124            }
125        }
126
127        hook
128    }
129
130    fn skill_file_path(skills_dir: &std::path::Path) -> PathBuf {
131        skills_dir.join("nexus-memory-extraction").join("SKILL.md")
132    }
133
134    /// Find skills directory
135    fn find_skills_dir() -> Option<PathBuf> {
136        let home = dirs::home_dir()?;
137
138        for dir_name in Self::SKILL_DIRS {
139            let dir = home.join(dir_name);
140            if dir.exists() {
141                return Some(dir);
142            }
143        }
144
145        None
146    }
147
148    /// Discover available skills
149    fn discover_skills(&mut self, skills_dir: &PathBuf) {
150        if !skills_dir.exists() {
151            return;
152        }
153
154        if let Ok(entries) = std::fs::read_dir(skills_dir) {
155            for entry in entries.filter_map(|e| e.ok()) {
156                let skill_md = entry.path().join("SKILL.md");
157                if skill_md.exists() {
158                    if let Ok(content) = std::fs::read_to_string(&skill_md) {
159                        if let Some(metadata) = self.parse_skill_metadata(&content) {
160                            self.detected_skills.push(metadata);
161                        }
162                    }
163                }
164            }
165        }
166    }
167
168    /// Parse SKILL.md frontmatter
169    fn parse_skill_metadata(&self, content: &str) -> Option<SkillMetadata> {
170        let content = content.trim();
171
172        if !content.starts_with("---") {
173            return None;
174        }
175
176        let end = content[3..].find("---")?;
177        let frontmatter = &content[3..end + 3];
178
179        // Parse YAML frontmatter (simplified)
180        let mut metadata = SkillMetadata::default();
181
182        for line in frontmatter.lines() {
183            if let Some((key, value)) = line.split_once(':') {
184                let key = key.trim();
185                let value = value.trim().trim_matches('"');
186
187                match key {
188                    "name" => metadata.name = value.to_string(),
189                    "description" => metadata.description = Some(value.to_string()),
190                    "version" => metadata.version = Some(value.to_string()),
191                    "author" => metadata.author = Some(value.to_string()),
192                    _ => {}
193                }
194            }
195        }
196
197        if !metadata.name.is_empty() {
198            Some(metadata)
199        } else {
200            None
201        }
202    }
203
204    /// Install the nexus-memory-extraction skill
205    fn install_skill(&mut self, skills_dir: &PathBuf) -> Result<()> {
206        std::fs::create_dir_all(skills_dir).map_err(|e| {
207            HookError::InstallationFailed(format!("Failed to create skills dir: {}", e))
208        })?;
209
210        let skill_dir = skills_dir.join("nexus-memory-extraction");
211        std::fs::create_dir_all(&skill_dir).map_err(|e| {
212            HookError::InstallationFailed(format!("Failed to create skill dir: {}", e))
213        })?;
214
215        let skill_md = skill_dir.join("SKILL.md");
216
217        // Cross-compatible skill format
218        let skill_content = r#"---
219name: nexus-memory-extraction
220description: Automatically extract session context to Nexus Memory System
221version: 1.0.0
222author: Nexus Memory System
223triggers:
224  - on_session_end
225  - on_checkpoint
226---
227
228# Nexus Memory Extraction Skill
229
230Cross-compatible skill for extracting session context.
231
232## Compatible Platforms
233
234- pi-mono
235- oh-my-pi
236- Claude Code
237- Codex CLI
238- Amp
239- Droid
240
241## Usage
242
243This skill runs automatically when sessions end.
244
245## Configuration
246
247Helper files available at: {baseDir}/
248
249Set environment variables:
250- `NEXUS_AUTO_INGEST=true`
251- `NEXUS_SERVER_URL=http://localhost:8768`
252"#;
253
254        std::fs::write(&skill_md, skill_content)
255            .map_err(|e| HookError::InstallationFailed(format!("Failed to write skill: {}", e)))?;
256
257        self.skill_installed = true;
258        tracing::info!("Pi-skills skill installed at: {:?}", skill_dir);
259
260        Ok(())
261    }
262
263    /// Get list of available skills
264    pub fn available_skills(&self) -> &[SkillMetadata] {
265        &self.detected_skills
266    }
267
268    /// Check if a specific skill is available
269    pub fn has_skill(&self, name: &str) -> bool {
270        self.detected_skills.iter().any(|s| s.name == name)
271    }
272
273    /// Get skill by name
274    pub fn get_skill(&self, name: &str) -> Option<&SkillMetadata> {
275        self.detected_skills.iter().find(|s| s.name == name)
276    }
277}
278
279impl Default for PiSkillsHook {
280    fn default() -> Self {
281        Self::new()
282    }
283}
284
285#[async_trait]
286impl AgentHook for PiSkillsHook {
287    fn agent_type(&self) -> &str {
288        &self.base.agent_type
289    }
290
291    async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
292        self.base.add_callback(callback);
293        self.base.installed = true;
294
295        Ok(())
296    }
297
298    async fn install_compact_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
299        self.base.add_callback(callback);
300        self.base.installed = true;
301
302        Ok(())
303    }
304
305    async fn detect_session_activity(&self) -> Result<SessionActivity> {
306        let mut monitor = self.process_monitor.clone();
307        let processes = monitor.find_agent_processes(AgentType::PiSkills);
308
309        let mut activity = SessionActivity::new(AgentType::PiSkills);
310
311        if !processes.is_empty() {
312            activity.is_active = true;
313            activity.processes = processes;
314        }
315
316        // Check for skills directory activity
317        if let Some(ref dir) = self.skills_dir {
318            if dir.exists() {
319                if let Ok(entries) = std::fs::read_dir(dir) {
320                    for entry in entries.filter_map(|e| e.ok()) {
321                        let skill_md = entry.path().join("SKILL.md");
322                        if skill_md.exists() {
323                            if let Ok(metadata) = std::fs::metadata(&skill_md) {
324                                if let Ok(modified) = metadata.modified() {
325                                    let age = std::time::SystemTime::now()
326                                        .duration_since(modified)
327                                        .unwrap_or(std::time::Duration::MAX);
328
329                                    if age.as_secs() < 300 {
330                                        activity.is_active = true;
331                                        break;
332                                    }
333                                }
334                            }
335                        }
336                    }
337                }
338            }
339        }
340
341        Ok(activity)
342    }
343
344    async fn extract_session_context(&self) -> Result<SessionContext> {
345        let mut context = SessionContext::new("pi-skills")
346            .with_source("native")
347            .with_reliability(1.0);
348
349        // Add detected skills info
350        let skill_names: Vec<String> = self
351            .detected_skills
352            .iter()
353            .map(|s| s.name.clone())
354            .collect();
355
356        context.add_custom(
357            "available_skills",
358            serde_json::to_value(&skill_names).unwrap_or(serde_json::Value::Null),
359        );
360
361        // Add skill details
362        for skill in &self.detected_skills {
363            if let Some(ref desc) = skill.description {
364                context.add_insight(format!("Skill '{}': {}", skill.name, desc));
365            }
366        }
367
368        // Check for known skills availability
369        for known_skill in Self::KNOWN_SKILLS {
370            let is_available = self.has_skill(known_skill);
371            context.add_custom(
372                format!("skill_{}_available", known_skill.replace('-', "_")),
373                serde_json::Value::Bool(is_available),
374            );
375        }
376
377        // Get git status for skills repo if it's a git repo
378        if let Some(ref dir) = self.skills_dir {
379            let git_status = std::process::Command::new("git")
380                .args(["status", "--porcelain"])
381                .current_dir(dir)
382                .output()
383                .ok();
384
385            if let Some(output) = git_status {
386                if output.status.success() {
387                    let status = String::from_utf8_lossy(&output.stdout);
388                    for line in status.lines() {
389                        if line.len() > 3 {
390                            let file_path = &line[3..];
391                            context.add_file(FileInfo::new(file_path, FileAction::Modified));
392                        }
393                    }
394                }
395            }
396        }
397
398        context.complete();
399        Ok(context)
400    }
401
402    fn is_hook_installed(&self) -> bool {
403        self.skill_installed
404    }
405
406    fn reliability_score(&self) -> f32 {
407        if self.skill_installed {
408            1.0
409        } else {
410            0.95
411        }
412    }
413
414    fn lifecycle_capabilities(&self) -> LifecycleCapabilities {
415        LifecycleCapabilities {
416            session_start: false,
417            session_end: true,
418            checkpoint: true,
419            error_hook: false,
420            compact: true,
421        }
422    }
423
424    fn support_tier(&self) -> SupportTier {
425        SupportTier::NativeLifecycle
426    }
427}
428
429#[cfg(test)]
430mod tests {
431    use super::*;
432    use std::sync::Arc;
433
434    #[test]
435    fn test_pi_skills_hook_new() {
436        let hook = PiSkillsHook::new();
437        assert_eq!(hook.agent_type(), "pi-skills");
438    }
439
440    #[tokio::test]
441    async fn test_pi_skills_hook_detect_activity() {
442        let hook = PiSkillsHook::new();
443        let activity = hook.detect_session_activity().await.unwrap();
444
445        assert_eq!(activity.agent_type, AgentType::PiSkills);
446    }
447
448    #[test]
449    fn test_pi_skills_hook_constants() {
450        assert_eq!(PiSkillsHook::AGENT_TYPE, "pi-skills");
451
452        let known_skills = PiSkillsHook::KNOWN_SKILLS;
453        assert!(known_skills.contains(&"brave-search"));
454        assert!(known_skills.contains(&"transcribe"));
455        assert!(known_skills.contains(&"youtube-transcript"));
456    }
457
458    #[test]
459    fn test_pi_skills_hook_has_skill() {
460        let hook = PiSkillsHook::new();
461
462        // Should not have unknown skill
463        assert!(!hook.has_skill("nonexistent-skill"));
464    }
465
466    #[test]
467    fn test_pi_skills_hook_lifecycle_capabilities() {
468        let hook = PiSkillsHook::new();
469        let caps = hook.lifecycle_capabilities();
470
471        assert!(
472            !caps.session_start,
473            "pi-skills does not support session_start"
474        );
475        assert!(caps.session_end, "pi-skills should support session_end");
476        assert!(caps.checkpoint, "pi-skills should support checkpoint");
477        assert!(!caps.error_hook, "pi-skills does not support error_hook");
478        assert!(caps.compact, "pi-skills should support compact via skills");
479    }
480
481    #[tokio::test]
482    async fn test_pi_skills_hook_install_compact_hook() {
483        let mut hook = PiSkillsHook::new();
484        let cb: SessionEndCallback = Arc::new(|_ctx| ());
485        let result = hook.install_compact_hook(cb).await;
486        assert!(
487            result.is_ok(),
488            "pi-skills should accept compact hook via skills"
489        );
490    }
491}