nexus_memory_hooks/agents/
claude.rs1use async_trait::async_trait;
6use std::path::PathBuf;
7
8use crate::base::{AgentHook, BaseHook, LifecycleCapabilities, SessionEndCallback};
9use crate::error::{HookError, Result};
10use crate::monitor::ProcessMonitor;
11use crate::session::SessionContext;
12use crate::types::{AgentType, SessionActivity, SupportTier};
13
14pub struct ClaudeCodeHook {
28 base: BaseHook,
30
31 skill_path: PathBuf,
33
34 skill_installed: bool,
36
37 settings_hook_installed: bool,
39
40 process_monitor: ProcessMonitor,
42}
43
44impl ClaudeCodeHook {
45 pub const SKILL_NAME: &'static str = "nexus-memory-extraction";
47
48 pub const CONFIG_DIR: &'static str = ".claude";
50
51 pub const SKILLS_DIR: &'static str = "skills";
53
54 pub fn new() -> Self {
56 let skill_path = dirs::home_dir()
57 .unwrap_or_else(|| PathBuf::from("."))
58 .join(Self::CONFIG_DIR)
59 .join(Self::SKILLS_DIR)
60 .join(Self::SKILL_NAME);
61
62 let mut hook = Self {
63 base: BaseHook::new("claude-code"),
64 skill_path,
65 skill_installed: false,
66 settings_hook_installed: Self::has_settings_hook(),
67 process_monitor: ProcessMonitor::new(),
68 };
69
70 if let Err(e) = hook.install_skill() {
72 tracing::warn!("Failed to install Claude Code skill: {}", e);
73 }
74
75 hook
76 }
77
78 fn install_skill(&mut self) -> Result<()> {
80 std::fs::create_dir_all(&self.skill_path).map_err(|e| {
82 HookError::InstallationFailed(format!("Failed to create skill dir: {}", e))
83 })?;
84
85 let skill_md = self.skill_path.join("SKILL.md");
86
87 let skill_content = r#"---
88name: nexus-memory-extraction
89description: Automatically extract session context to Nexus Memory System
90version: 1.0.0
91author: Nexus Memory System
92trigger:
93 - on_session_end
94 - on_checkpoint
95 - on_completion
96 - on_error
97priority: high
98---
99
100# Nexus Memory Extraction Skill
101
102## Overview
103
104This skill automatically triggers when your Claude Code session ends, ensuring no context is lost.
105
106## What It Does
107
1081. **Captures Context**: Extracts current conversation, decisions, and context
1092. **Summarizes**: Creates structured summary of key points
1103. **Stores**: Automatically stores to Nexus Memory System
1114. **Confirms**: Shows what was stored
112
113## Triggers
114
115- **on_session_end**: When you close Claude Code
116- **on_checkpoint**: At periodic checkpoints during long sessions
117- **on_completion**: When a task is completed
118- **on_error**: If an error occurs (stores context for debugging)
119
120## No Manual Action Required
121
122This skill runs automatically. You don't need to remember to trigger it.
123You do not need to start a Nexus server manually for normal CLI memory capture.
124
125## Configuration
126
127The skill reads from:
128- `NEXUS_AUTO_INGEST=true` environment variable
129- the local Nexus CLI runtime for default operation
130
131Optional:
132- an external Nexus endpoint only when explicitly configured for advanced remote workflows
133
134## Output
135
136After storing, you'll see:
137```
138[Nexus] Stored 3 memories from Claude Code session:
139 - 2 decisions
140 - 1 context item
141 - Memory IDs: nexus_123, nexus_124, nexus_125
142```
143"#;
144
145 std::fs::write(&skill_md, skill_content).map_err(|e| {
146 HookError::InstallationFailed(format!("Failed to write skill file: {}", e))
147 })?;
148
149 self.skill_installed = true;
150 tracing::info!("Claude Code Skill installed at: {:?}", self.skill_path);
151
152 Ok(())
153 }
154
155 fn settings_path() -> PathBuf {
157 dirs::home_dir()
158 .unwrap_or_else(|| PathBuf::from("."))
159 .join(Self::CONFIG_DIR)
160 .join("settings.json")
161 }
162
163 fn install_settings_hook(&mut self) -> Result<()> {
169 let settings_path = Self::settings_path();
170 let command = Self::desired_session_start_command();
171
172 let mut settings = if settings_path.exists() {
173 let content = std::fs::read_to_string(&settings_path).map_err(|e| {
174 HookError::InstallationFailed(format!("Failed to read settings.json: {}", e))
175 })?;
176 serde_json::from_str::<serde_json::Value>(&content).map_err(|e| {
177 HookError::InstallationFailed(format!("Failed to parse settings.json: {}", e))
178 })?
179 } else {
180 serde_json::json!({})
181 };
182
183 Self::upsert_session_start_hook(&mut settings, &command)?;
184
185 let serialized = serde_json::to_string_pretty(&settings).map_err(|e| {
187 HookError::InstallationFailed(format!("Failed to serialize settings: {}", e))
188 })?;
189
190 if let Some(parent) = settings_path.parent() {
192 std::fs::create_dir_all(parent).map_err(|e| {
193 HookError::InstallationFailed(format!("Failed to create settings dir: {}", e))
194 })?;
195 }
196
197 std::fs::write(&settings_path, serialized).map_err(|e| {
198 HookError::InstallationFailed(format!("Failed to write settings.json: {}", e))
199 })?;
200
201 self.settings_hook_installed = true;
202 tracing::info!(
203 "Claude Code SessionStart hook written to: {:?}",
204 settings_path
205 );
206
207 Ok(())
208 }
209
210 fn find_nexus_binary() -> String {
212 if let Ok(bin) = std::env::var("NEXUS_HOOK_BINARY") {
213 if !bin.trim().is_empty() {
214 return bin;
215 }
216 }
217
218 if let Ok(current_exe) = std::env::current_exe() {
219 if current_exe
220 .file_name()
221 .and_then(|name| name.to_str())
222 .is_some_and(|name| name == "nexus")
223 {
224 return current_exe.to_string_lossy().to_string();
225 }
226 }
227
228 let candidates: Vec<PathBuf> = [
230 dirs::home_dir().map(|h| h.join(".local").join("bin").join("nexus")),
231 Some(PathBuf::from("/usr/local/bin/nexus")),
232 ]
233 .into_iter()
234 .flatten()
235 .collect();
236
237 for candidate in candidates {
238 if candidate.exists() {
239 return candidate.to_string_lossy().to_string();
240 }
241 }
242
243 "nexus".to_string()
245 }
246
247 fn desired_session_start_command() -> String {
248 let nexus_bin = Self::find_nexus_binary();
249 format!(
250 "'{}' session start --agent claude-code --mode session",
251 nexus_bin.replace('\'', "'\\''")
252 )
253 }
254
255 fn has_settings_hook() -> bool {
256 let settings_path = Self::settings_path();
257 let Ok(content) = std::fs::read_to_string(settings_path) else {
258 return false;
259 };
260 let Ok(settings) = serde_json::from_str::<serde_json::Value>(&content) else {
261 return false;
262 };
263 let desired_command = Self::desired_session_start_command();
264 settings
265 .get("hooks")
266 .and_then(|hooks| hooks.get("SessionStart"))
267 .and_then(|value| value.as_array())
268 .is_some_and(|entries| {
269 entries.iter().any(|entry| {
270 Self::entry_contains_exact_session_start_hook(entry, &desired_command)
271 })
272 })
273 }
274
275 #[cfg(test)]
276 fn entry_has_session_start_hook(entry: &serde_json::Value) -> bool {
277 entry
278 .get("command")
279 .and_then(|command| command.as_str())
280 .map(Self::command_is_session_start_hook)
281 .unwrap_or(false)
282 || entry
283 .get("hooks")
284 .and_then(|hooks| hooks.as_array())
285 .is_some_and(|hooks| {
286 hooks.iter().any(|hook| {
287 hook.get("command")
288 .and_then(|command| command.as_str())
289 .map(Self::command_is_session_start_hook)
290 .unwrap_or(false)
291 })
292 })
293 }
294
295 fn command_is_session_start_hook(command: &str) -> bool {
296 command.contains("nexus")
297 && command.contains("session start")
298 && command.contains("claude-code")
299 }
300
301 fn entry_contains_exact_session_start_hook(
302 entry: &serde_json::Value,
303 desired_command: &str,
304 ) -> bool {
305 entry
306 .get("command")
307 .and_then(|command| command.as_str())
308 .map(|command| command == desired_command)
309 .unwrap_or(false)
310 || entry
311 .get("hooks")
312 .and_then(|hooks| hooks.as_array())
313 .is_some_and(|hooks| {
314 hooks.iter().any(|hook| {
315 hook.get("command")
316 .and_then(|command| command.as_str())
317 .map(|command| command == desired_command)
318 .unwrap_or(false)
319 })
320 })
321 }
322
323 fn upsert_session_start_hook(
324 settings: &mut serde_json::Value,
325 desired_command: &str,
326 ) -> Result<()> {
327 let settings_obj = settings.as_object_mut().ok_or_else(|| {
328 HookError::InstallationFailed(
329 "settings.json must contain a top-level JSON object".to_string(),
330 )
331 })?;
332
333 let hooks = settings_obj
334 .entry("hooks")
335 .or_insert_with(|| serde_json::json!({}));
336 let hooks_obj = hooks.as_object_mut().ok_or_else(|| {
337 HookError::InstallationFailed("'hooks' must be a JSON object".to_string())
338 })?;
339
340 let session_start = hooks_obj
341 .entry("SessionStart")
342 .or_insert_with(|| serde_json::json!([]));
343 let entries = session_start.as_array_mut().ok_or_else(|| {
344 HookError::InstallationFailed("'hooks.SessionStart' must be an array".to_string())
345 })?;
346
347 if Self::replace_existing_session_start_hook(entries, desired_command) {
348 return Ok(());
349 }
350
351 entries.push(serde_json::json!({
352 "matcher": "",
353 "hooks": [{
354 "type": "command",
355 "command": desired_command,
356 }]
357 }));
358
359 Ok(())
360 }
361
362 fn replace_existing_session_start_hook(
363 entries: &mut [serde_json::Value],
364 desired_command: &str,
365 ) -> bool {
366 for entry in entries {
367 if entry
368 .get("command")
369 .and_then(|value| value.as_str())
370 .is_some_and(Self::command_is_session_start_hook)
371 {
372 *entry = serde_json::json!({
373 "type": "command",
374 "command": desired_command,
375 });
376 return true;
377 }
378
379 if let Some(hooks) = entry
380 .get_mut("hooks")
381 .and_then(|value| value.as_array_mut())
382 {
383 for hook in hooks {
384 if hook
385 .get("command")
386 .and_then(|value| value.as_str())
387 .is_some_and(Self::command_is_session_start_hook)
388 {
389 *hook = serde_json::json!({
390 "type": "command",
391 "command": desired_command,
392 });
393 return true;
394 }
395 }
396 }
397 }
398
399 false
400 }
401
402 fn read_session_file(&self) -> Option<serde_json::Value> {
404 let session_file = dirs::home_dir()?
405 .join(Self::CONFIG_DIR)
406 .join("session.json");
407
408 if session_file.exists() {
409 let content = std::fs::read_to_string(&session_file).ok()?;
410 serde_json::from_str(&content).ok()
411 } else {
412 None
413 }
414 }
415
416 fn read_checkpoint_data(&self) -> Option<Vec<serde_json::Value>> {
418 let checkpoint_dir = dirs::home_dir()?.join(Self::CONFIG_DIR).join("checkpoints");
419
420 if !checkpoint_dir.exists() {
421 return None;
422 }
423
424 let mut checkpoints = Vec::new();
425
426 if let Ok(entries) = std::fs::read_dir(&checkpoint_dir) {
427 for entry in entries.flatten() {
428 if entry
429 .path()
430 .extension()
431 .map(|e| e == "json")
432 .unwrap_or(false)
433 {
434 if let Ok(content) = std::fs::read_to_string(entry.path()) {
435 if let Ok(data) = serde_json::from_str(&content) {
436 checkpoints.push(data);
437 }
438 }
439 }
440 }
441 }
442
443 Some(checkpoints)
444 }
445}
446
447impl Default for ClaudeCodeHook {
448 fn default() -> Self {
449 Self::new()
450 }
451}
452
453#[async_trait]
454impl AgentHook for ClaudeCodeHook {
455 fn agent_type(&self) -> &str {
456 &self.base.agent_type
457 }
458
459 async fn install_session_end_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
460 self.base.add_callback(callback);
461 self.base.installed = true;
462
463 if !self.skill_installed {
464 tracing::warn!("Claude Code Skill not installed, using fallback detection");
465 }
466
467 Ok(())
468 }
469
470 async fn install_session_start_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
476 self.base.add_callback(callback);
477
478 self.install_settings_hook()?;
479
480 Ok(())
481 }
482
483 async fn install_checkpoint_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
485 self.base.add_callback(callback);
486 Ok(())
487 }
488
489 async fn install_compact_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
491 self.base.add_callback(callback);
492 Ok(())
493 }
494
495 async fn install_error_hook(&mut self, callback: SessionEndCallback) -> Result<()> {
497 self.base.add_callback(callback);
498 Ok(())
499 }
500
501 async fn detect_session_activity(&self) -> Result<SessionActivity> {
502 let mut monitor = self.process_monitor.clone();
504 let processes = monitor.find_agent_processes(AgentType::ClaudeCode);
505
506 let mut activity = SessionActivity::new(AgentType::ClaudeCode);
507
508 if !processes.is_empty() {
509 activity.is_active = true;
510 activity.processes = processes;
511 }
512
513 if let Some(session) = self.read_session_file() {
515 if let Some(id) = session.get("session_id").and_then(|s| s.as_str()) {
516 activity.session_id = Some(id.to_string());
517 }
518 }
519
520 Ok(activity)
521 }
522
523 async fn extract_session_context(&self) -> Result<SessionContext> {
524 let mut context = SessionContext::new("claude-code")
525 .with_source("native")
526 .with_reliability(1.0);
527
528 if let Some(session) = self.read_session_file() {
530 if let Some(messages) = session.get("messages").and_then(|m| m.as_array()) {
531 for msg in messages {
532 let role = msg
533 .get("role")
534 .and_then(|r| r.as_str())
535 .unwrap_or("unknown");
536 let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or("");
537 context.add_message(role, content);
538 }
539 }
540
541 if let Some(project_ctx) = session.get("project_context") {
542 context.add_custom("project_context", project_ctx.clone());
543 }
544 }
545
546 if let Some(checkpoints) = self.read_checkpoint_data() {
548 for checkpoint in checkpoints {
549 if let Some(decisions) = checkpoint.get("decisions").and_then(|d| d.as_array()) {
550 for decision in decisions {
551 if let Some(summary) = decision.get("summary").and_then(|s| s.as_str()) {
552 let mut dec = crate::session::Decision::new(summary);
553 if let Some(rationale) =
554 decision.get("rationale").and_then(|r| r.as_str())
555 {
556 dec.rationale = Some(rationale.to_string());
557 }
558 context.add_decision(dec);
559 }
560 }
561 }
562
563 if let Some(files) = checkpoint.get("files").and_then(|f| f.as_array()) {
564 for file in files {
565 if let Some(path) = file.get("path").and_then(|p| p.as_str()) {
566 let action = file
567 .get("action")
568 .and_then(|a| a.as_str())
569 .unwrap_or("modified");
570 let file_action = match action {
571 "created" => crate::session::FileAction::Created,
572 "deleted" => crate::session::FileAction::Deleted,
573 "read" => crate::session::FileAction::Read,
574 _ => crate::session::FileAction::Modified,
575 };
576 context.add_file(crate::session::FileInfo::new(path, file_action));
577 }
578 }
579 }
580 }
581 }
582
583 context.complete();
584 Ok(context)
585 }
586
587 fn is_hook_installed(&self) -> bool {
588 self.skill_installed || self.settings_hook_installed
589 }
590
591 fn reliability_score(&self) -> f32 {
592 if self.skill_installed && self.settings_hook_installed {
593 1.0
594 } else if self.skill_installed || self.settings_hook_installed {
595 0.98
596 } else {
597 0.95 }
599 }
600
601 fn lifecycle_capabilities(&self) -> LifecycleCapabilities {
602 LifecycleCapabilities {
603 session_start: true,
604 session_end: true,
605 checkpoint: true,
606 error_hook: true,
607 compact: true,
608 }
609 }
610
611 fn support_tier(&self) -> SupportTier {
612 SupportTier::NativeLifecycle
613 }
614}
615
616#[cfg(test)]
617mod tests {
618 use super::*;
619
620 #[test]
621 fn test_claude_hook_new() {
622 let hook = ClaudeCodeHook::new();
623 assert_eq!(hook.agent_type(), "claude-code");
624 }
625
626 #[tokio::test]
627 async fn test_claude_hook_detect_activity() {
628 let hook = ClaudeCodeHook::new();
629 let activity = hook.detect_session_activity().await.unwrap();
630
631 assert_eq!(activity.agent_type, AgentType::ClaudeCode);
632 }
633
634 #[test]
635 fn test_claude_hook_lifecycle_capabilities() {
636 let hook = ClaudeCodeHook::new();
637 let caps = hook.lifecycle_capabilities();
638
639 assert!(
640 caps.session_start,
641 "Claude Code should support session_start"
642 );
643 assert!(caps.session_end, "Claude Code should support session_end");
644 assert!(caps.checkpoint, "Claude Code should support checkpoint");
645 assert!(caps.error_hook, "Claude Code should support error_hook");
646 assert!(caps.compact, "Claude Code should support compact");
647 }
648
649 #[tokio::test]
650 async fn test_claude_hook_install_session_start() {
651 let mut hook = ClaudeCodeHook::new();
652 let callback = std::sync::Arc::new(|_ctx| {});
653
654 let result = hook.install_session_start_hook(callback).await;
656 match result {
658 Ok(()) => {
659 assert!(hook.settings_hook_installed);
660 }
661 Err(HookError::InstallationFailed(_)) => {
662 }
664 Err(HookError::NotSupported(msg)) => {
665 panic!(
666 "Session start should be supported for Claude Code, got: {}",
667 msg
668 );
669 }
670 Err(e) => {
671 panic!("Unexpected error: {}", e);
672 }
673 }
674 }
675
676 #[tokio::test]
677 async fn test_claude_hook_install_checkpoint_supported() {
678 let mut hook = ClaudeCodeHook::new();
679 let callback = std::sync::Arc::new(|_ctx| {});
680
681 let result = hook.install_checkpoint_hook(callback).await;
682 assert!(
683 result.is_ok(),
684 "Checkpoint should be supported for Claude Code"
685 );
686 }
687
688 #[tokio::test]
689 async fn test_claude_hook_install_error_supported() {
690 let mut hook = ClaudeCodeHook::new();
691 let callback = std::sync::Arc::new(|_ctx| {});
692
693 let result = hook.install_error_hook(callback).await;
694 assert!(
695 result.is_ok(),
696 "Error hook should be supported for Claude Code"
697 );
698 }
699
700 #[test]
701 fn test_find_nexus_binary() {
702 let bin = ClaudeCodeHook::find_nexus_binary();
703 assert!(!bin.is_empty());
704 assert!(bin.contains("nexus"));
706 }
707
708 #[test]
709 fn test_entry_has_session_start_hook_detects_nested_command() {
710 let entry = serde_json::json!({
711 "matcher": "",
712 "hooks": [
713 {
714 "type": "command",
715 "command": "/tmp/nexus session start --agent claude-code --mode session"
716 }
717 ]
718 });
719
720 assert!(ClaudeCodeHook::entry_has_session_start_hook(&entry));
721 }
722
723 #[test]
724 fn test_upsert_session_start_hook_repairs_stale_command() {
725 let desired = "'/new/nexus' session start --agent claude-code --mode session";
726 let mut settings = serde_json::json!({
727 "hooks": {
728 "SessionStart": [{
729 "matcher": "",
730 "hooks": [{
731 "type": "command",
732 "command": "'/old/nexus' session start --agent claude-code --mode session"
733 }]
734 }]
735 }
736 });
737
738 ClaudeCodeHook::upsert_session_start_hook(&mut settings, desired).unwrap();
739
740 let hooks = settings["hooks"]["SessionStart"].as_array().unwrap();
741 assert_eq!(hooks.len(), 1);
742 assert_eq!(hooks[0]["hooks"][0]["command"], desired);
743 }
744
745 #[test]
746 fn test_upsert_session_start_hook_rejects_invalid_shapes() {
747 let mut settings = serde_json::json!({
748 "hooks": {
749 "SessionStart": {}
750 }
751 });
752
753 let error = ClaudeCodeHook::upsert_session_start_hook(
754 &mut settings,
755 "'/nexus' session start --agent claude-code --mode session",
756 )
757 .unwrap_err();
758
759 assert!(error.to_string().contains("SessionStart"));
760 }
761}