Skip to main content

agentic_memory/v3/
ghost_writer.rs

1//! The Ghost Writer: Automatic sync to ALL AI coding assistants.
2//!
3//! Supports:
4//! - Claude Code  (~/.claude/memory)
5//! - Cursor       (~/.cursor/memory)
6//! - Windsurf     (~/.windsurf/memory)
7//! - Cody         (~/.sourcegraph/cody/memory)
8//!
9//! Zero user configuration. Zero tool calls. Just works.
10//! **We build for ALL AI agents. Not just Claude.**
11
12use super::edge_cases;
13use super::engine::{MemoryEngineV3, SessionResumeResult};
14use chrono::Utc;
15use std::path::{Path, PathBuf};
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::{Arc, Mutex};
18use std::time::{Duration, Instant};
19
20const START_MARKER: &str = "<!-- AGENTIC_MEMORY_V3_START -->";
21const END_MARKER: &str = "<!-- AGENTIC_MEMORY_V3_END -->";
22
23// ═══════════════════════════════════════════════════════════════════
24// Multi-client support
25// ═══════════════════════════════════════════════════════════════════
26
27/// Supported AI coding assistants.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum ClientType {
30    /// Claude Code (Anthropic)
31    Claude,
32    /// Cursor (AI-first IDE)
33    Cursor,
34    /// Windsurf (Codeium)
35    Windsurf,
36    /// Cody (Sourcegraph)
37    Cody,
38}
39
40impl ClientType {
41    /// The filename to write in the client's memory directory.
42    pub fn memory_filename(&self) -> &'static str {
43        match self {
44            ClientType::Claude => "V3_CONTEXT.md",
45            ClientType::Cursor => "agentic-memory.md",
46            ClientType::Windsurf => "agentic-memory.md",
47            ClientType::Cody => "agentic-memory.md",
48        }
49    }
50
51    /// Human-readable name for logging.
52    pub fn display_name(&self) -> &'static str {
53        match self {
54            ClientType::Claude => "Claude Code",
55            ClientType::Cursor => "Cursor",
56            ClientType::Windsurf => "Windsurf",
57            ClientType::Cody => "Cody",
58        }
59    }
60
61    /// Return all known client types.
62    pub fn all() -> &'static [ClientType] {
63        &[
64            ClientType::Claude,
65            ClientType::Cursor,
66            ClientType::Windsurf,
67            ClientType::Cody,
68        ]
69    }
70}
71
72/// A detected client with its memory directory.
73#[derive(Debug, Clone)]
74pub struct DetectedClient {
75    pub client_type: ClientType,
76    pub memory_dir: PathBuf,
77}
78
79/// The Ghost Writer daemon.
80/// Runs in background, syncs context to ALL detected AI coding assistants.
81pub struct GhostWriter {
82    /// Our V3 engine
83    engine: Arc<MemoryEngineV3>,
84
85    /// Claude Code's memory directory (auto-detected, re-checked periodically)
86    /// Kept for backward compat — also used as the primary detect target
87    claude_memory_dir: Mutex<Option<PathBuf>>,
88
89    /// ALL detected client memory directories
90    detected_clients: Mutex<Vec<DetectedClient>>,
91
92    /// Sync interval
93    sync_interval: Duration,
94
95    /// Running flag
96    running: Arc<AtomicBool>,
97
98    /// Last sync time
99    last_sync: Mutex<Option<chrono::DateTime<Utc>>>,
100
101    /// Detection interval for re-checking client directories
102    detection_interval: Duration,
103}
104
105impl GhostWriter {
106    /// Create and AUTO-START the ghost writer (syncs to ALL detected clients)
107    pub fn spawn(engine: Arc<MemoryEngineV3>) -> Arc<Self> {
108        let clients = Self::detect_all_memory_dirs();
109        let claude_dir = clients
110            .iter()
111            .find(|c| c.client_type == ClientType::Claude)
112            .map(|c| c.memory_dir.clone());
113
114        for c in &clients {
115            log::info!(
116                "{} detected at {:?}",
117                c.client_type.display_name(),
118                c.memory_dir
119            );
120        }
121
122        let writer = Arc::new(Self {
123            engine,
124            claude_memory_dir: Mutex::new(claude_dir),
125            detected_clients: Mutex::new(clients),
126            sync_interval: Duration::from_secs(5),
127            running: Arc::new(AtomicBool::new(true)),
128            last_sync: Mutex::new(None),
129            detection_interval: Duration::from_secs(300), // Re-check every 5 min
130        });
131
132        writer.clone().start_background_sync();
133        writer
134    }
135
136    /// Spawn only if ANY AI client is detected; returns None if none installed
137    pub fn spawn_if_available(engine: Arc<MemoryEngineV3>) -> Option<Arc<Self>> {
138        let clients = Self::detect_all_memory_dirs();
139        if clients.is_empty() {
140            log::info!("No AI coding assistants detected. Ghost writer disabled. Memory still works via MCP tools.");
141            return None;
142        }
143
144        let claude_dir = clients
145            .iter()
146            .find(|c| c.client_type == ClientType::Claude)
147            .map(|c| c.memory_dir.clone());
148
149        for c in &clients {
150            log::info!(
151                "{} detected at {:?}",
152                c.client_type.display_name(),
153                c.memory_dir
154            );
155        }
156
157        let writer = Arc::new(Self {
158            engine,
159            claude_memory_dir: Mutex::new(claude_dir),
160            detected_clients: Mutex::new(clients),
161            sync_interval: Duration::from_secs(5),
162            running: Arc::new(AtomicBool::new(true)),
163            last_sync: Mutex::new(None),
164            detection_interval: Duration::from_secs(300),
165        });
166        writer.clone().start_background_sync();
167        Some(writer)
168    }
169
170    /// Create without auto-start (for testing)
171    pub fn new(engine: Arc<MemoryEngineV3>) -> Self {
172        let clients = Self::detect_all_memory_dirs();
173        let claude_dir = clients
174            .iter()
175            .find(|c| c.client_type == ClientType::Claude)
176            .map(|c| c.memory_dir.clone());
177
178        Self {
179            engine,
180            claude_memory_dir: Mutex::new(claude_dir),
181            detected_clients: Mutex::new(clients),
182            sync_interval: Duration::from_secs(5),
183            running: Arc::new(AtomicBool::new(false)),
184            last_sync: Mutex::new(None),
185            detection_interval: Duration::from_secs(300),
186        }
187    }
188
189    // ═══════════════════════════════════════════════════════════════
190    // Multi-client detection
191    // ═══════════════════════════════════════════════════════════════
192
193    /// Detect ALL AI coding assistant memory directories.
194    /// Returns every client whose config directory exists or can be created.
195    pub fn detect_all_memory_dirs() -> Vec<DetectedClient> {
196        let mut dirs = Vec::new();
197
198        if let Some(home) = dirs::home_dir() {
199            // Claude Code: ~/.claude/memory
200            let claude = home.join(".claude").join("memory");
201            if Self::create_if_parent_exists(&claude) {
202                dirs.push(DetectedClient {
203                    client_type: ClientType::Claude,
204                    memory_dir: claude,
205                });
206            }
207
208            // Cursor: ~/.cursor/memory
209            let cursor = home.join(".cursor").join("memory");
210            if Self::create_if_parent_exists(&cursor) {
211                dirs.push(DetectedClient {
212                    client_type: ClientType::Cursor,
213                    memory_dir: cursor,
214                });
215            }
216
217            // Windsurf: ~/.windsurf/memory
218            let windsurf = home.join(".windsurf").join("memory");
219            if Self::create_if_parent_exists(&windsurf) {
220                dirs.push(DetectedClient {
221                    client_type: ClientType::Windsurf,
222                    memory_dir: windsurf,
223                });
224            }
225
226            // Cody: ~/.sourcegraph/cody/memory
227            let cody = home.join(".sourcegraph").join("cody").join("memory");
228            if Self::create_if_parent_exists(&cody) {
229                dirs.push(DetectedClient {
230                    client_type: ClientType::Cody,
231                    memory_dir: cody,
232                });
233            }
234        }
235
236        // Also check env overrides
237        if let Ok(dir) = std::env::var("CLAUDE_MEMORY_DIR") {
238            let path = PathBuf::from(dir);
239            if std::fs::create_dir_all(&path).is_ok() {
240                // Avoid duplicate if already detected
241                if !dirs.iter().any(|d| d.memory_dir == path) {
242                    dirs.push(DetectedClient {
243                        client_type: ClientType::Claude,
244                        memory_dir: path,
245                    });
246                }
247            }
248        }
249
250        dirs
251    }
252
253    /// Create the memory directory if its parent directory already exists
254    /// (i.e., the client is installed). Returns true if the dir now exists.
255    fn create_if_parent_exists(memory_dir: &Path) -> bool {
256        if memory_dir.exists() {
257            return true;
258        }
259        // Only create if the parent (client's config dir) already exists
260        if let Some(parent) = memory_dir.parent() {
261            if parent.exists() {
262                return std::fs::create_dir_all(memory_dir).is_ok();
263            }
264        }
265        false
266    }
267
268    /// Sync context to ALL detected clients at once.
269    pub fn sync_to_all_clients(&self) {
270        let context = self.engine.session_resume();
271        let clients = self.detected_clients.lock().unwrap().clone();
272
273        for detected in &clients {
274            let filename = detected.client_type.memory_filename();
275            let target = detected.memory_dir.join(filename);
276            let markdown = Self::format_for_client(&context, detected.client_type);
277
278            if edge_cases::safe_write_to_claude(&target, &markdown).is_ok() {
279                log::debug!(
280                    "Synced to {} at {:?}",
281                    detected.client_type.display_name(),
282                    target
283                );
284            }
285        }
286
287        *self.last_sync.lock().unwrap() = Some(Utc::now());
288    }
289
290    /// Format context for a specific client.
291    /// Claude gets the full format. Other clients get a streamlined markdown.
292    pub fn format_for_client(context: &SessionResumeResult, client: ClientType) -> String {
293        match client {
294            ClientType::Claude => Self::format_as_claude_memory(context),
295            _ => Self::format_as_generic_memory(context, client),
296        }
297    }
298
299    /// Format context as generic AI assistant markdown (Cursor, Windsurf, Cody).
300    fn format_as_generic_memory(context: &SessionResumeResult, client: ClientType) -> String {
301        let mut md = String::new();
302
303        md.push_str("# AgenticMemory V3 Context\n\n");
304        md.push_str(&format!(
305            "> Auto-synced for {} at {}\n\n",
306            client.display_name(),
307            Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
308        ));
309
310        md.push_str(&format!(
311            "**Session:** `{}` | **Blocks:** {}\n\n",
312            context.session_id, context.block_count
313        ));
314
315        // Decisions first (most actionable)
316        if !context.decisions.is_empty() {
317            md.push_str("## Decisions\n\n");
318            for (i, d) in context.decisions.iter().enumerate() {
319                md.push_str(&format!("{}. {}\n", i + 1, d));
320            }
321            md.push('\n');
322        }
323
324        // Files
325        if !context.files_touched.is_empty() {
326            md.push_str("## Files Modified\n\n");
327            for (path, op) in context.files_touched.iter().take(30) {
328                md.push_str(&format!("- `{}` ({})\n", path, op));
329            }
330            md.push('\n');
331        }
332
333        // Errors
334        if !context.errors_resolved.is_empty() {
335            md.push_str("## Errors Resolved\n\n");
336            for (err, res) in &context.errors_resolved {
337                md.push_str(&format!("- **{}** → {}\n", err, res));
338            }
339            md.push('\n');
340        }
341
342        md.push_str("---\n");
343        md.push_str("_Auto-generated by AgenticMemory V3. Do not edit._\n");
344
345        md
346    }
347
348    /// Get all currently detected clients.
349    pub fn detected_clients(&self) -> Vec<DetectedClient> {
350        self.detected_clients.lock().unwrap().clone()
351    }
352
353    /// Auto-detect Claude Code's memory directory (convenience wrapper for tests).
354    #[cfg(test)]
355    fn detect_claude_memory_dir() -> Option<PathBuf> {
356        // First try the multi-client detection
357        let clients = Self::detect_all_memory_dirs();
358        if let Some(claude) = clients.iter().find(|c| c.client_type == ClientType::Claude) {
359            return Some(claude.memory_dir.clone());
360        }
361
362        // Check CLAUDE_MEMORY_DIR env var (direct override)
363        if let Ok(dir) = std::env::var("CLAUDE_MEMORY_DIR") {
364            let path = PathBuf::from(dir);
365            if std::fs::create_dir_all(&path).is_ok() {
366                return Some(path);
367            }
368        }
369
370        None
371    }
372
373    /// Start background sync thread with periodic re-detection
374    fn start_background_sync(self: Arc<Self>) {
375        let writer = self.clone();
376
377        std::thread::Builder::new()
378            .name("ghost-writer".to_string())
379            .spawn(move || {
380                let mut last_detection = Instant::now();
381
382                while writer.running.load(Ordering::SeqCst) {
383                    // Periodic re-detection of ALL client directories
384                    if last_detection.elapsed() > writer.detection_interval {
385                        let new_clients = Self::detect_all_memory_dirs();
386                        let current_count = writer.detected_clients.lock().unwrap().len();
387                        if new_clients.len() != current_count {
388                            log::info!(
389                                "Client detection changed: {} → {} clients",
390                                current_count,
391                                new_clients.len()
392                            );
393                        }
394                        // Update Claude dir too
395                        let claude_dir = new_clients
396                            .iter()
397                            .find(|c| c.client_type == ClientType::Claude)
398                            .map(|c| c.memory_dir.clone());
399                        *writer.claude_memory_dir.lock().unwrap() = claude_dir;
400                        *writer.detected_clients.lock().unwrap() = new_clients;
401                        last_detection = Instant::now();
402                    }
403
404                    writer.sync_once();
405                    std::thread::sleep(writer.sync_interval);
406                }
407            })
408            .expect("Failed to spawn ghost writer thread");
409    }
410
411    /// Perform one sync cycle — writes to ALL detected clients.
412    pub fn sync_once(&self) {
413        // Sync to ALL clients (Claude, Cursor, Windsurf, Cody)
414        self.sync_to_all_clients();
415
416        // Additionally: merge into Claude's MEMORY.md if it exists
417        let claude_dir = self.claude_memory_dir.lock().unwrap().clone();
418        if let Some(dir) = claude_dir {
419            let memory_file = dir.join("MEMORY.md");
420            if memory_file.exists() {
421                let context = self.engine.session_resume();
422                Self::merge_into_memory_md(&memory_file, &context);
423            }
424        }
425    }
426
427    /// Stop the ghost writer
428    pub fn stop(&self) {
429        self.running.store(false, Ordering::SeqCst);
430    }
431
432    /// Check if running
433    pub fn is_running(&self) -> bool {
434        self.running.load(Ordering::SeqCst)
435    }
436
437    /// Get last sync time
438    pub fn last_sync_time(&self) -> Option<chrono::DateTime<Utc>> {
439        *self.last_sync.lock().unwrap()
440    }
441
442    /// Get detected Claude memory directory
443    pub fn get_claude_memory_dir(&self) -> Option<PathBuf> {
444        self.claude_memory_dir.lock().unwrap().clone()
445    }
446
447    /// Format context as Claude-compatible markdown
448    pub fn format_as_claude_memory(context: &SessionResumeResult) -> String {
449        let mut md = String::new();
450
451        md.push_str("# AgenticMemory V3 Context\n\n");
452        md.push_str(&format!(
453            "> Auto-synced by Ghost Writer at {}\n\n",
454            Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
455        ));
456
457        // Session info
458        md.push_str(&format!("**Session:** `{}`\n\n", context.session_id));
459
460        // Recent decisions (most important — shown first)
461        if !context.decisions.is_empty() {
462            md.push_str("## Recent Decisions\n\n");
463            for (i, decision) in context.decisions.iter().enumerate() {
464                md.push_str(&format!("{}. {}\n", i + 1, decision));
465            }
466            md.push('\n');
467        }
468
469        // Files touched
470        if !context.files_touched.is_empty() {
471            md.push_str("## Files Modified\n\n");
472            md.push_str("| File | Operation |\n");
473            md.push_str("|------|----------|\n");
474            for (path, op) in context.files_touched.iter().take(20) {
475                md.push_str(&format!("| `{}` | {} |\n", path, op));
476            }
477            if context.files_touched.len() > 20 {
478                md.push_str(&format!(
479                    "\n_...and {} more files_\n",
480                    context.files_touched.len() - 20
481                ));
482            }
483            md.push('\n');
484        }
485
486        // Errors resolved
487        if !context.errors_resolved.is_empty() {
488            md.push_str("## Errors Resolved\n\n");
489            for (error, resolution) in &context.errors_resolved {
490                md.push_str(&format!("- **{}**\n  -> {}\n", error, resolution));
491            }
492            md.push('\n');
493        }
494
495        // Recent activity summary
496        if !context.recent_messages.is_empty() {
497            md.push_str("## Recent Activity\n\n");
498            for (role, msg) in context.recent_messages.iter().take(10) {
499                let preview = if msg.len() > 150 {
500                    format!("{}...", &msg[..150])
501                } else {
502                    msg.clone()
503                };
504                md.push_str(&format!("- **[{}]** {}\n", role, preview));
505            }
506            md.push('\n');
507        }
508
509        // All known files (collapsed reference)
510        if !context.all_known_files.is_empty() {
511            md.push_str("<details>\n<summary>All Known Files (");
512            md.push_str(&context.all_known_files.len().to_string());
513            md.push_str(")</summary>\n\n");
514            for file in &context.all_known_files {
515                md.push_str(&format!("- `{}`\n", file));
516            }
517            md.push_str("\n</details>\n\n");
518        }
519
520        md.push_str("---\n");
521        md.push_str("_This file is auto-generated by AgenticMemory V3. Do not edit manually._\n");
522
523        md
524    }
525
526    /// Merge our context into existing MEMORY.md (preserves user sections, uses atomic write)
527    fn merge_into_memory_md(memory_file: &Path, context: &SessionResumeResult) {
528        let existing = match std::fs::read_to_string(memory_file) {
529            Ok(content) => content,
530            Err(_) => return,
531        };
532
533        let our_section = Self::format_memory_md_section(context);
534
535        let new_content = if existing.contains(START_MARKER) && existing.contains(END_MARKER) {
536            // Replace existing section
537            if let (Some(start), Some(end)) =
538                (existing.find(START_MARKER), existing.find(END_MARKER))
539            {
540                let before = &existing[..start];
541                let after = &existing[end + END_MARKER.len()..];
542                format!("{}{}{}", before, our_section, after)
543            } else {
544                return;
545            }
546        } else {
547            // Append our section
548            format!("{}\n\n{}", existing.trim(), our_section)
549        };
550
551        // Preserve any user-defined sections (marked with <!-- USER_START/END -->)
552        let final_content = edge_cases::merge_preserving_user_sections(&existing, &new_content);
553
554        // Use atomic write for safety
555        let _ = edge_cases::safe_write_to_claude(memory_file, &final_content);
556    }
557
558    /// Format our section for MEMORY.md
559    fn format_memory_md_section(context: &SessionResumeResult) -> String {
560        let mut section = String::new();
561
562        section.push_str(START_MARKER);
563        section.push_str("\n## AgenticMemory V3 Session Context\n\n");
564        section.push_str(&format!(
565            "_Last updated: {}_\n\n",
566            Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
567        ));
568
569        // Compact format for MEMORY.md
570        if !context.decisions.is_empty() {
571            section.push_str("**Recent Decisions:**\n");
572            for decision in context.decisions.iter().take(5) {
573                section.push_str(&format!("- {}\n", decision));
574            }
575            section.push('\n');
576        }
577
578        if !context.files_touched.is_empty() {
579            let files: Vec<_> = context
580                .files_touched
581                .iter()
582                .take(10)
583                .map(|(p, _)| format!("`{}`", p))
584                .collect();
585            section.push_str(&format!("**Files:** {}\n\n", files.join(", ")));
586        }
587
588        section.push_str(END_MARKER);
589        section.push('\n');
590
591        section
592    }
593}
594
595impl Drop for GhostWriter {
596    fn drop(&mut self) {
597        self.running.store(false, Ordering::SeqCst);
598    }
599}
600
601#[cfg(test)]
602mod tests {
603    use super::*;
604
605    fn sample_context() -> SessionResumeResult {
606        SessionResumeResult {
607            session_id: "test-session".to_string(),
608            block_count: 10,
609            recent_messages: vec![
610                ("user".to_string(), "Hello".to_string()),
611                ("assistant".to_string(), "Hi there!".to_string()),
612            ],
613            files_touched: vec![
614                ("/src/main.rs".to_string(), "create".to_string()),
615                ("/src/lib.rs".to_string(), "update".to_string()),
616            ],
617            decisions: vec![
618                "Use Rust for performance".to_string(),
619                "Implement V3 architecture".to_string(),
620            ],
621            errors_resolved: vec![("missing dep".to_string(), "added to Cargo.toml".to_string())],
622            all_known_files: vec!["/src/main.rs".to_string()],
623        }
624    }
625
626    #[test]
627    fn test_format_as_claude_memory() {
628        let context = sample_context();
629        let markdown = GhostWriter::format_as_claude_memory(&context);
630
631        assert!(markdown.contains("AgenticMemory V3 Context"));
632        assert!(markdown.contains("Use Rust for performance"));
633        assert!(markdown.contains("/src/main.rs"));
634    }
635
636    #[test]
637    fn test_format_for_cursor() {
638        let context = sample_context();
639        let markdown = GhostWriter::format_for_client(&context, ClientType::Cursor);
640
641        assert!(markdown.contains("AgenticMemory V3 Context"));
642        assert!(markdown.contains("Cursor"));
643        assert!(markdown.contains("Use Rust for performance"));
644        assert!(markdown.contains("/src/main.rs"));
645    }
646
647    #[test]
648    fn test_format_for_windsurf() {
649        let context = sample_context();
650        let markdown = GhostWriter::format_for_client(&context, ClientType::Windsurf);
651
652        assert!(markdown.contains("Windsurf"));
653        assert!(markdown.contains("Decisions"));
654    }
655
656    #[test]
657    fn test_format_for_cody() {
658        let context = sample_context();
659        let markdown = GhostWriter::format_for_client(&context, ClientType::Cody);
660
661        assert!(markdown.contains("Cody"));
662        assert!(markdown.contains("Decisions"));
663    }
664
665    #[test]
666    fn test_client_type_filenames() {
667        assert_eq!(ClientType::Claude.memory_filename(), "V3_CONTEXT.md");
668        assert_eq!(ClientType::Cursor.memory_filename(), "agentic-memory.md");
669        assert_eq!(ClientType::Windsurf.memory_filename(), "agentic-memory.md");
670        assert_eq!(ClientType::Cody.memory_filename(), "agentic-memory.md");
671    }
672
673    #[test]
674    fn test_client_type_all() {
675        let all = ClientType::all();
676        assert_eq!(all.len(), 4);
677        assert!(all.contains(&ClientType::Claude));
678        assert!(all.contains(&ClientType::Cursor));
679        assert!(all.contains(&ClientType::Windsurf));
680        assert!(all.contains(&ClientType::Cody));
681    }
682
683    #[test]
684    fn test_detect_claude_memory_dir_with_env() {
685        let dir = tempfile::TempDir::new().unwrap();
686        std::env::set_var("CLAUDE_MEMORY_DIR", dir.path().to_str().unwrap());
687
688        let detected = GhostWriter::detect_claude_memory_dir();
689        assert!(detected.is_some());
690
691        std::env::remove_var("CLAUDE_MEMORY_DIR");
692    }
693
694    #[test]
695    fn test_create_if_parent_exists() {
696        let dir = tempfile::TempDir::new().unwrap();
697        let memory_dir = dir.path().join("memory");
698
699        // Parent exists, so this should succeed
700        assert!(GhostWriter::create_if_parent_exists(&memory_dir));
701        assert!(memory_dir.exists());
702    }
703
704    #[test]
705    fn test_create_if_parent_missing() {
706        let memory_dir = PathBuf::from("/tmp/nonexistent_ghost_test_dir/also_missing/memory");
707
708        // Parent doesn't exist, should fail
709        assert!(!GhostWriter::create_if_parent_exists(&memory_dir));
710    }
711}