Skip to main content

nexus_memory_hooks/agents/
droid.rs

1//! Droid (Factory CLI) hook implementation.
2//!
3//! Installs native lifecycle hooks in `~/.factory/settings.json`.
4
5use async_trait::async_trait;
6use std::path::{Path, PathBuf};
7use tokio::fs;
8
9use crate::base::{AgentHook, BaseHook, LifecycleCapabilities, SessionEndCallback};
10use crate::error::{HookError, Result};
11use crate::monitor::ProcessMonitor;
12use crate::session::SessionContext;
13use crate::types::{AgentType, SessionActivity, SupportTier};
14
15const SESSION_START_EVENT: &str = "SessionStart";
16const SESSION_END_EVENT: &str = "SessionEnd";
17const CHECKPOINT_EVENT: &str = "PostToolUse";
18const COMPACT_EVENT: &str = "PreCompact";
19const ERROR_EVENT: &str = "Stop";
20
21/// Droid hook using Factory settings.json lifecycle hooks.
22pub struct DroidHook {
23    base: BaseHook,
24    settings_hook_installed: bool,
25    process_monitor: ProcessMonitor,
26    readonly: bool,
27    settings_path_override: Option<PathBuf>,
28}
29
30impl DroidHook {
31    pub const CONFIG_DIR: &'static str = ".factory";
32
33    pub fn new() -> Self {
34        Self::new_with_mode(false)
35    }
36
37    pub fn new_readonly() -> Self {
38        Self::new_with_mode(true)
39    }
40
41    fn new_with_mode(readonly: bool) -> Self {
42        let settings_hook_installed = Self::default_settings_path()
43            .ok()
44            .and_then(|path| Self::has_settings_hooks_at_path(&path).ok())
45            .unwrap_or(false);
46        Self {
47            base: BaseHook::new("droid"),
48            settings_hook_installed,
49            process_monitor: ProcessMonitor::new(),
50            readonly,
51            settings_path_override: None,
52        }
53    }
54
55    #[cfg(test)]
56    fn with_settings_path(settings_path: PathBuf, readonly: bool) -> Self {
57        Self {
58            base: BaseHook::new("droid"),
59            settings_hook_installed: false,
60            process_monitor: ProcessMonitor::new(),
61            readonly,
62            settings_path_override: Some(settings_path),
63        }
64    }
65
66    fn default_settings_path() -> Result<PathBuf> {
67        let home = dirs::home_dir().ok_or_else(|| {
68            HookError::InstallationFailed(format!(
69                "Home directory unavailable; cannot resolve {}/settings.json",
70                Self::CONFIG_DIR
71            ))
72        })?;
73        Ok(home.join(Self::CONFIG_DIR).join("settings.json"))
74    }
75
76    fn settings_path(&self) -> Result<PathBuf> {
77        if let Some(path) = &self.settings_path_override {
78            return Ok(path.clone());
79        }
80        Self::default_settings_path()
81    }
82
83    fn find_nexus_binary() -> String {
84        if let Ok(bin) = std::env::var("NEXUS_HOOK_BINARY") {
85            if !bin.trim().is_empty() {
86                return bin;
87            }
88        }
89
90        if let Ok(current_exe) = std::env::current_exe() {
91            let name = current_exe.file_name().and_then(|n| n.to_str());
92            #[cfg(not(windows))]
93            let valid = name.is_some_and(|n| matches!(n, "nexus" | "nexus-bin"));
94            #[cfg(windows)]
95            let valid = name.is_some_and(|n| {
96                matches!(n, "nexus" | "nexus-bin" | "nexus.exe" | "nexus-bin.exe")
97            });
98            if valid {
99                return current_exe.to_string_lossy().to_string();
100            }
101        }
102
103        #[cfg_attr(not(windows), allow(unused_mut))]
104        let mut candidates: Vec<PathBuf> = [
105            dirs::home_dir().map(|h| h.join(".cargo").join("bin").join("nexus")),
106            dirs::home_dir().map(|h| h.join(".cargo").join("bin").join("nexus-bin")),
107            dirs::home_dir().map(|h| h.join(".local").join("bin").join("nexus")),
108            dirs::home_dir().map(|h| h.join(".local").join("bin").join("nexus-bin")),
109            Some(PathBuf::from("/usr/local/bin/nexus")),
110            Some(PathBuf::from("/usr/local/bin/nexus-bin")),
111        ]
112        .into_iter()
113        .flatten()
114        .collect();
115
116        #[cfg(windows)]
117        {
118            if let Some(home) = dirs::home_dir() {
119                candidates.extend([
120                    home.join(".cargo").join("bin").join("nexus.exe"),
121                    home.join(".cargo").join("bin").join("nexus-bin.exe"),
122                    home.join(".local").join("bin").join("nexus.exe"),
123                    home.join(".local").join("bin").join("nexus-bin.exe"),
124                ]);
125            }
126        }
127
128        for candidate in candidates {
129            if candidate.exists() {
130                return candidate.to_string_lossy().to_string();
131            }
132        }
133
134        "nexus".to_string()
135    }
136
137    fn desired_commands() -> [(String, String); 5] {
138        let nexus_bin = Self::find_nexus_binary()
139            .replace('\\', "\\\\")
140            .replace('"', "\\\"");
141
142        let scoped = |base: &str| -> String {
143            #[cfg(not(windows))]
144            {
145                format!(
146                    "bash -c \"{base} --session-key \\\"${{FACTORY_SESSION_ID:-${{SESSION_ID:-}}}}\\\" --cwd \\\"${{FACTORY_CWD:-${{PWD:-}}}}\\\"\""
147                )
148            }
149            #[cfg(windows)]
150            {
151                // On Windows, invoke the binary directly without bash.
152                // Shell variable expansion is not available; nexus gracefully
153                // handles missing session-key/cwd at runtime via derived keys.
154                format!("{base}")
155            }
156        };
157
158        [
159            (
160                SESSION_START_EVENT.to_string(),
161                scoped(&format!(
162                    "\"{nexus_bin}\" session start --agent droid --mode session"
163                )),
164            ),
165            (
166                SESSION_END_EVENT.to_string(),
167                scoped(&format!(
168                    "\"{nexus_bin}\" session end --agent droid --reason session-end"
169                )),
170            ),
171            (
172                CHECKPOINT_EVENT.to_string(),
173                scoped(&format!(
174                    "\"{nexus_bin}\" session event --agent droid --kind checkpoint"
175                )),
176            ),
177            (
178                COMPACT_EVENT.to_string(),
179                scoped(&format!(
180                    "\"{nexus_bin}\" session event --agent droid --kind compact"
181                )),
182            ),
183            (
184                ERROR_EVENT.to_string(),
185                scoped(&format!(
186                    "\"{nexus_bin}\" session event --agent droid --kind error"
187                )),
188            ),
189        ]
190    }
191
192    fn has_settings_hooks_at_path(settings_path: &Path) -> Result<bool> {
193        let content = match std::fs::read_to_string(settings_path) {
194            Ok(content) => content,
195            Err(_) => return Ok(false),
196        };
197        let settings = match serde_json::from_str::<serde_json::Value>(&content) {
198            Ok(settings) => settings,
199            Err(_) => return Ok(false),
200        };
201
202        let commands = Self::desired_commands();
203        Ok(commands
204            .iter()
205            .all(|(event, command)| Self::settings_has_command(&settings, event, command)))
206    }
207
208    fn has_settings_hooks(&self) -> Result<bool> {
209        let settings_path = self.settings_path()?;
210        Self::has_settings_hooks_at_path(&settings_path)
211    }
212
213    fn settings_has_command(settings: &serde_json::Value, event: &str, command: &str) -> bool {
214        settings
215            .get("hooks")
216            .and_then(|hooks| hooks.get(event))
217            .and_then(|event_entries| event_entries.as_array())
218            .is_some_and(|entries| {
219                entries
220                    .iter()
221                    .any(|entry| Self::entry_contains_exact_command(entry, command))
222            })
223    }
224
225    fn entry_contains_exact_command(entry: &serde_json::Value, desired_command: &str) -> bool {
226        entry
227            .get("command")
228            .and_then(|command| command.as_str())
229            .map(|command| command == desired_command)
230            .unwrap_or(false)
231            || entry
232                .get("hooks")
233                .and_then(|hooks| hooks.as_array())
234                .is_some_and(|hooks| {
235                    hooks.iter().any(|hook| {
236                        hook.get("command")
237                            .and_then(|command| command.as_str())
238                            .map(|command| command == desired_command)
239                            .unwrap_or(false)
240                    })
241                })
242    }
243
244    async fn install_settings_hooks(&mut self) -> Result<()> {
245        self.ensure_mutable()?;
246
247        if self.settings_hook_installed && self.has_settings_hooks().unwrap_or(false) {
248            return Ok(());
249        }
250
251        let settings_path = self.settings_path()?;
252        let mut settings = if fs::try_exists(&settings_path).await.map_err(|e| {
253            HookError::InstallationFailed(format!("Failed to check settings.json: {}", e))
254        })? {
255            let content = fs::read_to_string(&settings_path).await.map_err(|e| {
256                HookError::InstallationFailed(format!("Failed to read settings.json: {}", e))
257            })?;
258            serde_json::from_str::<serde_json::Value>(&content).map_err(|e| {
259                HookError::InstallationFailed(format!("Failed to parse settings.json: {}", e))
260            })?
261        } else {
262            serde_json::json!({})
263        };
264
265        for (event, command) in Self::desired_commands() {
266            Self::upsert_event_hook(&mut settings, &event, &command)?;
267        }
268
269        if let Some(parent) = settings_path.parent() {
270            fs::create_dir_all(parent).await.map_err(|e| {
271                HookError::InstallationFailed(format!("Failed to create settings dir: {}", e))
272            })?;
273        }
274
275        let serialized = serde_json::to_string_pretty(&settings).map_err(|e| {
276            HookError::InstallationFailed(format!("Failed to serialize settings: {}", e))
277        })?;
278        let tmp_path = settings_path.with_extension("json.tmp");
279        fs::write(&tmp_path, serialized).await.map_err(|e| {
280            HookError::InstallationFailed(format!("Failed to write temporary settings: {}", e))
281        })?;
282        #[cfg(windows)]
283        if fs::try_exists(&settings_path).await.map_err(|e| {
284            HookError::InstallationFailed(format!("Failed to check settings.json: {}", e))
285        })? {
286            fs::remove_file(&settings_path).await.map_err(|e| {
287                HookError::InstallationFailed(format!(
288                    "Failed to remove existing settings.json before replace: {}",
289                    e
290                ))
291            })?;
292        }
293        fs::rename(&tmp_path, &settings_path).await.map_err(|e| {
294            HookError::InstallationFailed(format!("Failed to replace settings.json: {}", e))
295        })?;
296
297        self.settings_hook_installed = true;
298        Ok(())
299    }
300
301    fn upsert_event_hook(
302        settings: &mut serde_json::Value,
303        event_name: &str,
304        desired_command: &str,
305    ) -> Result<()> {
306        let settings_obj = settings.as_object_mut().ok_or_else(|| {
307            HookError::InstallationFailed(
308                "settings.json must contain a top-level JSON object".to_string(),
309            )
310        })?;
311
312        let hooks = settings_obj
313            .entry("hooks")
314            .or_insert_with(|| serde_json::json!({}));
315        let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
316            HookError::InstallationFailed("'hooks' must be a JSON object".to_string())
317        })?;
318
319        let event_entries = hooks_obj
320            .entry(event_name)
321            .or_insert_with(|| serde_json::json!([]));
322        let entries = event_entries.as_array_mut().ok_or_else(|| {
323            HookError::InstallationFailed(format!("'hooks.{event_name}' must be an array"))
324        })?;
325
326        if Self::replace_existing_event_hook(entries, desired_command) {
327            return Ok(());
328        }
329
330        entries.push(serde_json::json!({
331            "matcher": "",
332            "hooks": [{
333                "type": "command",
334                "command": desired_command,
335            }]
336        }));
337        Ok(())
338    }
339
340    fn replace_existing_event_hook(
341        entries: &mut Vec<serde_json::Value>,
342        desired_command: &str,
343    ) -> bool {
344        let mut replaced = false;
345        let mut canonical_seen = false;
346        let mut rewritten = Vec::with_capacity(entries.len());
347
348        for mut entry in entries.drain(..) {
349            let mut managed_in_entry = false;
350
351            if let Some(command) = entry.get("command").and_then(|value| value.as_str()) {
352                if Self::is_nexus_managed_command(command) {
353                    managed_in_entry = true;
354                    replaced = true;
355                    if canonical_seen {
356                        continue;
357                    }
358                    if let Some(obj) = entry.as_object_mut() {
359                        obj.insert(
360                            "command".to_string(),
361                            serde_json::Value::String(desired_command.to_string()),
362                        );
363                        obj.insert(
364                            "type".to_string(),
365                            serde_json::Value::String("command".into()),
366                        );
367                    }
368                    canonical_seen = true;
369                }
370            }
371
372            if let Some(hooks) = entry
373                .get_mut("hooks")
374                .and_then(|value| value.as_array_mut())
375            {
376                let mut filtered_hooks = Vec::with_capacity(hooks.len());
377                for mut hook in hooks.drain(..) {
378                    let managed = hook
379                        .get("command")
380                        .and_then(|value| value.as_str())
381                        .is_some_and(Self::is_nexus_managed_command);
382                    if managed {
383                        managed_in_entry = true;
384                        replaced = true;
385                        if canonical_seen {
386                            continue;
387                        }
388                        if let Some(obj) = hook.as_object_mut() {
389                            obj.insert(
390                                "command".to_string(),
391                                serde_json::Value::String(desired_command.to_string()),
392                            );
393                            obj.insert(
394                                "type".to_string(),
395                                serde_json::Value::String("command".into()),
396                            );
397                        }
398                        canonical_seen = true;
399                    }
400                    filtered_hooks.push(hook);
401                }
402                *hooks = filtered_hooks;
403            }
404
405            if !managed_in_entry || canonical_seen {
406                rewritten.push(entry);
407            }
408        }
409
410        *entries = rewritten;
411        replaced
412    }
413
414    fn is_nexus_managed_command(command: &str) -> bool {
415        let command = command.to_ascii_lowercase();
416        command.contains("nexus")
417            && command.contains("--agent droid")
418            && (command.contains(" session ") || command.contains("ingest-hook-event"))
419    }
420
421    fn ensure_mutable(&self) -> Result<()> {
422        if self.readonly {
423            return Err(HookError::NotSupported(
424                "DroidHook readonly mode does not allow hook installation".to_string(),
425            ));
426        }
427        Ok(())
428    }
429}
430
431impl Default for DroidHook {
432    fn default() -> Self {
433        Self::new()
434    }
435}
436
437#[async_trait]
438impl AgentHook for DroidHook {
439    fn agent_type(&self) -> &str {
440        &self.base.agent_type
441    }
442
443    async fn install_session_start_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
444        self.ensure_mutable()?;
445        self.base.add_callback(callback);
446        self.install_settings_hooks().await
447    }
448
449    async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
450        self.ensure_mutable()?;
451        self.base.add_callback(callback);
452        self.base.installed = true;
453        self.install_settings_hooks().await
454    }
455
456    async fn install_checkpoint_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
457        self.ensure_mutable()?;
458        self.base.add_callback(callback);
459        self.install_settings_hooks().await
460    }
461
462    async fn install_compact_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
463        self.ensure_mutable()?;
464        self.base.add_callback(callback);
465        self.install_settings_hooks().await
466    }
467
468    async fn install_error_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
469        self.ensure_mutable()?;
470        self.base.add_callback(callback);
471        self.install_settings_hooks().await
472    }
473
474    async fn detect_session_activity(&self) -> Result<SessionActivity> {
475        let mut monitor = self.process_monitor.clone();
476        let processes = monitor.find_agent_processes(AgentType::Droid);
477
478        let mut activity = SessionActivity::new(AgentType::Droid);
479        if !processes.is_empty() {
480            activity.is_active = true;
481            activity.processes = processes;
482        }
483
484        Ok(activity)
485    }
486
487    async fn extract_session_context(&self) -> Result<SessionContext> {
488        let mut context = SessionContext::new("droid")
489            .with_source("native")
490            .with_reliability(if self.settings_hook_installed {
491                0.98
492            } else {
493                0.9
494            });
495        context.complete();
496        Ok(context)
497    }
498
499    fn is_hook_installed(&self) -> bool {
500        self.settings_hook_installed
501    }
502
503    fn reliability_score(&self) -> f32 {
504        if self.settings_hook_installed {
505            0.98
506        } else {
507            0.9
508        }
509    }
510
511    fn lifecycle_capabilities(&self) -> LifecycleCapabilities {
512        LifecycleCapabilities {
513            session_start: true,
514            session_end: true,
515            checkpoint: true,
516            error_hook: true,
517            compact: true,
518        }
519    }
520
521    fn support_tier(&self) -> SupportTier {
522        SupportTier::NativeLifecycle
523    }
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529
530    #[test]
531    fn test_droid_hook_new() {
532        let hook = DroidHook::new();
533        assert_eq!(hook.agent_type(), "droid");
534    }
535
536    #[test]
537    fn test_find_nexus_binary_supports_nexus_bin() {
538        let bin = DroidHook::find_nexus_binary();
539        assert!(!bin.is_empty());
540        assert!(bin.contains("nexus"));
541    }
542
543    #[test]
544    fn test_droid_lifecycle_capabilities() {
545        let hook = DroidHook::new();
546        let caps = hook.lifecycle_capabilities();
547        assert!(caps.session_start);
548        assert!(caps.session_end);
549        assert!(caps.checkpoint);
550        assert!(caps.error_hook);
551        assert!(caps.compact);
552    }
553
554    #[tokio::test]
555    async fn test_install_session_end_hook_is_supported() {
556        let home = tempfile::tempdir().unwrap();
557        let settings_path = home.path().join(".factory").join("settings.json");
558        let mut hook = DroidHook::with_settings_path(settings_path, false);
559        let callback = std::sync::Arc::new(|_ctx| {});
560        let result = hook.install_session_end_hook(callback).await;
561        assert!(
562            result.is_ok(),
563            "install_session_end_hook should succeed in a temp dir: {result:?}"
564        );
565    }
566
567    #[tokio::test]
568    async fn test_readonly_droid_hook_rejects_install() {
569        let home = tempfile::tempdir().unwrap();
570        let settings_path = home.path().join(".factory").join("settings.json");
571        let mut hook = DroidHook::with_settings_path(settings_path, true);
572        let callback = std::sync::Arc::new(|_ctx| {});
573        let result = hook.install_session_start_hook(callback).await;
574        assert!(matches!(result, Err(HookError::NotSupported(_))));
575    }
576}