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