Skip to main content

nexus_memory_hooks/
injection.rs

1//! Reference injection system for agent configuration files.
2
3use 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/// Target for injecting Nexus references into an agent's configuration.
12#[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
19/// Sentinel markers for Nexus-managed blocks.
20pub const NEXUS_BLOCK_START: &str = "<!-- NEXUS:START -->";
21pub const NEXUS_BLOCK_END: &str = "<!-- NEXUS:END -->";
22
23/// Check if a JSON value is a Nexus-managed entry.
24///
25/// Nexus-owned entries have `"source": "nexus-memory"` at the top level.
26fn 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    /// Get known agent injection targets.
36    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    /// Find injection target by agent type.
73    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
80/// Inject Nexus references into a config file.
81///
82/// # Parameters
83/// - `config_file`: Path to the configuration file
84/// - `soul_path`: Path to the soul.md reference
85/// - `context_path`: Path to the context.md reference  
86/// - `agent_type`: Reserved for future agent-specific logic (currently unused)
87pub 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    // Detect JSON config files (Droid's settings.json, etc.)
101    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        // For JSON, add settings if not already present
108        inject_into_json(&content, config_file, soul_path, Some(context_path))?
109    } else {
110        // Standard markdown-style injection
111        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
167/// Inject Nexus references into a JSON config file (e.g., Droid's settings.json).
168/// For JSON files, we add a `"nexus"` meta field or add to hooks structure.
169fn 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    // Create the nexus reference object
186    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
216/// Shared logic for inserting a Nexus reference object into a JSON config.
217fn 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    // Handle settings.json-style structure with "hooks" key
225    if let Value::Object(ref mut map) = json {
226        // Determine if hooks exists and is an object
227        let has_hooks = matches!(map.get("hooks"), Some(Value::Object(_)));
228
229        // Determine target location:
230        // Use hooks if has_hooks AND root contains no keys other than "hooks" and "nexus"
231        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            // Clean root nexus before borrowing hooks (only if Nexus-owned)
246            if map.get("nexus").map(is_nexus_owned).unwrap_or(false) {
247                map.remove("nexus");
248            }
249
250            // Insert into hooks
251            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                // hooks existed but not an object; unexpected
266                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            // Root target: clean hooks.nexus if present (only if Nexus-owned)
276            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        // Non-object JSON: this configuration format cannot support Nexus injection
296        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
308/// Inject only the soul identity reference into a config file (no project context).
309///
310/// # Parameters
311/// - `config_file`: Path to the configuration file to modify
312/// - `soul_path`: Path to the soul.md file to reference
313/// - `agent_type`: Reserved for future agent-specific injection logic (currently unused)
314pub 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
389/// Inject soul-only reference into JSON config
390fn 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
415/// Remove Nexus references from a config file.
416pub 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        // Handle wrapper case: if the file was wrapped by inject_into_json for non-object original,
434        // the structure is { "nexus": ..., "original": <original_value> }.
435        // After removing nexus, we should unwrap back to the original.
436        if let Value::Object(ref mut map) = json {
437            // Check if this is a wrapper (has both "nexus" and "original" keys)
438            let is_wrapper = map.contains_key("nexus") && map.contains_key("original");
439
440            // Remove nexus key from wrapper root (Nexus-owned check not needed here - this nexus was created by us)
441            if is_wrapper {
442                map.remove("nexus");
443                changed = true;
444            } else {
445                // Normal case: remove nexus from root (only if Nexus-owned)
446                if map.get("nexus").map(is_nexus_owned).unwrap_or(false) {
447                    map.remove("nexus");
448                    changed = true;
449                }
450            }
451
452            // Remove nexus key from hooks if present (only if Nexus-owned)
453            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 this was a wrapper and nexus was removed, unwrap to original
461            if is_wrapper && !map.contains_key("nexus") {
462                if let Some(original) = map.remove("original") {
463                    json = original;
464                    // Note: changed already true
465                }
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            // Both markers present
480            (Some(start), Some(end)) => {
481                if start < end {
482                    // Valid order: remove from start to end+len
483                    let mut updated = content[..start].to_string();
484                    let remaining = &content[end + NEXUS_BLOCK_END.len()..];
485                    updated.push_str(remaining);
486                    // Collapse trailing double newlines
487                    while updated.ends_with("\n\n") {
488                        updated.pop();
489                    }
490                    atomic_write(config_file, &updated)?;
491                } else {
492                    // Malordered: end before start, treat as orphaned markers, remove individually
493                    let mut updated = content;
494                    updated = updated.replace(NEXUS_BLOCK_START, "");
495                    updated = updated.replace(NEXUS_BLOCK_END, "");
496                    // Collapse trailing double newlines if any
497                    while updated.ends_with("\n\n") {
498                        updated.pop();
499                    }
500                    atomic_write(config_file, &updated)?;
501                }
502            }
503            // Only START marker present
504            (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                // Collapse trailing double newlines
509                while updated.ends_with("\n\n") {
510                    updated.pop();
511                }
512                atomic_write(config_file, &updated)?;
513            }
514            // Only END marker present
515            (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                // Collapse trailing double newlines
520                while updated.ends_with("\n\n") {
521                    updated.pop();
522                }
523                atomic_write(config_file, &updated)?;
524            }
525            // Neither marker present: nothing to do
526            (None, None) => {}
527        }
528    }
529
530    Ok(())
531}
532
533/// Extract project identity from CLAUDE.md and AGENTS.md to auto-seed soul.md
534/// if it doesn't exist or is empty.
535pub fn auto_seed_soul(project_root: &Path) -> Option<String> {
536    let soul_path = soul_path();
537
538    // Check if soul already exists and has content
539    if soul_path.exists() {
540        if let Ok(content) = fs::read_to_string(&soul_path) {
541            let trimmed = content.trim();
542            // Check for empty headers only (the default template)
543            if trimmed.is_empty() || trimmed == "# Nexus Soul" {
544                // Will regenerate below
545            } else if !trimmed.is_empty() {
546                // Soul already has real content - don't overwrite
547                debug!("Soul already has content, skipping auto-seed");
548                return None;
549            }
550        }
551    }
552
553    let mut extracts = Vec::new();
554
555    // Read CLAUDE.md
556    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    // Read AGENTS.md
564    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    // Also check for any .md files in .config/nexus/ project context
572    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    // Extract key patterns from the files
585    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        // Extract build/test commands
594        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        // Extract coding patterns from code blocks
610        let in_code_block = content.contains("```");
611        if in_code_block {
612            // Look for common patterns like use statements, imports, etc.
613            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        // Extract testing patterns
625        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        // Extract general preferences
637        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    // Build a soul.md from extracted patterns
652    let mut soul_content = String::new();
653    soul_content.push_str("# Nexus Soul\n\n");
654
655    // Identity & Preferences section
656    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    // Technical Learnings section
677    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    // Add auto-detected patterns
684    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    // Working Patterns section
693    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    // Agent Notes section
700    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    // Ensure we have some actual content beyond headers
706    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        // Create parent directories if needed
715        if let Some(parent) = soul_path.parent() {
716            let _ = fs::create_dir_all(parent);
717        }
718
719        // Write the auto-seeded soul
720        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
754/// Pipeline executed when a new agent session starts.
755pub 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    // 1. Resolve Project Identity
767    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    // 2. Initialize Repositories (for Morning Recall)
774    let config = nexus_core::Config::from_env().unwrap_or_default();
775    // SQLite create_if_missing won't create parent dirs
776    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    // 3. Load Cache and Perform Morning Recall
786    let mut cache = nexus_agent::cognitive_cache::CognitiveCache::load_or_init(&nexus_dir);
787    // Remove internal session lifecycle memories from hot cache (cleanup from previous runs)
788    cache
789        .hot_cache
790        .entries
791        .retain(|e| !e.content.contains("Session lifecycle event"));
792
793    // Attempt to get embedder if available
794    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    // 4. Build and Write context.md
812    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    // Promote morning recall results to hot cache for future sessions
825    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    // Save updated hot cache to disk (so future sessions see these memories)
842    cache.save(&nexus_dir)?;
843
844    // 4b. Auto-seed soul if empty (extract identity from CLAUDE.md/AGENTS.md)
845    let _ = auto_seed_soul(&project.root_dir);
846
847    // 5. Compute soul path for injection reference
848    // (now includes auto-seeded content from project config files)
849    let soul_path = dirs::config_dir()
850        .unwrap_or_else(|| PathBuf::from("."))
851        .join("nexus")
852        .join("soul.md");
853
854    // 6. Inject references into agent configs
855    if let Some(target) = AgentInjectionTarget::find(agent_type) {
856        // Project config
857        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                // Non-Nexus-owned config already present; log and continue
863                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        // Global config - skip if it's the same file as project config (Droid edge case)
870        if let Some(global_config) = target.global_config {
871            // Guard against overlap: if global_config resolves to the same path as project_config, skip
872            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    // 7. Start session scratch file
885    let session_manager = nexus_agent::session_manager::SessionManager::new(&project.root_dir);
886    session_manager.start_session(session_id, agent_type)?;
887
888    // 8. Hardening: .gitignore — ensure .nexus/ is always ignored
889    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        // 1. First injection
929        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        // 2. Second injection (idempotent)
934        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    // --- Additional remediation tests ---
993
994    #[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        // Idempotency: second injection should yield same result
1014        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        // Hooks injection: non-owned root nexus should be preserved, hooks.nexus added
1045        {
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            // Non-owned root nexus must be preserved (not removed)
1052            assert!(
1053                parsed.get("nexus").is_some(),
1054                "root nexus (non-owned) should be preserved"
1055            );
1056            // Verify root nexus still has original source
1057            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        // Root injection: non-owned hooks.nexus should be preserved, root.nexus added
1070        {
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            // Root nexus should exist (Nexus-owned)
1077            assert!(parsed.get("nexus").is_some(), "root nexus should exist");
1078            let hooks = parsed.get("hooks").and_then(|v| v.as_object()).unwrap();
1079            // Non-owned hooks.nexus must be preserved (not removed)
1080            assert!(
1081                hooks.contains_key("nexus"),
1082                "hooks.nexus (non-owned) should be preserved"
1083            );
1084            // Verify the hooks.nexus source remains "other"
1085            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        // Non-Nexus owned entry in hooks should fail
1101        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        // Non-Nexus owned entry in root should fail
1107        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        // Hooks branch
1120        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        // Root branch
1136        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        // Only START
1175        {
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        // Only END
1185        {
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        // Malordered: END before START
1195        {
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(" ", "")); // both removed
1203        }
1204
1205        // Neither marker: file unchanged
1206        {
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}