agentic-vision-mcp 0.3.0

MCP server for AgenticVision — universal LLM access to persistent visual memory
Documentation
//! Ghost Writer Bridge — Syncs vision context to AI coding assistants.
//!
//! Detects Claude Code, Cursor, Windsurf, and Cody, then periodically
//! writes a visual context summary to each client's memory directory.

use std::path::{Path, PathBuf};
use std::sync::Arc;
use tokio::sync::Mutex;

use crate::session::VisionSessionManager;

/// Spawn a background tokio task that periodically syncs vision context
/// to all detected AI coding assistant memory directories.
///
/// Returns `None` if no AI clients are detected.
pub fn spawn_ghost_writer(
    session: Arc<Mutex<VisionSessionManager>>,
) -> Option<tokio::task::JoinHandle<()>> {
    let clients = detect_all_memory_dirs();
    if clients.is_empty() {
        tracing::info!("Ghost Writer (Vision): no AI coding assistants detected. Sync disabled.");
        return None;
    }

    for c in &clients {
        tracing::info!("Ghost Writer (Vision): {} detected at {:?}", c.name, c.dir);
    }

    let client_count = clients.len();
    let handle = tokio::spawn(async move {
        // First sync immediately
        sync_once(&session, &clients).await;

        let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
        interval.tick().await; // consume first tick
        loop {
            interval.tick().await;
            sync_once(&session, &clients).await;
        }
    });

    tracing::info!(
        "Ghost Writer (Vision): background sync started ({} clients, 5s interval)",
        client_count
    );
    Some(handle)
}

async fn sync_once(session: &Arc<Mutex<VisionSessionManager>>, clients: &[ClientDir]) {
    let markdown = build_vision_context(session).await;

    for client in clients {
        let target = client.dir.join(&client.filename);
        if let Err(e) = atomic_write(&target, markdown.as_bytes()) {
            tracing::warn!(
                "Ghost Writer (Vision): failed to sync to {:?}: {}",
                target,
                e
            );
        }
    }
}

async fn build_vision_context(session: &Arc<Mutex<VisionSessionManager>>) -> String {
    let session = session.lock().await;

    let session_id = session.current_session_id();
    let capture_count = session.store().count();
    let observations = session.observation_notes();
    let tool_log = session.tool_call_log();

    let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");

    let mut md = String::new();
    md.push_str("# AgenticVision Context\n\n");
    md.push_str(&format!("> Auto-synced by Ghost Writer at {now}\n\n"));
    md.push_str(&format!(
        "**Session:** `{session_id}` | **Captures:** {capture_count}\n\n"
    ));

    // Recent observations (most valuable — WHY + WHAT)
    if !observations.is_empty() {
        md.push_str("## Recent Observations\n\n");
        for note in observations.iter().rev().take(10) {
            md.push_str(&format!(
                "- **[{}]** {}",
                note.topic.as_deref().unwrap_or("general"),
                note.intent
            ));
            if let Some(obs) = &note.observation {
                let preview = if obs.len() > 150 {
                    format!("{}...", &obs[..150])
                } else {
                    obs.clone()
                };
                md.push_str(&format!("{preview}"));
            }
            md.push('\n');
        }
        md.push('\n');
    }

    // Recent tool calls (context trail)
    if !tool_log.is_empty() {
        md.push_str("## Recent Tool Calls\n\n");
        for record in tool_log.iter().rev().take(10) {
            let summary = if record.summary.len() > 120 {
                format!("{}...", &record.summary[..120])
            } else {
                record.summary.clone()
            };
            md.push_str(&format!("- `{}` — {summary}\n", record.tool_name));
        }
        md.push('\n');
    }

    md.push_str("---\n");
    md.push_str("_Auto-generated by AgenticVision. Do not edit manually._\n");
    md
}

// ═══════════════════════════════════════════════════════════════════
// Multi-client detection (self-contained, no external deps)
// ═══════════════════════════════════════════════════════════════════

struct ClientDir {
    name: &'static str,
    dir: PathBuf,
    filename: String,
}

fn detect_all_memory_dirs() -> Vec<ClientDir> {
    let home = match std::env::var("HOME").ok().map(PathBuf::from) {
        Some(h) => h,
        None => return vec![],
    };

    let candidates = [
        (
            "Claude Code",
            home.join(".claude").join("memory"),
            "VISION_CONTEXT.md",
        ),
        (
            "Cursor",
            home.join(".cursor").join("memory"),
            "agentic-vision.md",
        ),
        (
            "Windsurf",
            home.join(".windsurf").join("memory"),
            "agentic-vision.md",
        ),
        (
            "Cody",
            home.join(".sourcegraph").join("cody").join("memory"),
            "agentic-vision.md",
        ),
    ];

    let mut dirs = Vec::new();
    for (name, memory_dir, filename) in &candidates {
        if create_if_parent_exists(memory_dir) {
            dirs.push(ClientDir {
                name,
                dir: memory_dir.clone(),
                filename: filename.to_string(),
            });
        }
    }
    dirs
}

fn create_if_parent_exists(memory_dir: &Path) -> bool {
    if memory_dir.exists() {
        return true;
    }
    if let Some(parent) = memory_dir.parent() {
        if parent.exists() {
            return std::fs::create_dir_all(memory_dir).is_ok();
        }
    }
    false
}

fn atomic_write(target: &Path, content: &[u8]) -> Result<(), std::io::Error> {
    use std::io::Write;
    let tmp = target.with_extension("tmp");
    let mut f = std::fs::File::create(&tmp)?;
    f.write_all(content)?;
    f.sync_all()?;
    std::fs::rename(&tmp, target)?;
    Ok(())
}