nexus_memory_hooks/agents/
claude.rs1use 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
14pub struct ClaudeCodeHook {
21 base: BaseHook,
23
24 skill_path: PathBuf,
26
27 skill_installed: bool,
29
30 process_monitor: ProcessMonitor,
32}
33
34impl ClaudeCodeHook {
35 pub const SKILL_NAME: &'static str = "nexus-memory-extraction";
37
38 pub const CONFIG_DIR: &'static str = ".claude";
40
41 pub const SKILLS_DIR: &'static str = "skills";
43
44 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 if let Err(e) = hook.install_skill() {
61 tracing::warn!("Failed to install Claude Code skill: {}", e);
62 }
63
64 hook
65 }
66
67 fn install_skill(&mut self) -> Result<()> {
69 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 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 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 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 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 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 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 }
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}