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, deduplicated)
461        if !context.decisions.is_empty() {
462            md.push_str("## Recent Decisions\n\n");
463            let mut seen = std::collections::HashSet::new();
464            let mut idx = 0;
465            for decision in &context.decisions {
466                if seen.insert(decision) {
467                    idx += 1;
468                    md.push_str(&format!("{}. {}\n", idx, decision));
469                }
470            }
471            md.push('\n');
472        }
473
474        // Files touched
475        if !context.files_touched.is_empty() {
476            md.push_str("## Files Modified\n\n");
477            md.push_str("| File | Operation |\n");
478            md.push_str("|------|----------|\n");
479            for (path, op) in context.files_touched.iter().take(20) {
480                md.push_str(&format!("| `{}` | {} |\n", path, op));
481            }
482            if context.files_touched.len() > 20 {
483                md.push_str(&format!(
484                    "\n_...and {} more files_\n",
485                    context.files_touched.len() - 20
486                ));
487            }
488            md.push('\n');
489        }
490
491        // Errors resolved
492        if !context.errors_resolved.is_empty() {
493            md.push_str("## Errors Resolved\n\n");
494            for (error, resolution) in &context.errors_resolved {
495                md.push_str(&format!("- **{}**\n  -> {}\n", error, resolution));
496            }
497            md.push('\n');
498        }
499
500        // Recent activity summary
501        if !context.recent_messages.is_empty() {
502            md.push_str("## Recent Activity\n\n");
503            for (role, msg) in context.recent_messages.iter().take(10) {
504                let preview = if msg.len() > 150 {
505                    format!("{}...", &msg[..150])
506                } else {
507                    msg.clone()
508                };
509                md.push_str(&format!("- **[{}]** {}\n", role, preview));
510            }
511            md.push('\n');
512        }
513
514        // All known files (collapsed reference)
515        if !context.all_known_files.is_empty() {
516            md.push_str("<details>\n<summary>All Known Files (");
517            md.push_str(&context.all_known_files.len().to_string());
518            md.push_str(")</summary>\n\n");
519            for file in &context.all_known_files {
520                md.push_str(&format!("- `{}`\n", file));
521            }
522            md.push_str("\n</details>\n\n");
523        }
524
525        md.push_str("---\n");
526        md.push_str("_This file is auto-generated by AgenticMemory V3. Do not edit manually._\n");
527
528        md
529    }
530
531    /// Merge our context into existing MEMORY.md (preserves user sections, uses atomic write)
532    fn merge_into_memory_md(memory_file: &Path, context: &SessionResumeResult) {
533        let existing = match std::fs::read_to_string(memory_file) {
534            Ok(content) => content,
535            Err(_) => return,
536        };
537
538        let our_section = Self::format_memory_md_section(context);
539
540        let new_content = if existing.contains(START_MARKER) && existing.contains(END_MARKER) {
541            // Replace existing section
542            if let (Some(start), Some(end)) =
543                (existing.find(START_MARKER), existing.find(END_MARKER))
544            {
545                let before = &existing[..start];
546                let after = &existing[end + END_MARKER.len()..];
547                format!("{}{}{}", before, our_section, after)
548            } else {
549                return;
550            }
551        } else {
552            // Append our section
553            format!("{}\n\n{}", existing.trim(), our_section)
554        };
555
556        // Preserve any user-defined sections (marked with <!-- USER_START/END -->)
557        let final_content = edge_cases::merge_preserving_user_sections(&existing, &new_content);
558
559        // Use atomic write for safety
560        let _ = edge_cases::safe_write_to_claude(memory_file, &final_content);
561    }
562
563    /// Format our section for MEMORY.md
564    fn format_memory_md_section(context: &SessionResumeResult) -> String {
565        let mut section = String::new();
566
567        section.push_str(START_MARKER);
568        section.push_str("\n## AgenticMemory V3 Session Context\n\n");
569        section.push_str(&format!(
570            "_Last updated: {}_\n\n",
571            Utc::now().format("%Y-%m-%d %H:%M:%S UTC")
572        ));
573
574        // Compact format for MEMORY.md
575        if !context.decisions.is_empty() {
576            section.push_str("**Recent Decisions:**\n");
577            for decision in context.decisions.iter().take(5) {
578                section.push_str(&format!("- {}\n", decision));
579            }
580            section.push('\n');
581        }
582
583        if !context.files_touched.is_empty() {
584            let files: Vec<_> = context
585                .files_touched
586                .iter()
587                .take(10)
588                .map(|(p, _)| format!("`{}`", p))
589                .collect();
590            section.push_str(&format!("**Files:** {}\n\n", files.join(", ")));
591        }
592
593        section.push_str(END_MARKER);
594        section.push('\n');
595
596        section
597    }
598}
599
600impl Drop for GhostWriter {
601    fn drop(&mut self) {
602        self.running.store(false, Ordering::SeqCst);
603    }
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    fn sample_context() -> SessionResumeResult {
611        SessionResumeResult {
612            session_id: "test-session".to_string(),
613            block_count: 10,
614            recent_messages: vec![
615                ("user".to_string(), "Hello".to_string()),
616                ("assistant".to_string(), "Hi there!".to_string()),
617            ],
618            files_touched: vec![
619                ("/src/main.rs".to_string(), "create".to_string()),
620                ("/src/lib.rs".to_string(), "update".to_string()),
621            ],
622            decisions: vec![
623                "Use Rust for performance".to_string(),
624                "Implement V3 architecture".to_string(),
625            ],
626            errors_resolved: vec![("missing dep".to_string(), "added to Cargo.toml".to_string())],
627            all_known_files: vec!["/src/main.rs".to_string()],
628        }
629    }
630
631    #[test]
632    fn test_format_as_claude_memory() {
633        let context = sample_context();
634        let markdown = GhostWriter::format_as_claude_memory(&context);
635
636        assert!(markdown.contains("AgenticMemory V3 Context"));
637        assert!(markdown.contains("Use Rust for performance"));
638        assert!(markdown.contains("/src/main.rs"));
639    }
640
641    #[test]
642    fn test_format_for_cursor() {
643        let context = sample_context();
644        let markdown = GhostWriter::format_for_client(&context, ClientType::Cursor);
645
646        assert!(markdown.contains("AgenticMemory V3 Context"));
647        assert!(markdown.contains("Cursor"));
648        assert!(markdown.contains("Use Rust for performance"));
649        assert!(markdown.contains("/src/main.rs"));
650    }
651
652    #[test]
653    fn test_format_for_windsurf() {
654        let context = sample_context();
655        let markdown = GhostWriter::format_for_client(&context, ClientType::Windsurf);
656
657        assert!(markdown.contains("Windsurf"));
658        assert!(markdown.contains("Decisions"));
659    }
660
661    #[test]
662    fn test_format_for_cody() {
663        let context = sample_context();
664        let markdown = GhostWriter::format_for_client(&context, ClientType::Cody);
665
666        assert!(markdown.contains("Cody"));
667        assert!(markdown.contains("Decisions"));
668    }
669
670    #[test]
671    fn test_client_type_filenames() {
672        assert_eq!(ClientType::Claude.memory_filename(), "V3_CONTEXT.md");
673        assert_eq!(ClientType::Cursor.memory_filename(), "agentic-memory.md");
674        assert_eq!(ClientType::Windsurf.memory_filename(), "agentic-memory.md");
675        assert_eq!(ClientType::Cody.memory_filename(), "agentic-memory.md");
676    }
677
678    #[test]
679    fn test_client_type_all() {
680        let all = ClientType::all();
681        assert_eq!(all.len(), 4);
682        assert!(all.contains(&ClientType::Claude));
683        assert!(all.contains(&ClientType::Cursor));
684        assert!(all.contains(&ClientType::Windsurf));
685        assert!(all.contains(&ClientType::Cody));
686    }
687
688    #[test]
689    fn test_detect_claude_memory_dir_with_env() {
690        let dir = tempfile::TempDir::new().unwrap();
691        std::env::set_var("CLAUDE_MEMORY_DIR", dir.path().to_str().unwrap());
692
693        let detected = GhostWriter::detect_claude_memory_dir();
694        assert!(detected.is_some());
695
696        std::env::remove_var("CLAUDE_MEMORY_DIR");
697    }
698
699    #[test]
700    fn test_create_if_parent_exists() {
701        let dir = tempfile::TempDir::new().unwrap();
702        let memory_dir = dir.path().join("memory");
703
704        // Parent exists, so this should succeed
705        assert!(GhostWriter::create_if_parent_exists(&memory_dir));
706        assert!(memory_dir.exists());
707    }
708
709    #[test]
710    fn test_create_if_parent_missing() {
711        let memory_dir = PathBuf::from("/tmp/nonexistent_ghost_test_dir/also_missing/memory");
712
713        // Parent doesn't exist, should fail
714        assert!(!GhostWriter::create_if_parent_exists(&memory_dir));
715    }
716}