1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29pub enum ClientType {
30 Claude,
32 Cursor,
34 Windsurf,
36 Cody,
38}
39
40impl ClientType {
41 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 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 pub fn all() -> &'static [ClientType] {
63 &[
64 ClientType::Claude,
65 ClientType::Cursor,
66 ClientType::Windsurf,
67 ClientType::Cody,
68 ]
69 }
70}
71
72#[derive(Debug, Clone)]
74pub struct DetectedClient {
75 pub client_type: ClientType,
76 pub memory_dir: PathBuf,
77}
78
79pub struct GhostWriter {
82 engine: Arc<MemoryEngineV3>,
84
85 claude_memory_dir: Mutex<Option<PathBuf>>,
88
89 detected_clients: Mutex<Vec<DetectedClient>>,
91
92 sync_interval: Duration,
94
95 running: Arc<AtomicBool>,
97
98 last_sync: Mutex<Option<chrono::DateTime<Utc>>>,
100
101 detection_interval: Duration,
103}
104
105impl GhostWriter {
106 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), });
131
132 writer.clone().start_background_sync();
133 writer
134 }
135
136 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 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 pub fn detect_all_memory_dirs() -> Vec<DetectedClient> {
196 let mut dirs = Vec::new();
197
198 if let Some(home) = dirs::home_dir() {
199 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 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 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 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 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 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 fn create_if_parent_exists(memory_dir: &Path) -> bool {
256 if memory_dir.exists() {
257 return true;
258 }
259 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 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 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 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 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 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 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 pub fn detected_clients(&self) -> Vec<DetectedClient> {
350 self.detected_clients.lock().unwrap().clone()
351 }
352
353 #[cfg(test)]
355 fn detect_claude_memory_dir() -> Option<PathBuf> {
356 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 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 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 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 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 pub fn sync_once(&self) {
413 self.sync_to_all_clients();
415
416 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 pub fn stop(&self) {
429 self.running.store(false, Ordering::SeqCst);
430 }
431
432 pub fn is_running(&self) -> bool {
434 self.running.load(Ordering::SeqCst)
435 }
436
437 pub fn last_sync_time(&self) -> Option<chrono::DateTime<Utc>> {
439 *self.last_sync.lock().unwrap()
440 }
441
442 pub fn get_claude_memory_dir(&self) -> Option<PathBuf> {
444 self.claude_memory_dir.lock().unwrap().clone()
445 }
446
447 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 md.push_str(&format!("**Session:** `{}`\n\n", context.session_id));
459
460 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 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 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 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 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 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 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 format!("{}\n\n{}", existing.trim(), our_section)
549 };
550
551 let final_content = edge_cases::merge_preserving_user_sections(&existing, &new_content);
553
554 let _ = edge_cases::safe_write_to_claude(memory_file, &final_content);
556 }
557
558 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 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 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 assert!(!GhostWriter::create_if_parent_exists(&memory_dir));
710 }
711}