nexus_memory_hooks/agents/
oh_my_pi.rs1use 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
29pub struct OhMyPiHook {
54 base: BaseHook,
56
57 config_dir: PathBuf,
59
60 session_dir: PathBuf,
62
63 skills_dir: PathBuf,
65
66 process_monitor: ProcessMonitor,
68
69 skill_installed: bool,
71
72 has_native_engine: bool,
74}
75
76impl OhMyPiHook {
77 pub const AGENT_TYPE: &'static str = "oh-my-pi";
79
80 pub const CONFIG_DIR_NAME: &'static str = ".omp";
82
83 pub const SKILLS_SUBDIR: &'static str = "agent/skills";
85
86 pub const SESSIONS_SUBDIR: &'static str = "sessions";
88
89 pub const LOGS_SUBDIR: &'static str = "logs";
91
92 pub fn new() -> Self {
94 Self::new_with_install(true)
95 }
96
97 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 fn detect_native_engine() -> bool {
136 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 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 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 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 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 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 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 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 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 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 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 let mut fork_features: std::collections::HashMap<String, i32> =
448 std::collections::HashMap::new();
449
450 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 for session_data in self.read_session_files() {
465 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 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 *fork_features.entry(feature.to_string()).or_insert(0) += 1;
502
503 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 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 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 for cmd in self.read_log_files() {
545 context.add_command(cmd);
546 }
547
548 context.add_custom(
550 "fork_features",
551 serde_json::to_value(&fork_features).unwrap_or(serde_json::Value::Null),
552 );
553
554 if let Some(config) = self.read_config() {
556 context.add_custom("config", config);
557 }
558
559 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 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}