Skip to main content

nexus_memory_hooks/agents/
claude.rs

1//! Claude Code hook implementation
2//!
3//! Uses Skills-based lifecycle hooks for native integration.
4
5use async_trait::async_trait;
6use std::path::PathBuf;
7
8use crate::base::{AgentHook, BaseHook, SessionEndCallback};
9use crate::error::{HookError, Result};
10use crate::monitor::ProcessMonitor;
11use crate::session::SessionContext;
12use crate::types::{AgentType, SessionActivity};
13
14/// Claude Code hook using Skills lifecycle
15///
16/// Installation:
17/// 1. Creates Claude Code Skill at ~/.claude/skills/nexus-memory/SKILL.md
18/// 2. Skill auto-triggers on session_end, checkpoint, completion
19/// 3. Skill calls MCP tool to store memory
20pub struct ClaudeCodeHook {
21    /// Base hook functionality
22    base: BaseHook,
23
24    /// Skill path
25    skill_path: PathBuf,
26
27    /// Whether skill is installed
28    skill_installed: bool,
29
30    /// Process monitor for fallback detection
31    process_monitor: ProcessMonitor,
32}
33
34impl ClaudeCodeHook {
35    /// Skill name
36    pub const SKILL_NAME: &'static str = "nexus-memory-extraction";
37
38    /// Config directory
39    pub const CONFIG_DIR: &'static str = ".claude";
40
41    /// Skills subdirectory
42    pub const SKILLS_DIR: &'static str = "skills";
43
44    /// Create a new Claude Code hook
45    pub fn new() -> Self {
46        let skill_path = dirs::home_dir()
47            .unwrap_or_else(|| PathBuf::from("."))
48            .join(Self::CONFIG_DIR)
49            .join(Self::SKILLS_DIR)
50            .join(Self::SKILL_NAME);
51
52        let mut hook = Self {
53            base: BaseHook::new("claude-code"),
54            skill_path,
55            skill_installed: false,
56            process_monitor: ProcessMonitor::new(),
57        };
58
59        // Try to install skill
60        if let Err(e) = hook.install_skill() {
61            tracing::warn!("Failed to install Claude Code skill: {}", e);
62        }
63
64        hook
65    }
66
67    /// Install the SKILL.md file
68    fn install_skill(&mut self) -> Result<()> {
69        // Create skill directory
70        std::fs::create_dir_all(&self.skill_path).map_err(|e| {
71            HookError::InstallationFailed(format!("Failed to create skill dir: {}", e))
72        })?;
73
74        let skill_md = self.skill_path.join("SKILL.md");
75
76        let skill_content = r#"---
77name: nexus-memory-extraction
78description: Automatically extract session context to Nexus Memory System
79version: 1.0.0
80author: Nexus Memory System
81trigger:
82  - on_session_end
83  - on_checkpoint
84  - on_completion
85  - on_error
86priority: high
87---
88
89# Nexus Memory Extraction Skill
90
91## Overview
92
93This skill automatically triggers when your Claude Code session ends, ensuring no context is lost.
94
95## What It Does
96
971. **Captures Context**: Extracts current conversation, decisions, and context
982. **Summarizes**: Creates structured summary of key points
993. **Stores**: Automatically stores to Nexus Memory System
1004. **Confirms**: Shows what was stored
101
102## Triggers
103
104- **on_session_end**: When you close Claude Code
105- **on_checkpoint**: At periodic checkpoints during long sessions
106- **on_completion**: When a task is completed
107- **on_error**: If an error occurs (stores context for debugging)
108
109## No Manual Action Required
110
111This skill runs automatically. You don't need to remember to trigger it.
112
113## Configuration
114
115The skill reads from:
116- `NEXUS_AUTO_INGEST=true` environment variable
117- `NEXUS_SERVER_URL` for connection
118
119## Output
120
121After storing, you'll see:
122```
123[Nexus] Stored 3 memories from Claude Code session:
124  - 2 decisions
125  - 1 context item
126  - Memory IDs: nexus_123, nexus_124, nexus_125
127```
128"#;
129
130        std::fs::write(&skill_md, skill_content).map_err(|e| {
131            HookError::InstallationFailed(format!("Failed to write skill file: {}", e))
132        })?;
133
134        self.skill_installed = true;
135        tracing::info!("Claude Code Skill installed at: {:?}", self.skill_path);
136
137        Ok(())
138    }
139
140    /// Read session file
141    fn read_session_file(&self) -> Option<serde_json::Value> {
142        let session_file = dirs::home_dir()?
143            .join(Self::CONFIG_DIR)
144            .join("session.json");
145
146        if session_file.exists() {
147            let content = std::fs::read_to_string(&session_file).ok()?;
148            serde_json::from_str(&content).ok()
149        } else {
150            None
151        }
152    }
153
154    /// Read checkpoint data
155    fn read_checkpoint_data(&self) -> Option<Vec<serde_json::Value>> {
156        let checkpoint_dir = dirs::home_dir()?.join(Self::CONFIG_DIR).join("checkpoints");
157
158        if !checkpoint_dir.exists() {
159            return None;
160        }
161
162        let mut checkpoints = Vec::new();
163
164        if let Ok(entries) = std::fs::read_dir(&checkpoint_dir) {
165            for entry in entries.flatten() {
166                if entry
167                    .path()
168                    .extension()
169                    .map(|e| e == "json")
170                    .unwrap_or(false)
171                {
172                    if let Ok(content) = std::fs::read_to_string(entry.path()) {
173                        if let Ok(data) = serde_json::from_str(&content) {
174                            checkpoints.push(data);
175                        }
176                    }
177                }
178            }
179        }
180
181        Some(checkpoints)
182    }
183}
184
185impl Default for ClaudeCodeHook {
186    fn default() -> Self {
187        Self::new()
188    }
189}
190
191#[async_trait]
192impl AgentHook for ClaudeCodeHook {
193    fn agent_type(&self) -> &str {
194        &self.base.agent_type
195    }
196
197    async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
198        self.base.add_callback(callback);
199        self.base.installed = true;
200
201        if !self.skill_installed {
202            tracing::warn!("Claude Code Skill not installed, using fallback detection");
203        }
204
205        Ok(())
206    }
207
208    async fn detect_session_activity(&self) -> Result<SessionActivity> {
209        // Refresh process monitor
210        let mut monitor = self.process_monitor.clone();
211        let processes = monitor.find_agent_processes(AgentType::ClaudeCode);
212
213        let mut activity = SessionActivity::new(AgentType::ClaudeCode);
214
215        if !processes.is_empty() {
216            activity.is_active = true;
217            activity.processes = processes;
218        }
219
220        // Also check for session file
221        if let Some(session) = self.read_session_file() {
222            if let Some(id) = session.get("session_id").and_then(|s| s.as_str()) {
223                activity.session_id = Some(id.to_string());
224            }
225        }
226
227        Ok(activity)
228    }
229
230    async fn extract_session_context(&self) -> Result<SessionContext> {
231        let mut context = SessionContext::new("claude-code")
232            .with_source("native")
233            .with_reliability(1.0);
234
235        // Read session file
236        if let Some(session) = self.read_session_file() {
237            if let Some(messages) = session.get("messages").and_then(|m| m.as_array()) {
238                for msg in messages {
239                    let role = msg
240                        .get("role")
241                        .and_then(|r| r.as_str())
242                        .unwrap_or("unknown");
243                    let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
244                    context.add_message(role, content);
245                }
246            }
247
248            if let Some(project_ctx) = session.get("project_context") {
249                context.add_custom("project_context", project_ctx.clone());
250            }
251        }
252
253        // Read checkpoint data
254        if let Some(checkpoints) = self.read_checkpoint_data() {
255            for checkpoint in checkpoints {
256                if let Some(decisions) = checkpoint.get("decisions").and_then(|d| d.as_array()) {
257                    for decision in decisions {
258                        if let Some(summary) = decision.get("summary").and_then(|s| s.as_str()) {
259                            let mut dec = crate::session::Decision::new(summary);
260                            if let Some(rationale) =
261                                decision.get("rationale").and_then(|r| r.as_str())
262                            {
263                                dec.rationale = Some(rationale.to_string());
264                            }
265                            context.add_decision(dec);
266                        }
267                    }
268                }
269
270                if let Some(files) = checkpoint.get("files").and_then(|f| f.as_array()) {
271                    for file in files {
272                        if let Some(path) = file.get("path").and_then(|p| p.as_str()) {
273                            let action = file
274                                .get("action")
275                                .and_then(|a| a.as_str())
276                                .unwrap_or("modified");
277                            let file_action = match action {
278                                "created" => crate::session::FileAction::Created,
279                                "deleted" => crate::session::FileAction::Deleted,
280                                "read" => crate::session::FileAction::Read,
281                                _ => crate::session::FileAction::Modified,
282                            };
283                            context.add_file(crate::session::FileInfo::new(path, file_action));
284                        }
285                    }
286                }
287            }
288        }
289
290        context.complete();
291        Ok(context)
292    }
293
294    fn is_hook_installed(&self) -> bool {
295        self.skill_installed
296    }
297
298    fn reliability_score(&self) -> f32 {
299        if self.skill_installed {
300            1.0
301        } else {
302            0.95 // Fallback to process monitoring
303        }
304    }
305}
306
307#[cfg(test)]
308mod tests {
309    use super::*;
310
311    #[test]
312    fn test_claude_hook_new() {
313        let hook = ClaudeCodeHook::new();
314        assert_eq!(hook.agent_type(), "claude-code");
315    }
316
317    #[tokio::test]
318    async fn test_claude_hook_detect_activity() {
319        let hook = ClaudeCodeHook::new();
320        let activity = hook.detect_session_activity().await.unwrap();
321
322        assert_eq!(activity.agent_type, AgentType::ClaudeCode);
323    }
324}