nexus_memory_hooks/agents/
pi_mono.rs1use async_trait::async_trait;
15use std::path::PathBuf;
16
17use crate::base::{AgentHook, BaseHook, LifecycleCapabilities, SessionEndCallback};
18use crate::error::{HookError, Result};
19use crate::monitor::ProcessMonitor;
20use crate::session::{
21 FileAction, FileInfo, SessionContext, SubagentExecution, TaskInfo, TaskStatus,
22};
23use crate::types::{AgentType, SessionActivity, SupportTier};
24
25pub struct PiMonoHook {
45 base: BaseHook,
47
48 config_dir: PathBuf,
50
51 session_dir: PathBuf,
53
54 extensions_dir: PathBuf,
56
57 process_monitor: ProcessMonitor,
59
60 extension_installed: bool,
62}
63
64impl PiMonoHook {
65 pub const AGENT_TYPE: &'static str = "pi-mono";
67
68 pub const CONFIG_DIR_NAME: &'static str = ".pi";
70
71 pub const EXTENSIONS_SUBDIR: &'static str = "agent/extensions";
73
74 pub const SESSIONS_SUBDIR: &'static str = "sessions";
76
77 pub const LOGS_SUBDIR: &'static str = "logs";
79
80 pub fn new() -> Self {
82 Self::new_with_install(true)
83 }
84
85 pub fn new_readonly() -> Self {
87 Self::new_with_install(false)
88 }
89
90 fn new_with_install(auto_install: bool) -> Self {
91 let config_dir = dirs::home_dir()
92 .unwrap_or_else(|| PathBuf::from("."))
93 .join(Self::CONFIG_DIR_NAME);
94
95 let session_dir = config_dir.join(Self::SESSIONS_SUBDIR);
96 let extensions_dir = config_dir.join(Self::EXTENSIONS_SUBDIR);
97 let extension_installed = Self::extension_file_path(&extensions_dir).exists();
98
99 let mut hook = Self {
100 base: BaseHook::new(Self::AGENT_TYPE),
101 config_dir,
102 session_dir,
103 extensions_dir,
104 process_monitor: ProcessMonitor::new(),
105 extension_installed,
106 };
107
108 if auto_install {
109 hook.migrate_from_skill();
111
112 if !hook.extension_installed {
113 if let Err(e) = hook.install_extension() {
114 tracing::warn!("Failed to install pi-mono extension: {}", e);
115 }
116 }
117 }
118
119 hook
120 }
121
122 fn extension_file_path(extensions_dir: &std::path::Path) -> PathBuf {
123 extensions_dir.join("nexus-memory.ts")
124 }
125
126 fn install_extension(&mut self) -> Result<()> {
128 std::fs::create_dir_all(&self.extensions_dir).map_err(|e| {
129 HookError::InstallationFailed(format!("Failed to create extensions dir: {}", e))
130 })?;
131
132 let extension_path = Self::extension_file_path(&self.extensions_dir);
133
134 let extension_content = include_str!("../extension_ts/nexus_memory_pi.ts");
135
136 std::fs::write(&extension_path, extension_content).map_err(|e| {
137 HookError::InstallationFailed(format!("Failed to write extension: {}", e))
138 })?;
139
140 self.extension_installed = true;
141 tracing::info!("Pi-mono extension installed at: {:?}", extension_path);
142
143 Ok(())
144 }
145
146 fn migrate_from_skill(&mut self) {
148 let legacy_skill_dir = self
149 .config_dir
150 .join("agent")
151 .join("skills")
152 .join("nexus-memory-extraction");
153
154 if legacy_skill_dir.exists() {
155 tracing::info!("Migrating pi-mono from SKILL.md to TypeScript extension");
156 if let Err(e) = std::fs::remove_dir_all(&legacy_skill_dir) {
157 tracing::warn!("Failed to remove legacy skill dir: {}", e);
158 }
159 }
160 }
161
162 fn read_session_files(&self) -> Vec<serde_json::Value> {
164 let mut sessions = Vec::new();
165
166 if !self.session_dir.exists() {
167 return sessions;
168 }
169
170 if let Ok(entries) = std::fs::read_dir(&self.session_dir) {
171 let mut session_files: Vec<_> = entries
172 .filter_map(|e| e.ok())
173 .filter(|e| {
174 e.path()
175 .extension()
176 .map(|ext| ext == "json")
177 .unwrap_or(false)
178 })
179 .collect();
180
181 session_files.sort_by(|a, b| {
183 let time_a = a.metadata().ok().and_then(|m| m.modified().ok());
184 let time_b = b.metadata().ok().and_then(|m| m.modified().ok());
185 time_b.cmp(&time_a)
186 });
187
188 for entry in session_files.into_iter().take(10) {
190 if let Ok(content) = std::fs::read_to_string(entry.path()) {
191 if let Ok(data) = serde_json::from_str(&content) {
192 sessions.push(data);
193 }
194 }
195 }
196 }
197
198 sessions
199 }
200
201 fn read_log_files(&self) -> Vec<String> {
203 let mut commands = Vec::new();
204 let logs_dir = self.config_dir.join(Self::LOGS_SUBDIR);
205
206 if !logs_dir.exists() {
207 return commands;
208 }
209
210 if let Ok(entries) = std::fs::read_dir(&logs_dir) {
211 let mut log_files: Vec<_> = entries
212 .filter_map(|e| e.ok())
213 .filter(|e| {
214 e.path()
215 .extension()
216 .map(|ext| ext == "log")
217 .unwrap_or(false)
218 })
219 .collect();
220
221 log_files.sort_by(|a, b| {
222 let time_a = a.metadata().ok().and_then(|m| m.modified().ok());
223 let time_b = b.metadata().ok().and_then(|m| m.modified().ok());
224 time_b.cmp(&time_a)
225 });
226
227 for entry in log_files.into_iter().take(5) {
228 if let Ok(content) = std::fs::read_to_string(entry.path()) {
229 for line in content.lines() {
230 if line.contains("Executing:") || line.contains("Command:") {
231 commands.push(line.to_string());
232 }
233 }
234 }
235 }
236 }
237
238 commands
239 }
240}
241
242impl Default for PiMonoHook {
243 fn default() -> Self {
244 Self::new()
245 }
246}
247
248#[async_trait]
249impl AgentHook for PiMonoHook {
250 fn agent_type(&self) -> &str {
251 &self.base.agent_type
252 }
253
254 async fn install_session_start_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
255 self.base.add_session_start_callback(callback);
256 Ok(())
257 }
258
259 async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
260 self.base.add_callback(callback);
261 Ok(())
262 }
263
264 async fn install_checkpoint_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
265 self.base.add_checkpoint_callback(callback);
266 Ok(())
267 }
268
269 async fn install_compact_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
270 self.base.add_callback(callback);
271 Ok(())
272 }
273
274 async fn install_error_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
275 self.base.add_error_callback(callback);
276 Ok(())
277 }
278
279 async fn detect_session_activity(&self) -> Result<SessionActivity> {
280 let mut monitor = self.process_monitor.clone();
281 let processes = monitor.find_agent_processes(AgentType::PiMono);
282
283 let mut activity = SessionActivity::new(AgentType::PiMono);
284
285 if !processes.is_empty() {
286 activity.is_active = true;
287 activity.processes = processes;
288 }
289
290 if self.session_dir.exists() {
292 if let Ok(entries) = std::fs::read_dir(&self.session_dir) {
293 if let Some(most_recent) = entries
294 .filter_map(|e| e.ok())
295 .filter(|e| {
296 e.path()
297 .extension()
298 .map(|ext| ext == "json")
299 .unwrap_or(false)
300 })
301 .max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
302 {
303 if let Ok(metadata) = most_recent.metadata() {
304 if let Ok(modified) = metadata.modified() {
305 let age = std::time::SystemTime::now()
306 .duration_since(modified)
307 .unwrap_or(std::time::Duration::MAX);
308
309 if age.as_secs() < 300 {
311 activity.is_active = true;
312 activity.session_id = Some(
313 most_recent
314 .path()
315 .file_stem()
316 .unwrap()
317 .to_string_lossy()
318 .to_string(),
319 );
320 }
321 }
322 }
323 }
324 }
325 }
326
327 let subagent_check = std::process::Command::new("pgrep")
329 .arg("-f")
330 .arg("subagent|skill")
331 .output()
332 .ok();
333
334 if let Some(output) = subagent_check {
335 if output.status.success() && !output.stdout.is_empty() {
336 activity.is_active = true;
337 }
338 }
339
340 Ok(activity)
341 }
342
343 async fn extract_session_context(&self) -> Result<SessionContext> {
344 let mut context = SessionContext::new("pi-mono")
345 .with_source("native")
346 .with_reliability(1.0);
347
348 let mut role_usage: std::collections::HashMap<String, i32> =
350 std::collections::HashMap::new();
351
352 for session_data in self.read_session_files() {
354 if let Some(timestamp) = session_data.get("timestamp").and_then(|t| t.as_str()) {
356 context.add_custom(
357 "session_timestamp",
358 serde_json::Value::String(timestamp.to_string()),
359 );
360 }
361
362 if let Some(tasks) = session_data.get("tasks").and_then(|t| t.as_array()) {
364 for task in tasks {
365 let description = task
366 .get("description")
367 .and_then(|d| d.as_str())
368 .unwrap_or("");
369 let role = task
370 .get("role")
371 .and_then(|r| r.as_str())
372 .unwrap_or("unknown");
373
374 let mut task_info = TaskInfo::new(description);
375 task_info.subagent = Some(role.to_string());
376
377 if let Some(status) = task.get("status").and_then(|s| s.as_str()) {
378 task_info.status = match status {
379 "completed" => TaskStatus::Completed,
380 "failed" => TaskStatus::Failed,
381 "in_progress" => TaskStatus::InProgress,
382 _ => TaskStatus::Pending,
383 };
384 }
385
386 context.tasks.push(task_info);
387
388 *role_usage.entry(role.to_string()).or_insert(0) += 1;
390
391 context.subagent_executions.push(SubagentExecution {
393 subagent_type: role.to_string(),
394 task: description.to_string(),
395 status: "completed".to_string(),
396 started_at: chrono::Utc::now(),
397 completed_at: Some(chrono::Utc::now()),
398 result_summary: None,
399 });
400 }
401 }
402
403 if let Some(files) = session_data
405 .get("files_modified")
406 .and_then(|f| f.as_array())
407 {
408 for file in files {
409 if let Some(path) = file.as_str() {
410 context.add_file(FileInfo::new(path, FileAction::Modified));
411 }
412 }
413 }
414 }
415
416 for cmd in self.read_log_files() {
418 context.add_command(cmd);
419 }
420
421 context.add_custom(
423 "role_usage",
424 serde_json::to_value(&role_usage).unwrap_or(serde_json::Value::Null),
425 );
426
427 let git_status = std::process::Command::new("git")
429 .args(["status", "--porcelain"])
430 .output()
431 .ok();
432
433 if let Some(output) = git_status {
434 if output.status.success() {
435 let status = String::from_utf8_lossy(&output.stdout);
436 for line in status.lines() {
437 if line.len() > 3 {
438 let file_path = &line[3..];
439 context.add_file(FileInfo::new(file_path, FileAction::Modified));
440 }
441 }
442 }
443 }
444
445 context.complete();
446 Ok(context)
447 }
448
449 fn is_hook_installed(&self) -> bool {
450 self.extension_installed
451 }
452
453 fn reliability_score(&self) -> f32 {
454 if self.extension_installed {
455 1.0
456 } else {
457 0.95
458 }
459 }
460
461 fn lifecycle_capabilities(&self) -> LifecycleCapabilities {
462 LifecycleCapabilities {
463 session_start: true,
464 session_end: true,
465 checkpoint: true,
466 error_hook: true,
467 compact: true,
468 }
469 }
470
471 fn support_tier(&self) -> SupportTier {
472 SupportTier::NativeLifecycle
473 }
474}
475
476#[cfg(test)]
477mod tests {
478 use super::*;
479 use std::sync::Arc;
480
481 #[test]
482 fn test_pi_mono_hook_new() {
483 let hook = PiMonoHook::new();
484 assert_eq!(hook.agent_type(), "pi-mono");
485 }
486
487 #[tokio::test]
488 async fn test_pi_mono_hook_detect_activity() {
489 let hook = PiMonoHook::new();
490 let activity = hook.detect_session_activity().await.unwrap();
491
492 assert_eq!(activity.agent_type, AgentType::PiMono);
493 }
494
495 #[test]
496 fn test_pi_mono_hook_constants() {
497 assert_eq!(PiMonoHook::AGENT_TYPE, "pi-mono");
498 assert_eq!(PiMonoHook::CONFIG_DIR_NAME, ".pi");
499 assert_eq!(PiMonoHook::EXTENSIONS_SUBDIR, "agent/extensions");
500 }
501
502 #[test]
503 fn test_pi_mono_extension_file_path() {
504 let dir = tempfile::tempdir().unwrap();
505 let path = PiMonoHook::extension_file_path(&dir.path().join("extensions"));
506 assert_eq!(
507 path.file_name().unwrap().to_str().unwrap(),
508 "nexus-memory.ts"
509 );
510 }
511
512 #[test]
513 fn test_pi_mono_hook_lifecycle_capabilities_full() {
514 let hook = PiMonoHook::new();
515 let caps = hook.lifecycle_capabilities();
516
517 assert!(caps.session_start, "pi-mono should support session_start");
518 assert!(caps.session_end, "pi-mono should support session_end");
519 assert!(caps.checkpoint, "pi-mono should support checkpoint");
520 assert!(caps.error_hook, "pi-mono should support error_hook");
521 assert!(caps.compact, "pi-mono should support compact");
522 }
523
524 #[tokio::test]
525 async fn test_pi_mono_hook_install_session_start() {
526 let mut hook = PiMonoHook::new();
527 let cb: SessionEndCallback = Arc::new(|_ctx| ());
528 let result = hook.install_session_start_hook(cb).await;
529 assert!(result.is_ok(), "pi-mono should accept session_start hook");
530 }
531
532 #[tokio::test]
533 async fn test_pi_mono_hook_install_checkpoint() {
534 let mut hook = PiMonoHook::new();
535 let cb: SessionEndCallback = Arc::new(|_ctx| ());
536 let result = hook.install_checkpoint_hook(cb).await;
537 assert!(result.is_ok(), "pi-mono should accept checkpoint hook");
538 }
539
540 #[tokio::test]
541 async fn test_pi_mono_hook_install_error() {
542 let mut hook = PiMonoHook::new();
543 let cb: SessionEndCallback = Arc::new(|_ctx| ());
544 let result = hook.install_error_hook(cb).await;
545 assert!(result.is_ok(), "pi-mono should accept error hook");
546 }
547
548 #[test]
549 fn test_pi_mono_legacy_migration() {
550 let dir = tempfile::tempdir().unwrap();
551 let legacy_dir = dir
552 .path()
553 .join(".pi")
554 .join("agent")
555 .join("skills")
556 .join("nexus-memory-extraction");
557 std::fs::create_dir_all(&legacy_dir).unwrap();
558 std::fs::write(legacy_dir.join("SKILL.md"), "legacy").unwrap();
559
560 assert!(legacy_dir.exists());
562 std::fs::remove_dir_all(&legacy_dir).unwrap();
563 assert!(!legacy_dir.exists());
564 }
565}