Skip to main content

nexus_memory_hooks/agents/
oh_my_pi.rs

1//! Oh-My-Pi (OMP) hook implementation (MANDATORY)
2//!
3//! Oh-my-pi is a fork of pi-mono with additional features including
4//! Rust N-API native addon support.
5//!
6//! Repository: https://github.com/can1357/oh-my-pi
7//! Stack: TypeScript, Bun runtime, Rust N-API
8//! Config: ~/.omp/agent/skills/, .omp/skills/
9//! Detection: `omp` or `oh-my-pi` process
10//!
11//! Features:
12//! - Native Rust engine: grep, shell, text, keys, highlight, glob, task, ps, prof, clipboard
13//! - LSP integration with format-on-write
14//! - Browser automation (Puppeteer with stealth)
15//! - Task tool (subagent system)
16//! - TTSR (Time Traveling Streamed Rules)
17
18use async_trait::async_trait;
19use std::path::PathBuf;
20
21use crate::base::{AgentHook, BaseHook, LifecycleCapabilities, SessionEndCallback};
22use crate::error::{HookError, Result};
23use crate::monitor::ProcessMonitor;
24use crate::session::{
25    FileAction, FileInfo, SessionContext, SubagentExecution, TaskInfo, TaskStatus,
26};
27use crate::types::{AgentType, SessionActivity, SupportTier};
28
29/// Oh-My-Pi hook for extracting memory from OMP session execution.
30///
31/// Oh-My-Pi is a fork of pi-mono with additional features and modifications.
32/// It maintains similar session management but has:
33/// - Different CLI name (omp instead of pi)
34/// - Different session storage location
35/// - Rust N-API native addon support
36///
37/// # Detection Paths
38///
39/// - ~/.local/bin/omp
40/// - /usr/local/bin/omp
41/// - $PATH
42///
43/// # Session Files
44///
45/// - ~/.omp/sessions/ - Session history
46/// - ~/.omp/logs/ - Centralized logs
47///
48/// # Native Features (Rust N-API)
49///
50/// - grep, shell, text, keys, highlight, glob, task, ps, prof, clipboard
51/// - LSP integration
52/// - Browser automation
53pub struct OhMyPiHook {
54    /// Base hook functionality
55    base: BaseHook,
56
57    /// Config directory
58    config_dir: PathBuf,
59
60    /// Session directory
61    session_dir: PathBuf,
62
63    /// Skills directory
64    skills_dir: PathBuf,
65
66    /// Process monitor
67    process_monitor: ProcessMonitor,
68
69    /// Whether skill is installed
70    skill_installed: bool,
71
72    /// Has native engine (Rust N-API)
73    has_native_engine: bool,
74}
75
76impl OhMyPiHook {
77    /// Agent type string
78    pub const AGENT_TYPE: &'static str = "oh-my-pi";
79
80    /// Config directory name
81    pub const CONFIG_DIR_NAME: &'static str = ".omp";
82
83    /// Skills subdirectory
84    pub const SKILLS_SUBDIR: &'static str = "agent/skills";
85
86    /// Sessions subdirectory
87    pub const SESSIONS_SUBDIR: &'static str = "sessions";
88
89    /// Logs subdirectory
90    pub const LOGS_SUBDIR: &'static str = "logs";
91
92    /// Create a new Oh-My-Pi hook
93    pub fn new() -> Self {
94        Self::new_with_install(true)
95    }
96
97    /// Create a new Oh-My-Pi hook without mutating user state.
98    pub fn new_readonly() -> Self {
99        Self::new_with_install(false)
100    }
101
102    fn new_with_install(auto_install: bool) -> Self {
103        let config_dir = dirs::home_dir()
104            .unwrap_or_else(|| PathBuf::from("."))
105            .join(Self::CONFIG_DIR_NAME);
106
107        let session_dir = config_dir.join(Self::SESSIONS_SUBDIR);
108        let skills_dir = config_dir.join(Self::SKILLS_SUBDIR);
109        let skill_installed = Self::skill_file_path(&skills_dir).exists();
110
111        let mut hook = Self {
112            base: BaseHook::new(Self::AGENT_TYPE),
113            config_dir,
114            session_dir,
115            skills_dir,
116            process_monitor: ProcessMonitor::new(),
117            skill_installed,
118            has_native_engine: Self::detect_native_engine(),
119        };
120
121        if auto_install && !hook.skill_installed {
122            if let Err(e) = hook.install_skill() {
123                tracing::warn!("Failed to install oh-my-pi skill: {}", e);
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    /// Detect if native Rust engine is available
135    fn detect_native_engine() -> bool {
136        // Check for native addon presence
137        if let Some(home) = dirs::home_dir() {
138            let native_addon = home
139                .join(Self::CONFIG_DIR_NAME)
140                .join("native")
141                .join("libnexus_native.so");
142
143            if native_addon.exists() {
144                return true;
145            }
146
147            // Also check for .node addon
148            let node_addon = home
149                .join(Self::CONFIG_DIR_NAME)
150                .join("native")
151                .join("nexus_native.node");
152
153            if node_addon.exists() {
154                return true;
155            }
156        }
157
158        false
159    }
160
161    /// Install the nexus-memory-extraction skill with TTSR support
162    fn install_skill(&mut self) -> Result<()> {
163        std::fs::create_dir_all(&self.skills_dir).map_err(|e| {
164            HookError::InstallationFailed(format!("Failed to create skills dir: {}", e))
165        })?;
166
167        let skill_dir = self.skills_dir.join("nexus-memory-extraction");
168        std::fs::create_dir_all(&skill_dir).map_err(|e| {
169            HookError::InstallationFailed(format!("Failed to create skill dir: {}", e))
170        })?;
171
172        let skill_md = Self::skill_file_path(&self.skills_dir);
173
174        // Oh-my-pi supports TTSR (Time Traveling Streamed Rules)
175        let skill_content = r#"---
176name: nexus-memory-extraction
177description: Automatically extract session context to Nexus Memory System
178version: 1.0.0
179author: Nexus Memory System
180triggers:
181  - on_session_end
182  - on_checkpoint
183  - on_completion
184  - on_error
185priority: high
186---
187
188# Nexus Memory Extraction Skill (Oh-My-Pi)
189
190This skill automatically extracts session context when oh-my-pi sessions end.
191
192## Features
193
194- **Native Rust Integration**: Works with OMP's native engine
195- **TTSR Support**: Time Traveling Streamed Rules for complex workflows
196- **Full Context Capture**: Conversations, decisions, files, commands
197
198## Native Engine Features
199
200The skill leverages OMP's native Rust engine for:
201- `grep`: Fast searching
202- `shell`: Command execution
203- `glob`: File pattern matching
204- `task`: Subagent management
205
206## Configuration
207
208Set environment variables:
209- `NEXUS_AUTO_INGEST=true`
210- `NEXUS_SERVER_URL=http://localhost:8768`
211"#;
212
213        std::fs::write(&skill_md, skill_content)
214            .map_err(|e| HookError::InstallationFailed(format!("Failed to write skill: {}", e)))?;
215
216        self.skill_installed = true;
217        tracing::info!("Oh-my-pi skill installed at: {:?}", skill_dir);
218
219        Ok(())
220    }
221
222    /// Read session files
223    fn read_session_files(&self) -> Vec<serde_json::Value> {
224        let mut sessions = Vec::new();
225
226        if !self.session_dir.exists() {
227            return sessions;
228        }
229
230        if let Ok(entries) = std::fs::read_dir(&self.session_dir) {
231            let mut session_files: Vec<_> = entries
232                .filter_map(|e| e.ok())
233                .filter(|e| {
234                    e.path()
235                        .extension()
236                        .map(|ext| ext == "json")
237                        .unwrap_or(false)
238                })
239                .collect();
240
241            session_files.sort_by(|a, b| {
242                let time_a = a.metadata().ok().and_then(|m| m.modified().ok());
243                let time_b = b.metadata().ok().and_then(|m| m.modified().ok());
244                time_b.cmp(&time_a)
245            });
246
247            for entry in session_files.into_iter().take(10) {
248                if let Ok(content) = std::fs::read_to_string(entry.path()) {
249                    if let Ok(data) = serde_json::from_str(&content) {
250                        sessions.push(data);
251                    }
252                }
253            }
254        }
255
256        sessions
257    }
258
259    /// Read log files from centralized log directory
260    fn read_log_files(&self) -> Vec<String> {
261        let mut commands = Vec::new();
262        let logs_dir = self.config_dir.join(Self::LOGS_SUBDIR);
263
264        if !logs_dir.exists() {
265            return commands;
266        }
267
268        if let Ok(entries) = std::fs::read_dir(&logs_dir) {
269            let mut log_files: Vec<_> = entries
270                .filter_map(|e| e.ok())
271                .filter(|e| {
272                    e.path()
273                        .extension()
274                        .map(|ext| ext == "log" || ext == "txt")
275                        .unwrap_or(false)
276                })
277                .collect();
278
279            log_files.sort_by(|a, b| {
280                let time_a = a.metadata().ok().and_then(|m| m.modified().ok());
281                let time_b = b.metadata().ok().and_then(|m| m.modified().ok());
282                time_b.cmp(&time_a)
283            });
284
285            for entry in log_files.into_iter().take(5) {
286                if let Ok(content) = std::fs::read_to_string(entry.path()) {
287                    for line in content.lines() {
288                        if line.contains("Executing:")
289                            || line.contains("Command:")
290                            || line.contains("OMP:")
291                        {
292                            commands.push(line.to_string());
293                        }
294                    }
295                }
296            }
297        }
298
299        commands
300    }
301
302    /// Read OMP configuration
303    fn read_config(&self) -> Option<serde_json::Value> {
304        let config_file = self.config_dir.join("config.json");
305
306        if config_file.exists() {
307            let content = std::fs::read_to_string(&config_file).ok()?;
308            serde_json::from_str(&content).ok()
309        } else {
310            None
311        }
312    }
313
314    /// Check if native feature is available
315    pub fn has_native_feature(&self, feature: &str) -> bool {
316        if !self.has_native_engine {
317            return false;
318        }
319
320        matches!(
321            feature,
322            "grep"
323                | "shell"
324                | "text"
325                | "keys"
326                | "highlight"
327                | "glob"
328                | "task"
329                | "ps"
330                | "prof"
331                | "clipboard"
332        )
333    }
334
335    /// Get list of available native features
336    pub fn native_features(&self) -> &'static [&'static str] {
337        &[
338            "grep",
339            "shell",
340            "text",
341            "keys",
342            "highlight",
343            "glob",
344            "task",
345            "ps",
346            "prof",
347            "clipboard",
348        ]
349    }
350}
351
352impl Default for OhMyPiHook {
353    fn default() -> Self {
354        Self::new()
355    }
356}
357
358#[async_trait]
359impl AgentHook for OhMyPiHook {
360    fn agent_type(&self) -> &str {
361        &self.base.agent_type
362    }
363
364    async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
365        self.base.add_callback(callback);
366        self.base.installed = true;
367
368        Ok(())
369    }
370
371    async fn install_compact_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
372        self.base.add_callback(callback);
373        self.base.installed = true;
374
375        Ok(())
376    }
377
378    async fn detect_session_activity(&self) -> Result<SessionActivity> {
379        let mut monitor = self.process_monitor.clone();
380        let processes = monitor.find_agent_processes(AgentType::OhMyPi);
381
382        let mut activity = SessionActivity::new(AgentType::OhMyPi);
383
384        if !processes.is_empty() {
385            activity.is_active = true;
386            activity.processes = processes;
387        }
388
389        // Check for recent session file activity
390        if self.session_dir.exists() {
391            if let Ok(entries) = std::fs::read_dir(&self.session_dir) {
392                if let Some(most_recent) = entries
393                    .filter_map(|e| e.ok())
394                    .filter(|e| {
395                        e.path()
396                            .extension()
397                            .map(|ext| ext == "json")
398                            .unwrap_or(false)
399                    })
400                    .max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
401                {
402                    if let Ok(metadata) = most_recent.metadata() {
403                        if let Ok(modified) = metadata.modified() {
404                            let age = std::time::SystemTime::now()
405                                .duration_since(modified)
406                                .unwrap_or(std::time::Duration::MAX);
407
408                            if age.as_secs() < 300 {
409                                activity.is_active = true;
410                                activity.session_id = Some(
411                                    most_recent
412                                        .path()
413                                        .file_stem()
414                                        .unwrap()
415                                        .to_string_lossy()
416                                        .to_string(),
417                                );
418                            }
419                        }
420                    }
421                }
422            }
423        }
424
425        // Check for OMP-specific extensions
426        let ext_check = std::process::Command::new("pgrep")
427            .arg("-f")
428            .arg("omp-agent|oh-my-skill")
429            .output()
430            .ok();
431
432        if let Some(output) = ext_check {
433            if output.status.success() && !output.stdout.is_empty() {
434                activity.is_active = true;
435            }
436        }
437
438        Ok(activity)
439    }
440
441    async fn extract_session_context(&self) -> Result<SessionContext> {
442        let mut context = SessionContext::new("oh-my-pi")
443            .with_source("native")
444            .with_reliability(1.0);
445
446        // Track fork-specific features
447        let mut fork_features: std::collections::HashMap<String, i32> =
448            std::collections::HashMap::new();
449
450        // Add native engine info
451        context.add_custom(
452            "has_native_engine",
453            serde_json::Value::Bool(self.has_native_engine),
454        );
455
456        if self.has_native_engine {
457            context.add_custom(
458                "native_features",
459                serde_json::to_value(self.native_features()).unwrap_or(serde_json::Value::Null),
460            );
461        }
462
463        // Extract from session files
464        for session_data in self.read_session_files() {
465            // Extract session info
466            if let Some(timestamp) = session_data.get("timestamp").and_then(|t| t.as_str()) {
467                context.add_custom(
468                    "session_timestamp",
469                    serde_json::Value::String(timestamp.to_string()),
470                );
471            }
472
473            // Extract tasks with OMP-specific features
474            if let Some(tasks) = session_data.get("tasks").and_then(|t| t.as_array()) {
475                for task in tasks {
476                    let description = task
477                        .get("description")
478                        .and_then(|d| d.as_str())
479                        .unwrap_or("");
480                    let feature = task
481                        .get("feature")
482                        .or_else(|| task.get("role"))
483                        .and_then(|r| r.as_str())
484                        .unwrap_or("unknown");
485
486                    let mut task_info = TaskInfo::new(description);
487                    task_info.subagent = Some(feature.to_string());
488
489                    if let Some(status) = task.get("status").and_then(|s| s.as_str()) {
490                        task_info.status = match status {
491                            "completed" => TaskStatus::Completed,
492                            "failed" => TaskStatus::Failed,
493                            "in_progress" => TaskStatus::InProgress,
494                            _ => TaskStatus::Pending,
495                        };
496                    }
497
498                    context.tasks.push(task_info);
499
500                    // Track fork-specific features
501                    *fork_features.entry(feature.to_string()).or_insert(0) += 1;
502
503                    // Add as subagent execution
504                    context.subagent_executions.push(SubagentExecution {
505                        subagent_type: feature.to_string(),
506                        task: description.to_string(),
507                        status: "completed".to_string(),
508                        started_at: chrono::Utc::now(),
509                        completed_at: Some(chrono::Utc::now()),
510                        result_summary: None,
511                    });
512                }
513            }
514
515            // Extract files modified
516            if let Some(files) = session_data
517                .get("files_modified")
518                .and_then(|f| f.as_array())
519            {
520                for file in files {
521                    if let Some(path) = file.as_str() {
522                        context.add_file(FileInfo::new(path, FileAction::Modified));
523                    }
524                }
525            }
526
527            // Extract extensions used (OMP-specific)
528            if let Some(extensions) = session_data
529                .get("extensions_used")
530                .and_then(|e| e.as_array())
531            {
532                for ext in extensions {
533                    if let Some(ext_str) = ext.as_str() {
534                        context.add_custom(
535                            format!("extension_{}", ext_str),
536                            serde_json::Value::Bool(true),
537                        );
538                    }
539                }
540            }
541        }
542
543        // Extract commands from logs
544        for cmd in self.read_log_files() {
545            context.add_command(cmd);
546        }
547
548        // Store fork feature stats
549        context.add_custom(
550            "fork_features",
551            serde_json::to_value(&fork_features).unwrap_or(serde_json::Value::Null),
552        );
553
554        // Read OMP config
555        if let Some(config) = self.read_config() {
556            context.add_custom("config", config);
557        }
558
559        // Get git status
560        let git_status = std::process::Command::new("git")
561            .args(["status", "--porcelain"])
562            .output()
563            .ok();
564
565        if let Some(output) = git_status {
566            if output.status.success() {
567                let status = String::from_utf8_lossy(&output.stdout);
568                for line in status.lines() {
569                    if line.len() > 3 {
570                        let file_path = &line[3..];
571                        context.add_file(FileInfo::new(file_path, FileAction::Modified));
572                    }
573                }
574            }
575        }
576
577        context.complete();
578        Ok(context)
579    }
580
581    fn is_hook_installed(&self) -> bool {
582        self.skill_installed
583    }
584
585    fn reliability_score(&self) -> f32 {
586        if self.skill_installed {
587            1.0
588        } else {
589            0.95
590        }
591    }
592
593    fn lifecycle_capabilities(&self) -> LifecycleCapabilities {
594        LifecycleCapabilities {
595            session_start: false,
596            session_end: true,
597            checkpoint: true,
598            error_hook: true,
599            compact: true,
600        }
601    }
602
603    fn support_tier(&self) -> SupportTier {
604        SupportTier::NativeLifecycle
605    }
606}
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use std::sync::Arc;
612
613    #[test]
614    fn test_oh_my_pi_hook_new() {
615        let hook = OhMyPiHook::new();
616        assert_eq!(hook.agent_type(), "oh-my-pi");
617    }
618
619    #[tokio::test]
620    async fn test_oh_my_pi_hook_detect_activity() {
621        let hook = OhMyPiHook::new();
622        let activity = hook.detect_session_activity().await.unwrap();
623
624        assert_eq!(activity.agent_type, AgentType::OhMyPi);
625    }
626
627    #[test]
628    fn test_oh_my_pi_hook_constants() {
629        assert_eq!(OhMyPiHook::AGENT_TYPE, "oh-my-pi");
630        assert_eq!(OhMyPiHook::CONFIG_DIR_NAME, ".omp");
631        assert_eq!(OhMyPiHook::SKILLS_SUBDIR, "agent/skills");
632    }
633
634    #[test]
635    fn test_oh_my_pi_hook_native_features() {
636        let hook = OhMyPiHook::new();
637        let features = hook.native_features();
638
639        assert!(features.contains(&"grep"));
640        assert!(features.contains(&"shell"));
641        assert!(features.contains(&"task"));
642    }
643
644    #[test]
645    fn test_oh_my_pi_hook_has_native_feature() {
646        let hook = OhMyPiHook::new();
647
648        // May or may not have native engine, but should handle the check
649        if hook.has_native_engine {
650            assert!(hook.has_native_feature("grep"));
651            assert!(hook.has_native_feature("shell"));
652        }
653
654        assert!(!hook.has_native_feature("unknown"));
655    }
656
657    #[test]
658    fn test_oh_my_pi_hook_lifecycle_capabilities() {
659        let hook = OhMyPiHook::new();
660        let caps = hook.lifecycle_capabilities();
661
662        assert!(
663            !caps.session_start,
664            "oh-my-pi does not support session_start"
665        );
666        assert!(
667            caps.session_end,
668            "oh-my-pi should support session_end via skills"
669        );
670        assert!(
671            caps.checkpoint,
672            "oh-my-pi should support checkpoint via skills"
673        );
674        assert!(
675            caps.error_hook,
676            "oh-my-pi should support error_hook via skills"
677        );
678        assert!(caps.compact, "oh-my-pi should support compact via skills");
679    }
680
681    #[tokio::test]
682    async fn test_oh_my_pi_hook_install_compact_hook() {
683        let mut hook = OhMyPiHook::new();
684        let cb: SessionEndCallback = Arc::new(|_ctx| ());
685        let result = hook.install_compact_hook(cb).await;
686        assert!(
687            result.is_ok(),
688            "oh-my-pi should accept compact hook via skills"
689        );
690    }
691}