1use nexus_agent::soul::soul_path;
4use nexus_core::fsutil::atomic_write;
5use serde_json::Value;
6use std::fs;
7use std::io::{self, Write};
8use std::path::{Path, PathBuf};
9use tracing::{debug, info, warn};
10
11#[derive(Debug, Clone)]
13pub struct AgentInjectionTarget {
14 pub agent_type: String,
15 pub global_config: Option<PathBuf>,
16 pub project_config_filename: String,
17}
18
19pub const NEXUS_BLOCK_START: &str = "<!-- NEXUS:START -->";
21pub const NEXUS_BLOCK_END: &str = "<!-- NEXUS:END -->";
22
23fn is_nexus_owned(value: &Value) -> bool {
27 value
28 .get("source")
29 .and_then(|v| v.as_str())
30 .map(|s| s == "nexus-memory")
31 .unwrap_or(false)
32}
33
34impl AgentInjectionTarget {
35 pub fn known_agents() -> Vec<Self> {
37 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
38 vec![
39 Self {
40 agent_type: "claude-code".to_string(),
41 global_config: Some(home.join(".claude").join("CLAUDE.md")),
42 project_config_filename: "CLAUDE.md".to_string(),
43 },
44 Self {
45 agent_type: "amp".to_string(),
46 global_config: Some(home.join(".config").join("amp").join("AGENTS.md")),
47 project_config_filename: "AGENTS.md".to_string(),
48 },
49 Self {
50 agent_type: "codex".to_string(),
51 global_config: Some(home.join(".config").join("codex").join("AGENTS.md")),
52 project_config_filename: "AGENTS.md".to_string(),
53 },
54 Self {
55 agent_type: "gemini".to_string(),
56 global_config: Some(home.join(".gemini").join("GEMINI.md")),
57 project_config_filename: "GEMINI.md".to_string(),
58 },
59 Self {
60 agent_type: "pi-mono".to_string(),
61 global_config: Some(home.join(".pi").join("agent").join("AGENTS.md")),
62 project_config_filename: ".pi/AGENTS.md".to_string(),
63 },
64 Self {
65 agent_type: "droid".to_string(),
66 global_config: Some(home.join(".factory").join("settings.json")),
67 project_config_filename: ".factory/settings.json".to_string(),
68 },
69 ]
70 }
71
72 pub fn find(agent_type: &str) -> Option<Self> {
74 Self::known_agents()
75 .into_iter()
76 .find(|t| t.agent_type == agent_type)
77 }
78}
79
80pub fn inject_reference(
88 config_file: &Path,
89 soul_path: &Path,
90 context_path: &Path,
91 _agent_type: Option<&str>,
92) -> io::Result<()> {
93 if !config_file.exists() {
94 return Ok(());
95 }
96
97 let content = fs::read_to_string(config_file)?;
98 let original_content = content.clone();
99
100 let is_json = config_file
102 .extension()
103 .map(|ext| ext == "json")
104 .unwrap_or(false);
105
106 let new_content = if is_json {
107 inject_into_json(&content, config_file, soul_path, Some(context_path))?
109 } else {
110 let block = format!(
112 "{}\n\
113 ## Nexus Memory Substrate\n\
114 - Identity: [{soul_name}]({soul_path})\n\
115 - Project Context: [{context_name}]({context_path})\n\
116 {}",
117 NEXUS_BLOCK_START,
118 NEXUS_BLOCK_END,
119 soul_name = "Soul",
120 soul_path = soul_path.to_string_lossy(),
121 context_name = "Project Context",
122 context_path = context_path.to_string_lossy(),
123 );
124
125 if let (Some(start), Some(end)) = (
126 content.find(NEXUS_BLOCK_START),
127 content.find(NEXUS_BLOCK_END),
128 ) {
129 if start >= end {
130 let stripped = content
131 .replace(NEXUS_BLOCK_START, "")
132 .replace(NEXUS_BLOCK_END, "");
133 let mut updated = stripped.trim_end().to_string();
134 updated.push('\n');
135 updated.push_str(&block);
136 if !updated.ends_with('\n') {
137 updated.push('\n');
138 }
139 updated
140 } else {
141 let mut updated = content[..start].to_string();
142 updated.push_str(&block);
143 updated.push_str(&content[end + NEXUS_BLOCK_END.len()..]);
144 updated
145 }
146 } else {
147 let mut updated = content;
148 if !updated.is_empty() && !updated.ends_with('\n') {
149 updated.push('\n');
150 }
151 updated.push_str(&block);
152 if !updated.ends_with('\n') {
153 updated.push('\n');
154 }
155 updated
156 }
157 };
158
159 if new_content != original_content {
160 atomic_write(config_file, &new_content)?;
161 debug!("Injected Nexus reference into {}", config_file.display());
162 }
163
164 Ok(())
165}
166
167fn inject_into_json(
170 content: &str,
171 config_file: &Path,
172 soul_path: &Path,
173 context_path: Option<&Path>,
174) -> io::Result<String> {
175 let json: Value = serde_json::from_str(content).map_err(|e| {
176 std::io::Error::new(
177 std::io::ErrorKind::InvalidData,
178 format!("Failed to parse JSON in {}: {}", config_file.display(), e),
179 )
180 })?;
181
182 let soul_name = "Soul";
183 let context_name = "Project Context";
184
185 let nexus_obj = if let Some(cp) = context_path {
187 serde_json::json!({
188 "identity": {
189 "name": soul_name,
190 "path": soul_path.to_string_lossy(),
191 "source": "soul.md"
192 },
193 "projectContext": {
194 "name": context_name,
195 "path": cp.to_string_lossy(),
196 "source": "context.md"
197 },
198 "source": "nexus-memory",
199 "version": env!("CARGO_PKG_VERSION"),
200 })
201 } else {
202 serde_json::json!({
203 "identity": {
204 "name": soul_name,
205 "path": soul_path.to_string_lossy(),
206 "source": "soul.md"
207 },
208 "source": "nexus-memory",
209 "version": env!("CARGO_PKG_VERSION"),
210 })
211 };
212
213 insert_nexus_into_json(json, config_file, nexus_obj)
214}
215
216fn insert_nexus_into_json(
218 mut json: Value,
219 config_file: &Path,
220 nexus_obj: serde_json::Value,
221) -> io::Result<String> {
222 use serde_json::Value;
223
224 if let Value::Object(ref mut map) = json {
226 let has_hooks = matches!(map.get("hooks"), Some(Value::Object(_)));
228
229 let target_hooks = if has_hooks {
232 let mut only_hooks = true;
233 for key in map.keys() {
234 if key != "hooks" && key != "nexus" {
235 only_hooks = false;
236 break;
237 }
238 }
239 only_hooks
240 } else {
241 false
242 };
243
244 if target_hooks {
245 if map.get("nexus").map(is_nexus_owned).unwrap_or(false) {
247 map.remove("nexus");
248 }
249
250 if let Some(hooks) = map.get_mut("hooks").and_then(|v| v.as_object_mut()) {
252 if let Some(existing) = hooks.get("nexus") {
253 if !is_nexus_owned(existing) {
254 return Err(io::Error::new(
255 io::ErrorKind::AlreadyExists,
256 format!(
257 "Refusing to overwrite non-Nexus-managed hooks.nexus in {}",
258 config_file.display()
259 ),
260 ));
261 }
262 }
263 hooks.insert("nexus".to_string(), nexus_obj);
264 } else {
265 return Err(io::Error::new(
267 io::ErrorKind::InvalidData,
268 format!(
269 "Expected hooks to be an object in {}",
270 config_file.display()
271 ),
272 ));
273 }
274 } else {
275 if let Some(hooks) = map.get_mut("hooks").and_then(|v| v.as_object_mut()) {
277 if hooks.get("nexus").map(is_nexus_owned).unwrap_or(false) {
278 hooks.remove("nexus");
279 }
280 }
281 if let Some(existing) = map.get("nexus") {
282 if !is_nexus_owned(existing) {
283 return Err(io::Error::new(
284 io::ErrorKind::AlreadyExists,
285 format!(
286 "Refusing to overwrite non-Nexus-managed nexus key in {}",
287 config_file.display()
288 ),
289 ));
290 }
291 }
292 map.insert("nexus".to_string(), nexus_obj);
293 }
294 } else {
295 return Err(io::Error::new(
297 io::ErrorKind::InvalidData,
298 format!(
299 "Expected top-level JSON object for Nexus injection in {}",
300 config_file.display()
301 ),
302 ));
303 }
304
305 serde_json::to_string_pretty(&json).map_err(std::io::Error::other)
306}
307
308pub fn inject_soul_only(
315 config_file: &Path,
316 soul_path: &Path,
317 _agent_type: Option<&str>,
318) -> io::Result<()> {
319 if !config_file.exists() {
320 return Ok(());
321 }
322
323 let content = fs::read_to_string(config_file)?;
324 let original_content = content.clone();
325
326 let is_json = config_file
327 .extension()
328 .map(|ext| ext == "json")
329 .unwrap_or(false);
330
331 let new_content = if is_json {
332 inject_into_json_soul_only(&content, soul_path, config_file)?
333 } else {
334 let block = format!(
335 "{}\n\
336 ## Nexus Memory Substrate\n\
337 - Identity: [Soul]({soul_path_val})\n\
338 {}",
339 NEXUS_BLOCK_START,
340 NEXUS_BLOCK_END,
341 soul_path_val = soul_path.to_string_lossy(),
342 );
343
344 if let (Some(start), Some(end)) = (
345 content.find(NEXUS_BLOCK_START),
346 content.find(NEXUS_BLOCK_END),
347 ) {
348 if start >= end {
349 let stripped = content
350 .replace(NEXUS_BLOCK_START, "")
351 .replace(NEXUS_BLOCK_END, "");
352 let mut updated = stripped.trim_end().to_string();
353 updated.push('\n');
354 updated.push_str(&block);
355 if !updated.ends_with('\n') {
356 updated.push('\n');
357 }
358 updated
359 } else {
360 let mut updated = content[..start].to_string();
361 updated.push_str(&block);
362 updated.push_str(&content[end + NEXUS_BLOCK_END.len()..]);
363 updated
364 }
365 } else {
366 let mut updated = content;
367 if !updated.is_empty() && !updated.ends_with('\n') {
368 updated.push('\n');
369 }
370 updated.push_str(&block);
371 if !updated.ends_with('\n') {
372 updated.push('\n');
373 }
374 updated
375 }
376 };
377
378 if new_content != original_content {
379 atomic_write(config_file, &new_content)?;
380 debug!(
381 "Injected soul-only Nexus reference into {}",
382 config_file.display()
383 );
384 }
385
386 Ok(())
387}
388
389fn inject_into_json_soul_only(
391 content: &str,
392 soul_path: &Path,
393 config_file: &Path,
394) -> io::Result<String> {
395 let json: Value = serde_json::from_str(content).map_err(|e| {
396 std::io::Error::new(
397 std::io::ErrorKind::InvalidData,
398 format!("Failed to parse JSON in {}: {}", config_file.display(), e),
399 )
400 })?;
401
402 let nexus_obj = serde_json::json!({
403 "identity": {
404 "name": "Soul",
405 "path": soul_path.to_string_lossy(),
406 "source": "soul.md"
407 },
408 "source": "nexus-memory",
409 "version": env!("CARGO_PKG_VERSION"),
410 });
411
412 insert_nexus_into_json(json, config_file, nexus_obj)
413}
414
415pub fn remove_reference(config_file: &Path) -> io::Result<()> {
417 if !config_file.exists() {
418 return Ok(());
419 }
420
421 let is_json = config_file
422 .extension()
423 .map(|ext| ext == "json")
424 .unwrap_or(false);
425
426 if is_json {
427 let content = fs::read_to_string(config_file)?;
428 let mut json: Value = serde_json::from_str(&content)
429 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
430
431 let mut changed = false;
432
433 if let Value::Object(ref mut map) = json {
437 let is_wrapper = map.contains_key("nexus") && map.contains_key("original");
439
440 if is_wrapper {
442 map.remove("nexus");
443 changed = true;
444 } else {
445 if map.get("nexus").map(is_nexus_owned).unwrap_or(false) {
447 map.remove("nexus");
448 changed = true;
449 }
450 }
451
452 if let Some(hooks) = map.get_mut("hooks").and_then(|v| v.as_object_mut()) {
454 if hooks.get("nexus").map(is_nexus_owned).unwrap_or(false) {
455 hooks.remove("nexus");
456 changed = true;
457 }
458 }
459
460 if is_wrapper && !map.contains_key("nexus") {
462 if let Some(original) = map.remove("original") {
463 json = original;
464 }
466 }
467 }
468
469 if changed {
470 let updated = serde_json::to_string_pretty(&json).map_err(std::io::Error::other)?;
471 atomic_write(config_file, &updated)?;
472 }
473 } else {
474 let content = fs::read_to_string(config_file)?;
475 let start_pos = content.find(NEXUS_BLOCK_START);
476 let end_pos = content.find(NEXUS_BLOCK_END);
477
478 match (start_pos, end_pos) {
479 (Some(start), Some(end)) => {
481 if start < end {
482 let mut updated = content[..start].to_string();
484 let remaining = &content[end + NEXUS_BLOCK_END.len()..];
485 updated.push_str(remaining);
486 while updated.ends_with("\n\n") {
488 updated.pop();
489 }
490 atomic_write(config_file, &updated)?;
491 } else {
492 let mut updated = content;
494 updated = updated.replace(NEXUS_BLOCK_START, "");
495 updated = updated.replace(NEXUS_BLOCK_END, "");
496 while updated.ends_with("\n\n") {
498 updated.pop();
499 }
500 atomic_write(config_file, &updated)?;
501 }
502 }
503 (Some(start), None) => {
505 let mut updated = content[..start].to_string();
506 let remaining = &content[start + NEXUS_BLOCK_START.len()..];
507 updated.push_str(remaining);
508 while updated.ends_with("\n\n") {
510 updated.pop();
511 }
512 atomic_write(config_file, &updated)?;
513 }
514 (None, Some(end)) => {
516 let mut updated = content[..end].to_string();
517 let remaining = &content[end + NEXUS_BLOCK_END.len()..];
518 updated.push_str(remaining);
519 while updated.ends_with("\n\n") {
521 updated.pop();
522 }
523 atomic_write(config_file, &updated)?;
524 }
525 (None, None) => {}
527 }
528 }
529
530 Ok(())
531}
532
533pub fn auto_seed_soul(project_root: &Path) -> Option<String> {
536 let soul_path = soul_path();
537
538 if soul_path.exists() {
540 if let Ok(content) = fs::read_to_string(&soul_path) {
541 let trimmed = content.trim();
542 if trimmed.is_empty() || trimmed == "# Nexus Soul" {
544 } else if !trimmed.is_empty() {
546 debug!("Soul already has content, skipping auto-seed");
548 return None;
549 }
550 }
551 }
552
553 let mut extracts = Vec::new();
554
555 let claude_md = project_root.join("CLAUDE.md");
557 if claude_md.exists() {
558 if let Ok(content) = fs::read_to_string(&claude_md) {
559 extracts.push(("CLAUDE.md".to_string(), content));
560 }
561 }
562
563 let agents_md = project_root.join("AGENTS.md");
565 if agents_md.exists() {
566 if let Ok(content) = fs::read_to_string(&agents_md) {
567 extracts.push(("AGENTS.md".to_string(), content));
568 }
569 }
570
571 let project_context_path = project_root.join(".nexus").join("context.md");
573 if project_context_path.exists() {
574 if let Ok(content) = fs::read_to_string(&project_context_path) {
575 extracts.push(("context.md".to_string(), content));
576 }
577 }
578
579 if extracts.is_empty() {
580 debug!("No CLAUDE.md or AGENTS.md found for soul auto-seeding");
581 return None;
582 }
583
584 let mut patterns = Vec::new();
586 let mut tool_preferences = Vec::new();
587 let mut coding_conventions = Vec::new();
588 let mut testing_notes = Vec::new();
589
590 for (_source, content) in &extracts {
591 let lines: Vec<&str> = content.lines().collect();
592
593 for line in &lines {
595 let lower = line.to_lowercase();
596 if (lower.contains("cargo")
597 || lower.contains("npm")
598 || lower.contains("python")
599 || lower.contains("uv"))
600 && (lower.contains("build")
601 || lower.contains("test")
602 || lower.contains("lint")
603 || lower.contains("format"))
604 {
605 tool_preferences.push(line.trim().to_string());
606 }
607 }
608
609 let in_code_block = content.contains("```");
611 if in_code_block {
612 if content.contains("use anyhow") || content.contains("anyhow::Result") {
614 coding_conventions.push("Uses anyhow for error handling".to_string());
615 }
616 if content.contains("use serde") || content.contains("#[derive(Serialize") {
617 coding_conventions.push("Uses serde for serialization".to_string());
618 }
619 if content.contains("#[cfg(") {
620 coding_conventions.push("Uses feature gating (#[cfg])".to_string());
621 }
622 }
623
624 if content.to_lowercase().contains("test") {
626 if content.to_lowercase().contains("tdd")
627 || content.to_lowercase().contains("test-driven")
628 {
629 testing_notes.push("Test-Driven Development approach".to_string());
630 }
631 if content.to_lowercase().contains("integration") {
632 testing_notes.push("Integration tests".to_string());
633 }
634 }
635
636 if content.to_lowercase().contains("convention") || content.to_lowercase().contains("style")
638 {
639 for line in &lines {
640 if (line.to_lowercase().contains("prefer")
641 || line.to_lowercase().contains("always"))
642 && line.len() > 10
643 && line.len() < 200
644 {
645 patterns.push(line.trim().to_string());
646 }
647 }
648 }
649 }
650
651 let mut soul_content = String::new();
653 soul_content.push_str("# Nexus Soul\n\n");
654
655 soul_content.push_str("## Identity & Preferences\n\n");
657 if !tool_preferences.is_empty() {
658 soul_content.push_str("### Build & Tool Preferences\n");
659 for pref in tool_preferences.iter().take(5) {
660 if !pref.is_empty() {
661 soul_content.push_str(&format!("- {}\n", pref));
662 }
663 }
664 soul_content.push('\n');
665 }
666 if !patterns.is_empty() {
667 soul_content.push_str("### Project Conventions\n");
668 for pat in patterns.iter().take(5) {
669 if !pat.is_empty() {
670 soul_content.push_str(&format!("- {}\n", pat));
671 }
672 }
673 soul_content.push('\n');
674 }
675
676 soul_content.push_str("## Technical Learnings\n\n");
678 if !coding_conventions.is_empty() {
679 for conv in coding_conventions.iter() {
680 soul_content.push_str(&format!("- {}\n", conv));
681 }
682 }
683 if content_contains_rust(&extracts) {
685 soul_content.push_str("- Project uses Rust (Cargo)\n");
686 }
687 if content_contains_warnings_policy(&extracts) {
688 soul_content.push_str("- Zero warnings policy enforced\n");
689 }
690 soul_content.push('\n');
691
692 soul_content.push_str("## Working Patterns\n\n");
694 for note in testing_notes.iter().take(3) {
695 soul_content.push_str(&format!("- {}\n", note));
696 }
697 soul_content.push('\n');
698
699 soul_content.push_str("## Agent Notes\n\n");
701 soul_content.push_str("- Auto-generated from project CLAUDE.md/AGENTS.md\n");
702 soul_content.push_str("- Update manually with additional learnings\n");
703 soul_content.push('\n');
704
705 let trimmed = soul_content.trim();
707 let has_real_content = trimmed
708 .lines()
709 .filter(|l| !l.starts_with('#') && !l.trim().is_empty())
710 .count()
711 > 3;
712
713 if has_real_content {
714 if let Some(parent) = soul_path.parent() {
716 let _ = fs::create_dir_all(parent);
717 }
718
719 if let Err(e) = atomic_write(&soul_path, &soul_content) {
721 tracing::warn!("Failed to write auto-seeded soul: {}", e);
722 return None;
723 }
724
725 info!("Auto-seeded soul.md from project config files");
726 Some(soul_content)
727 } else {
728 None
729 }
730}
731
732fn content_contains_rust(extracts: &[(String, String)]) -> bool {
733 for (_, content) in extracts {
734 let lower = content.to_lowercase();
735 if lower.contains("cargo") || lower.contains("rust") || lower.contains(".rs") {
736 return true;
737 }
738 }
739 false
740}
741
742fn content_contains_warnings_policy(extracts: &[(String, String)]) -> bool {
743 for (_, content) in extracts {
744 let lower = content.to_lowercase();
745 if lower.contains("warning")
746 && (lower.contains("error") || lower.contains("strict") || lower.contains("deny"))
747 {
748 return true;
749 }
750 }
751 false
752}
753
754pub async fn on_session_start(
756 cwd: &Path,
757 agent_type: &str,
758 session_id: &str,
759) -> anyhow::Result<()> {
760 let start_time = std::time::Instant::now();
761 info!(
762 "Starting Nexus session start pipeline for {} ({})",
763 agent_type, session_id
764 );
765
766 let project = nexus_core::ProjectIdentity::resolve(cwd);
768 let nexus_dir = project.root_dir.join(".nexus");
769 fs::create_dir_all(&nexus_dir)?;
770 fs::create_dir_all(nexus_dir.join("cache"))?;
771 fs::create_dir_all(nexus_dir.join("sessions"))?;
772
773 let config = nexus_core::Config::from_env().unwrap_or_default();
775 if let Some(parent) = config.database.path.parent() {
777 fs::create_dir_all(parent)?;
778 }
779 let mut storage = nexus_storage::StorageManager::from_url(&config.database_url()).await?;
780 storage.initialize().await?;
781 let memory_repo = nexus_storage::repository::MemoryRepository::new(storage.pool().clone());
782 let ns_repo = nexus_storage::repository::NamespaceRepository::new(storage.pool().clone());
783 let namespace = ns_repo.get_or_create(agent_type, agent_type).await?;
784
785 let mut cache = nexus_agent::cognitive_cache::CognitiveCache::load_or_init(&nexus_dir);
787 cache
789 .hot_cache
790 .entries
791 .retain(|e| !e.content.contains("Session lifecycle event"));
792
793 let embedder = if config.embedding.enabled {
795 nexus_agent::runtime::create_embedding_service(&config).await
796 } else {
797 None
798 };
799
800 let recalls = cache
801 .morning_recall(
802 &project,
803 namespace.id,
804 &memory_repo,
805 embedder
806 .as_ref()
807 .map(|e| e.as_ref() as &dyn nexus_core::EmbeddingService),
808 )
809 .await;
810
811 let window_size = nexus_agent::TokenBudget::estimate_window(agent_type) as f32;
813 let max_context_tokens =
814 (window_size * config.cognitive_system.context_allocation_pct) as usize;
815 let context_md = nexus_agent::context_builder::build_context_md(
816 &cache.hot_cache,
817 &recalls,
818 max_context_tokens,
819 );
820
821 let context_path = nexus_dir.join("context.md");
822 atomic_write(&context_path, &context_md)?;
823
824 let hot_cache_max = config.cognitive_system.hot_cache_max_entries;
826 for recall in &recalls {
827 let entry = nexus_agent::cognitive_cache::HotCacheEntry {
828 memory_id: recall.memory_id,
829 content: recall.content.clone(),
830 relevance_score: recall.relevance_score,
831 tier: recall.tier,
832 promoted_at: chrono::Utc::now(),
833 last_surfaced: chrono::Utc::now(),
834 hot_streak: 1,
835 pinned: false,
836 source_agent: Some(agent_type.to_string()),
837 };
838 cache.hot_cache.promote(entry, hot_cache_max);
839 }
840
841 cache.save(&nexus_dir)?;
843
844 let _ = auto_seed_soul(&project.root_dir);
846
847 let soul_path = dirs::config_dir()
850 .unwrap_or_else(|| PathBuf::from("."))
851 .join("nexus")
852 .join("soul.md");
853
854 if let Some(target) = AgentInjectionTarget::find(agent_type) {
856 let project_config = project.root_dir.join(&target.project_config_filename);
858 if let Err(e) =
859 inject_reference(&project_config, &soul_path, &context_path, Some(agent_type))
860 {
861 if e.kind() == std::io::ErrorKind::AlreadyExists {
862 warn!(file=?project_config, error=?e, "Skipping injection: config already contains non-Nexus reference");
864 } else {
865 return Err(e.into());
866 }
867 }
868
869 if let Some(global_config) = target.global_config {
871 if global_config == project_config {
873 debug!(file=?global_config, "Skipping global soul-only injection: same as project config");
874 } else if let Err(e) = inject_soul_only(&global_config, &soul_path, Some(agent_type)) {
875 if e.kind() == std::io::ErrorKind::AlreadyExists {
876 warn!(file=?global_config, error=?e, "Skipping injection: global config already contains non-Nexus reference");
877 } else {
878 return Err(e.into());
879 }
880 }
881 }
882 }
883
884 let session_manager = nexus_agent::session_manager::SessionManager::new(&project.root_dir);
886 session_manager.start_session(session_id, agent_type)?;
887
888 let gitignore = project.root_dir.join(".gitignore");
890 let gitignore_content = fs::read_to_string(&gitignore).unwrap_or_default();
891 let has_nexus_entry = gitignore_content.lines().any(|line| {
892 let trimmed = line.trim();
893 trimmed == ".nexus" || trimmed == ".nexus/" || trimmed == "/.nexus" || trimmed == "/.nexus/"
894 });
895 if !has_nexus_entry {
896 let mut f = fs::OpenOptions::new()
897 .create(true)
898 .append(true)
899 .open(&gitignore)?;
900 if !gitignore_content.is_empty() && !gitignore_content.ends_with('\n') {
901 writeln!(f)?;
902 }
903 writeln!(f, ".nexus/")?;
904 }
905
906 info!(
907 "Nexus session start pipeline completed in {:?} (hot cache: {} entries)",
908 start_time.elapsed(),
909 cache.hot_cache.entries.len()
910 );
911 Ok(())
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917 use tempfile::tempdir;
918
919 #[test]
920 fn test_inject_reference_idempotency() {
921 let dir = tempdir().unwrap();
922 let config = dir.path().join("CLAUDE.md");
923 fs::write(&config, "# Existing Content\n").unwrap();
924
925 let soul = PathBuf::from("/tmp/soul.md");
926 let context = PathBuf::from("/tmp/context.md");
927
928 inject_reference(&config, &soul, &context, None).unwrap();
930 let content1 = fs::read_to_string(&config).unwrap();
931 assert!(content1.contains(NEXUS_BLOCK_START));
932
933 inject_reference(&config, &soul, &context, None).unwrap();
935 let content2 = fs::read_to_string(&config).unwrap();
936 assert_eq!(content1, content2);
937 }
938
939 #[test]
940 fn test_remove_reference() {
941 let dir = tempdir().unwrap();
942 let config = dir.path().join("AGENTS.md");
943 fs::write(
944 &config,
945 "# Top\n<!-- NEXUS:START -->\n- Ref\n<!-- NEXUS:END -->\n# Bottom",
946 )
947 .unwrap();
948
949 remove_reference(&config).unwrap();
950 let content = fs::read_to_string(&config).unwrap();
951 assert!(!content.contains("NEXUS:START"));
952 assert!(content.contains("# Top"));
953 assert!(content.contains("# Bottom"));
954 }
955
956 #[tokio::test]
957 async fn test_on_session_start_creates_structure() {
958 let dir = tempdir().unwrap();
959 let db_path = dir.path().join("test.db");
960 let original_db = std::env::var("NEXUS_DATABASE_PATH").ok();
961 std::env::set_var("NEXUS_DATABASE_PATH", &db_path);
962
963 let result = on_session_start(dir.path(), "claude-code", "test-session").await;
964
965 if let Some(orig) = original_db {
966 std::env::set_var("NEXUS_DATABASE_PATH", orig);
967 } else {
968 std::env::remove_var("NEXUS_DATABASE_PATH");
969 }
970
971 result.unwrap();
972
973 assert!(dir.path().join(".nexus").exists());
974 assert!(dir.path().join(".nexus/context.md").exists());
975 assert!(dir.path().join(".nexus/sessions/test-session.md").exists());
976 }
977
978 #[test]
979 fn test_droid_injection_target_registered() {
980 let target = AgentInjectionTarget::find("droid");
981 assert!(target.is_some(), "droid must be in known_agents()");
982
983 let target = target.unwrap();
984 assert_eq!(target.agent_type, "droid");
985 assert!(target.global_config.is_some());
986 assert_eq!(target.project_config_filename, ".factory/settings.json");
987
988 let global = target.global_config.unwrap();
989 assert!(global.to_string_lossy().contains(".factory/settings.json"));
990 }
991
992 #[test]
995 fn test_inject_into_json_hooks_path() {
996 let dir = tempdir().unwrap();
997 let config = dir.path().join("settings.json");
998 let soul = Path::new("/tmp/soul.md");
999 let context = Path::new("/tmp/context.md");
1000 let initial_json = r#"{"hooks": {}}"#;
1001 fs::write(&config, initial_json).unwrap();
1002
1003 let result = inject_into_json(initial_json, &config, soul, Some(context)).unwrap();
1004 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1005
1006 let hooks = parsed.get("hooks").and_then(|v| v.as_object()).unwrap();
1007 assert!(hooks.contains_key("nexus"));
1008 let nexus = hooks.get("nexus").unwrap();
1009 assert_eq!(
1010 nexus.get("source").and_then(|v| v.as_str()),
1011 Some("nexus-memory")
1012 );
1013 let result2 = inject_into_json(initial_json, &config, soul, Some(context)).unwrap();
1015 assert_eq!(result, result2);
1016 }
1017
1018 #[test]
1019 fn test_inject_into_json_root_path() {
1020 let dir = tempdir().unwrap();
1021 let config = dir.path().join("settings.json");
1022 let soul = Path::new("/tmp/soul.md");
1023 let context = Path::new("/tmp/context.md");
1024 let initial_json = r#"{"some_other_key": "value"}"#;
1025 fs::write(&config, initial_json).unwrap();
1026
1027 let result = inject_into_json(initial_json, &config, soul, Some(context)).unwrap();
1028 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1029
1030 assert!(parsed.get("nexus").is_some());
1031 let nexus = parsed.get("nexus").unwrap();
1032 assert_eq!(
1033 nexus.get("source").and_then(|v| v.as_str()),
1034 Some("nexus-memory")
1035 );
1036 }
1037
1038 #[test]
1039 fn test_inject_into_json_duplicate_cleanup() {
1040 let dir = tempdir().unwrap();
1041 let soul = Path::new("/tmp/soul.md");
1042 let context = Path::new("/tmp/context.md");
1043
1044 {
1046 let config = dir.path().join("hooks_cleanup.json");
1047 let initial_json = r#"{"hooks": {}, "nexus": {"source": "other"}}"#;
1048 fs::write(&config, initial_json).unwrap();
1049 let result = inject_into_json(initial_json, &config, soul, Some(context)).unwrap();
1050 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1051 assert!(
1053 parsed.get("nexus").is_some(),
1054 "root nexus (non-owned) should be preserved"
1055 );
1056 let root_nexus = parsed.get("nexus").unwrap();
1058 assert_eq!(
1059 root_nexus.get("source").and_then(|v| v.as_str()),
1060 Some("other")
1061 );
1062 let hooks = parsed.get("hooks").and_then(|v| v.as_object()).unwrap();
1063 assert!(
1064 hooks.contains_key("nexus"),
1065 "hooks.nexus should exist (Nexus-owned)"
1066 );
1067 }
1068
1069 {
1071 let config = dir.path().join("root_cleanup.json");
1072 let initial_json = r#"{"hooks": {"nexus": {"source": "other"}}, "other": 1}"#;
1073 fs::write(&config, initial_json).unwrap();
1074 let result = inject_into_json(initial_json, &config, soul, Some(context)).unwrap();
1075 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1076 assert!(parsed.get("nexus").is_some(), "root nexus should exist");
1078 let hooks = parsed.get("hooks").and_then(|v| v.as_object()).unwrap();
1079 assert!(
1081 hooks.contains_key("nexus"),
1082 "hooks.nexus (non-owned) should be preserved"
1083 );
1084 let hooks_nexus = hooks.get("nexus").unwrap();
1086 assert_eq!(
1087 hooks_nexus.get("source").and_then(|v| v.as_str()),
1088 Some("other")
1089 );
1090 }
1091 }
1092
1093 #[test]
1094 fn test_inject_into_json_ownership_check() {
1095 let dir = tempdir().unwrap();
1096 let config = dir.path().join("ownership.json");
1097 let soul = Path::new("/tmp/soul.md");
1098 let context = Path::new("/tmp/context.md");
1099
1100 let initial_json = r#"{"hooks": {"nexus": {"source": "something-else"}}}"#;
1102 fs::write(&config, initial_json).unwrap();
1103 let err = inject_into_json(initial_json, &config, soul, Some(context)).unwrap_err();
1104 assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
1105
1106 let initial_json2 = r#"{"nexus": {"source": "other-source"}}"#;
1108 fs::write(&config, initial_json2).unwrap();
1109 let err2 = inject_into_json(initial_json2, &config, soul, Some(context)).unwrap_err();
1110 assert_eq!(err2.kind(), std::io::ErrorKind::AlreadyExists);
1111 }
1112
1113 #[test]
1114 fn test_inject_soul_only_json() {
1115 let dir = tempdir().unwrap();
1116 let config = dir.path().join("soul_only.json");
1117 let soul = Path::new("/tmp/soul.md");
1118
1119 let initial_json = r#"{"hooks": {}}"#;
1121 fs::write(&config, initial_json).unwrap();
1122 let result = inject_into_json_soul_only(initial_json, soul, &config).unwrap();
1123 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
1124 let hooks = parsed.get("hooks").and_then(|v| v.as_object()).unwrap();
1125 let nexus = hooks.get("nexus").unwrap();
1126 assert_eq!(
1127 nexus
1128 .get("identity")
1129 .and_then(|v| v.get("name"))
1130 .and_then(|v| v.as_str()),
1131 Some("Soul")
1132 );
1133 assert!(nexus.get("projectContext").is_none());
1134
1135 let initial_json2 = r#"{"other": "val"}"#;
1137 fs::write(&config, initial_json2).unwrap();
1138 let result2 = inject_into_json_soul_only(initial_json2, soul, &config).unwrap();
1139 let parsed2: serde_json::Value = serde_json::from_str(&result2).unwrap();
1140 let nexus2 = parsed2.get("nexus").unwrap();
1141 assert_eq!(
1142 nexus2
1143 .get("identity")
1144 .and_then(|v| v.get("name"))
1145 .and_then(|v| v.as_str()),
1146 Some("Soul")
1147 );
1148 }
1149
1150 #[test]
1151 fn test_remove_reference_json() {
1152 let dir = tempdir().unwrap();
1153 let config = dir.path().join("remove.json");
1154 let initial_json = r#"{"hooks": {"nexus": {"source": "nexus-memory"}}, "nexus": {"source": "nexus-memory"}, "other": 1}"#;
1155 fs::write(&config, initial_json).unwrap();
1156
1157 remove_reference(&config).unwrap();
1158 let content = fs::read_to_string(&config).unwrap();
1159 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1160
1161 let map = parsed.as_object().unwrap();
1162 assert!(!map.contains_key("nexus"));
1163 if let Some(hooks) = map.get("hooks").and_then(|v| v.as_object()) {
1164 assert!(!hooks.contains_key("nexus"));
1165 }
1166 assert_eq!(map.get("other").and_then(|v| v.as_i64()), Some(1));
1167 }
1168
1169 #[test]
1170 fn test_remove_reference_partial_markers() {
1171 let dir = tempdir().unwrap();
1172 let config = dir.path().join("partial.md");
1173
1174 {
1176 let content = format!("before{}after", NEXUS_BLOCK_START);
1177 fs::write(&config, &content).unwrap();
1178 remove_reference(&config).unwrap();
1179 let result = fs::read_to_string(&config).unwrap();
1180 assert!(!result.contains(NEXUS_BLOCK_START));
1181 assert_eq!(result, "beforeafter");
1182 }
1183
1184 {
1186 let content = format!("before{}after", NEXUS_BLOCK_END);
1187 fs::write(&config, &content).unwrap();
1188 remove_reference(&config).unwrap();
1189 let result = fs::read_to_string(&config).unwrap();
1190 assert!(!result.contains(NEXUS_BLOCK_END));
1191 assert_eq!(result, "beforeafter");
1192 }
1193
1194 {
1196 let content = format!("a{}b{}c", NEXUS_BLOCK_END, NEXUS_BLOCK_START);
1197 fs::write(&config, &content).unwrap();
1198 remove_reference(&config).unwrap();
1199 let result = fs::read_to_string(&config).unwrap();
1200 assert!(!result.contains(NEXUS_BLOCK_START));
1201 assert!(!result.contains(NEXUS_BLOCK_END));
1202 assert_eq!(result, "a b c".replace(" ", "")); }
1204
1205 {
1207 let content = "plain text".to_string();
1208 fs::write(&config, &content).unwrap();
1209 remove_reference(&config).unwrap();
1210 let result = fs::read_to_string(&config).unwrap();
1211 assert_eq!(result, "plain text");
1212 }
1213 }
1214}