mempal 0.6.0

Project memory for coding agents. Single binary, hybrid search, knowledge graph.
Documentation
use std::env;
use std::path::{Path, PathBuf};

use rusqlite::{Connection, OpenFlags};
use serde::Serialize;

use crate::core::db::CURRENT_SCHEMA_VERSION;

pub const REQUIRED_MCP_TOOLS: &[&str] = &[
    "mempal_context",
    "mempal_brief",
    "mempal_phase3",
    "mempal_cowork_bus",
];

pub const PHASE3_ACTIONS: &[&str] = &[
    "guidance",
    "instrumentation_policy",
    "prepare_record",
    "capture",
    "evaluator_advise",
    "default_proposal",
    "rollback_control",
    "check_record",
    "record_checked",
    "review",
    "readiness",
    "analytics",
    "record",
    "list",
    "stats",
    "gate",
    "research_validate_plan",
    "research_ingest_plan",
];

pub const COWORK_BUS_ACTIONS: &[&str] = &[
    "register",
    "list",
    "send",
    "broadcast",
    "drain",
    "events",
    "deliveries",
    "ack",
    "heartbeat",
    "channel_set",
    "channel_list",
    "channel_send",
    "tmux_peek",
    "doctor",
    "session_create",
    "session_list",
    "session_status",
    "session_close",
    "handoff",
    "capture",
];

#[derive(Debug, Clone, Serialize)]
pub struct DoctorReport {
    pub current_version: String,
    pub supported_schema_version: u32,
    pub db: DoctorDbReport,
    pub install: DoctorInstallReport,
    pub warnings: Vec<String>,
    pub recommendations: Vec<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct DoctorDbReport {
    pub path: String,
    pub exists: bool,
    pub schema_version: Option<u32>,
    pub compatible: bool,
    pub error: Option<String>,
}

#[derive(Debug, Clone, Serialize)]
pub struct DoctorInstallReport {
    pub current_exe: Option<String>,
    pub path_mempal: Option<String>,
    pub path_matches_current_exe: Option<bool>,
}

pub fn build_doctor_report(db_path: &Path) -> DoctorReport {
    let db = inspect_db(db_path);
    let install = inspect_install();
    let mut warnings = Vec::new();
    let mut recommendations = vec![
        "Run `mempal doctor --format json` after installing or upgrading mempal.".to_string(),
        "If PATH points at an older binary, restart the MCP client after updating PATH."
            .to_string(),
    ];

    if db.exists && !db.compatible {
        warnings.push(format!(
            "database schema is not compatible with this binary: found {:?}, supported {}",
            db.schema_version, CURRENT_SCHEMA_VERSION
        ));
        recommendations
            .push("Install a mempal binary that supports this palace.db schema.".to_string());
    }
    if let Some(error) = db.error.as_deref() {
        warnings.push(format!("database schema could not be inspected: {error}"));
    }
    if install.path_matches_current_exe == Some(false) {
        warnings
            .push("PATH resolves mempal to a different executable than this process".to_string());
        recommendations
            .push("Check `which mempal` and restart long-lived MCP clients.".to_string());
    }
    if install.path_mempal.is_none() {
        warnings.push("PATH does not contain a mempal executable".to_string());
    }

    DoctorReport {
        current_version: env!("CARGO_PKG_VERSION").to_string(),
        supported_schema_version: CURRENT_SCHEMA_VERSION,
        db,
        install,
        warnings,
        recommendations,
    }
}

fn inspect_db(db_path: &Path) -> DoctorDbReport {
    if !db_path.exists() {
        return DoctorDbReport {
            path: db_path.display().to_string(),
            exists: false,
            schema_version: None,
            compatible: true,
            error: None,
        };
    }

    match read_schema_version_read_only(db_path) {
        Ok(schema_version) => DoctorDbReport {
            path: db_path.display().to_string(),
            exists: true,
            schema_version: Some(schema_version),
            compatible: schema_version <= CURRENT_SCHEMA_VERSION,
            error: None,
        },
        Err(error) => DoctorDbReport {
            path: db_path.display().to_string(),
            exists: true,
            schema_version: None,
            compatible: false,
            error: Some(error),
        },
    }
}

fn read_schema_version_read_only(db_path: &Path) -> Result<u32, String> {
    let conn = Connection::open_with_flags(db_path, OpenFlags::SQLITE_OPEN_READ_ONLY)
        .map_err(|error| error.to_string())?;
    conn.pragma_query_value(None, "user_version", |row| row.get::<_, u32>(0))
        .map_err(|error| error.to_string())
}

fn inspect_install() -> DoctorInstallReport {
    let current_exe = env::current_exe().ok();
    let path_mempal = find_path_executable("mempal");
    let path_matches_current_exe = match (current_exe.as_ref(), path_mempal.as_ref()) {
        (Some(current), Some(path_mempal)) => Some(paths_match(current, path_mempal)),
        (_, Some(_)) => None,
        _ => None,
    };

    DoctorInstallReport {
        current_exe: current_exe.map(|path| path.display().to_string()),
        path_mempal: path_mempal.map(|path| path.display().to_string()),
        path_matches_current_exe,
    }
}

fn find_path_executable(name: &str) -> Option<PathBuf> {
    let path = env::var_os("PATH")?;
    env::split_paths(&path)
        .map(|dir| dir.join(name))
        .find(|candidate| candidate.is_file())
}

fn paths_match(left: &Path, right: &Path) -> bool {
    let left = left.canonicalize().unwrap_or_else(|_| left.to_path_buf());
    let right = right.canonicalize().unwrap_or_else(|_| right.to_path_buf());
    left == right
}