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 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 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 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 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 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 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 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 format!("{}\n\n{}", existing.trim(), our_section)
554 };
555
556 let final_content = edge_cases::merge_preserving_user_sections(&existing, &new_content);
558
559 let _ = edge_cases::safe_write_to_claude(memory_file, &final_content);
561 }
562
563 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 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 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 assert!(!GhostWriter::create_if_parent_exists(&memory_dir));
715 }
716}