nexus_memory_hooks/agents/
pi_skills.rs1use 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
25pub struct PiSkillsHook {
55 base: BaseHook,
57
58 skills_dir: Option<PathBuf>,
60
61 process_monitor: ProcessMonitor,
63
64 skill_installed: bool,
66
67 detected_skills: Vec<SkillMetadata>,
69}
70
71impl PiSkillsHook {
72 pub const AGENT_TYPE: &'static str = "pi-skills";
74
75 pub const SKILL_DIRS: &'static [&'static str] = &[".pi-skills", ".pi/skills", ".omp/skills"];
77
78 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 pub fn new() -> Self {
92 Self::new_with_install(true)
93 }
94
95 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 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 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 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 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 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 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 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 pub fn available_skills(&self) -> &[SkillMetadata] {
265 &self.detected_skills
266 }
267
268 pub fn has_skill(&self, name: &str) -> bool {
270 self.detected_skills.iter().any(|s| s.name == name)
271 }
272
273 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 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 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 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 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 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 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}