nexus_memory_hooks/agents/
cli.rs1use async_trait::async_trait;
6use std::path::PathBuf;
7
8use crate::base::{AgentHook, BaseHook, LifecycleCapabilities, SessionEndCallback};
9use crate::error::Result;
10use crate::monitor::ProcessMonitor;
11use crate::session::SessionContext;
12use crate::types::{AgentType, SessionActivity, SupportTier};
13
14pub struct CLIHook {
24 base: BaseHook,
26
27 agent_type_name: String,
29
30 agent_type: AgentType,
32
33 process_monitor: ProcessMonitor,
35}
36
37impl CLIHook {
38 pub fn new(agent_type: impl Into<String>) -> Self {
40 let agent_type_name = agent_type.into();
41 let agent_type = AgentType::parse(&agent_type_name).unwrap_or(AgentType::Generic);
42
43 Self {
44 base: BaseHook::new(&agent_type_name),
45 agent_type_name,
46 agent_type,
47 process_monitor: ProcessMonitor::new(),
48 }
49 }
50
51 fn config_path(&self) -> PathBuf {
53 dirs::home_dir()
54 .unwrap_or_else(|| PathBuf::from("."))
55 .join(self.agent_type.config_dir())
56 }
57
58 fn read_session_data(&self) -> Option<serde_json::Value> {
60 let session_file = self.config_path().join("session.json");
61
62 if session_file.exists() {
63 let content = std::fs::read_to_string(&session_file).ok()?;
64 serde_json::from_str(&content).ok()
65 } else {
66 None
67 }
68 }
69}
70
71#[async_trait]
72impl AgentHook for CLIHook {
73 fn agent_type(&self) -> &str {
74 &self.base.agent_type
75 }
76
77 async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
78 self.base.add_callback(callback);
79 self.base.installed = true;
80
81 Ok(())
85 }
86
87 async fn detect_session_activity(&self) -> Result<SessionActivity> {
88 let mut monitor = self.process_monitor.clone();
89 let processes = monitor.find_agent_processes(self.agent_type);
90
91 let mut activity = SessionActivity::new(self.agent_type);
92
93 if !processes.is_empty() {
94 activity.is_active = true;
95 activity.processes = processes;
96 }
97
98 let session_dir = self.config_path().join("sessions");
100 if session_dir.exists() {
101 if let Ok(entries) = std::fs::read_dir(&session_dir) {
102 let most_recent = entries
103 .filter_map(|e| e.ok())
104 .filter(|e| {
105 e.path()
106 .extension()
107 .map(|ext| ext == "json")
108 .unwrap_or(false)
109 })
110 .max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()));
111
112 if let Some(entry) = most_recent {
113 if let Ok(metadata) = entry.metadata() {
114 if let Ok(modified) = metadata.modified() {
115 let age = std::time::SystemTime::now()
116 .duration_since(modified)
117 .unwrap_or(std::time::Duration::MAX);
118
119 if age.as_secs() < 300 {
121 activity.is_active = true;
122 activity.session_id = Some(
123 entry
124 .path()
125 .file_stem()
126 .unwrap()
127 .to_string_lossy()
128 .to_string(),
129 );
130 }
131 }
132 }
133 }
134 }
135 }
136
137 Ok(activity)
138 }
139
140 async fn extract_session_context(&self) -> Result<SessionContext> {
141 let mut context = SessionContext::new(&self.agent_type_name)
142 .with_source("cli")
143 .with_reliability(0.95);
144
145 if let Some(session) = self.read_session_data() {
147 if let Some(messages) = session.get("messages").and_then(|m| m.as_array()) {
148 for msg in messages {
149 let role = msg
150 .get("role")
151 .and_then(|r| r.as_str())
152 .unwrap_or("unknown");
153 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
154 context.add_message(role, content);
155 }
156 }
157
158 if let Some(commands) = session.get("commands").and_then(|c| c.as_array()) {
159 for cmd in commands {
160 if let Some(cmd_str) = cmd.as_str() {
161 context.add_command(cmd_str);
162 }
163 }
164 }
165 }
166
167 let git_status = std::process::Command::new("git")
169 .args(["status", "--porcelain"])
170 .output()
171 .ok();
172
173 if let Some(output) = git_status {
174 if output.status.success() {
175 let status = String::from_utf8_lossy(&output.stdout);
176 for line in status.lines() {
177 if line.len() > 3 {
178 let status_char = line.chars().next().unwrap_or(' ');
179 let file_path = &line[3..];
180 let action = match status_char {
181 '?' => crate::session::FileAction::Created,
182 'D' => crate::session::FileAction::Deleted,
183 _ => crate::session::FileAction::Modified,
184 };
185 context.add_file(crate::session::FileInfo::new(file_path, action));
186 }
187 }
188 }
189 }
190
191 context.complete();
192 Ok(context)
193 }
194
195 fn is_hook_installed(&self) -> bool {
196 self.base.installed
197 }
198
199 fn reliability_score(&self) -> f32 {
200 0.95 }
202
203 fn lifecycle_capabilities(&self) -> LifecycleCapabilities {
204 LifecycleCapabilities::end_only()
207 }
208
209 fn support_tier(&self) -> SupportTier {
210 SupportTier::WrapperLifecycle
211 }
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217
218 #[test]
219 fn test_cli_hook_new() {
220 let hook = CLIHook::new("opencode");
221 assert_eq!(hook.agent_type(), "opencode");
222
223 let hermes = CLIHook::new("hermes");
224 assert_eq!(hermes.agent_type(), "hermes");
225 }
226
227 #[tokio::test]
228 async fn test_cli_hook_detect_activity() {
229 let hook = CLIHook::new("codex");
230 let activity = hook.detect_session_activity().await.unwrap();
231
232 assert_eq!(activity.agent_type, AgentType::Codex);
233 }
234
235 #[test]
236 fn test_cli_hook_lifecycle_capabilities() {
237 let hook = CLIHook::new("codex");
238 let caps = hook.lifecycle_capabilities();
239
240 assert!(
241 !caps.session_start,
242 "CLI agents do not support session_start"
243 );
244 assert!(
245 caps.session_end,
246 "CLI agents support session_end via atexit callback"
247 );
248 assert!(!caps.checkpoint, "CLI agents do not support checkpoint");
249 assert!(!caps.compact, "CLI agents do not support compact");
250 assert!(!caps.error_hook, "CLI agents do not support error_hook");
251 }
252}