engram-memory 0.5.0

Core binary and shared infrastructure for Engram memory system
Documentation
use std::fs;
use std::path::{Path, PathBuf};

use engram_storage::Database;

use crate::config::home_directory;
use crate::error::CoreError;

const ENGRAM_DIRECTORY: &str = ".engram";
const CONFIG_FILENAME: &str = "engram.toml";
const AGENT_MD_FILENAME: &str = "AGENT.md";
const DATABASE_FILENAME: &str = "engram.db";
const SOCKET_FILENAME: &str = "engram.sock";
const MCP_JSON_FILENAME: &str = ".mcp.json";
const GITIGNORE_FILENAME: &str = ".gitignore";
const GITIGNORE_MARKER: &str = ".engram/";
const AGENT_MD_CONTENT: &str = include_str!("../AGENT.md");
const UNIX_SOCKET_PATH_MAX_BYTES: usize = 104;
const MCP_NPX_PACKAGE: &str = "@engramm/engram-mcp-server";
const DEFAULT_ENGRAM_BIN_NAME: &str = "engram";

const DEFAULT_CONFIG_TEMPLATE: &str = r#"# Global engram config. Runtime always prefers per-project state under
# <project>/.engram/{engram.db, engram.sock} (discovered by walking up from cwd
# like .git). No database.path setting is needed for normal use; the runtime
# resolves it from the project marker, and ENGRAM_DB_PATH env var overrides
# both.

[embedding]
provider = "voyage"        # voyage | ollama | deterministic
model = "voyage-4"
dimension = 1024
# host: only for provider = "ollama". Defaults to http://localhost:11434 (ENGRAM_OLLAMA_HOST overrides).
# hyde_threshold: opt-in HyDE. 0 = disabled (default). N>0 = enable HyDE
# when the query has fewer than N words. HyDE adds ~1.5s latency on cache
# miss but improves recall on terse queries; cache is keyed by the
# original query so repeated calls are instant.
hyde_threshold = 0

[llm]
provider = "openai"        # openai | ollama | local
model = "gpt-4o-mini"
# host: only for provider = "ollama". Defaults to http://localhost:11434 (ENGRAM_OLLAMA_HOST overrides).

[server]
# socket_path is optional and only used as a last-resort fallback when no
# project .engram/ marker is found and ENGRAM_SOCKET_PATH is not set.
# Uncomment to provide an explicit fallback location:
# socket_path = "~/.engram/engram.sock"
reindex_interval_secs = 3600

[hnsw]
max_connections = 16
ef_construction = 200
ef_search = 40
dimension = 1024

[consolidation]
stale_days = 90
min_score = 0.3
"#;

const PROJECT_LOCAL_CONFIG_TEMPLATE: &str = r#"# Project-local engram config. Every value here is deep-merged over the
# global ~/.engram/engram.toml — uncomment only the fields this project
# needs to override; anything left commented is inherited from global.
# Effective priority: ENGRAM_* env vars > this file > global config.
#
# Provider API keys are intentionally absent here — they are always taken
# from the global config and can never be set or overridden per project.

# [embedding]
# model = "voyage-4"
# dimension = 1024
# output_dimension = 1024
# hyde_threshold = 0
# host = "http://localhost:11434"   # only for provider = "ollama"

# [llm]
# model = "gpt-4o-mini"
# host = "http://localhost:11434"   # only for provider = "ollama"

# [server]
# reindex_interval_secs = 3600

# [hnsw]
# max_connections = 16
# ef_construction = 200
# ef_search = 40
# dimension = 1024

# [consolidation]
# stale_days = 90
# min_score = 0.3
"#;

#[derive(Debug)]
pub enum McpCommand {
    Node { script_path: PathBuf },
    Npx,
}

pub fn execute() -> Result<(), CoreError> {
    let cwd = std::env::current_dir()
        .map_err(|error| CoreError::InitFailed(format!("cwd unavailable: {error}")))?;
    let home = home_directory()
        .ok_or_else(|| CoreError::InitFailed("HOME environment variable not set".into()))?;
    try_interactive_wizard();
    execute_with_dirs(&cwd, Path::new(&home))
}

pub fn execute_with_dirs(project_dir: &Path, home_dir: &Path) -> Result<(), CoreError> {
    validate_socket_path(project_dir)?;

    let home_config_path = home_dir.join(ENGRAM_DIRECTORY).join(CONFIG_FILENAME);
    let mcp_command = resolve_mcp_server_command();

    if home_config_path.exists() {
        create_engram_directory(project_dir)?;
        write_project_local_config(project_dir)?;
        initialize_database(project_dir)?;
        write_agent_instructions(project_dir)?;
        write_mcp_json(project_dir, &mcp_command)?;
        write_gitignore(project_dir)?;
        return Ok(());
    }

    create_engram_directory(project_dir)?;
    write_project_local_config(project_dir)?;
    write_default_config_to_home(home_dir)?;
    write_mcp_json(project_dir, &mcp_command)?;
    initialize_database(project_dir)?;
    write_agent_instructions(project_dir)?;
    write_gitignore(project_dir)?;
    print_completion_info(project_dir);
    Ok(())
}

fn try_interactive_wizard() {
    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
        return;
    }
    let _ = std::process::Command::new("engram-tui")
        .arg("init")
        .status();
}

fn validate_socket_path(project_dir: &Path) -> Result<(), CoreError> {
    let socket_path = project_dir.join(ENGRAM_DIRECTORY).join(SOCKET_FILENAME);
    let length = socket_path.as_os_str().as_encoded_bytes().len();
    if length >= UNIX_SOCKET_PATH_MAX_BYTES {
        return Err(CoreError::InitFailed(format!(
            "project path too deep: socket path '{}' is {length} bytes, max {} (UNIX limit)",
            socket_path.display(),
            UNIX_SOCKET_PATH_MAX_BYTES - 1
        )));
    }
    Ok(())
}

fn create_engram_directory(project_dir: &Path) -> Result<(), CoreError> {
    let target = project_dir.join(ENGRAM_DIRECTORY);
    fs::create_dir_all(&target).map_err(|error| {
        CoreError::InitFailed(format!("failed to create {}: {error}", target.display()))
    })
}

fn write_project_local_config(project_dir: &Path) -> Result<(), CoreError> {
    let config_path = project_dir.join(ENGRAM_DIRECTORY).join(CONFIG_FILENAME);
    if config_path.exists() {
        return Ok(());
    }
    fs::write(&config_path, PROJECT_LOCAL_CONFIG_TEMPLATE).map_err(|error| {
        CoreError::InitFailed(format!(
            "failed to write {}: {error}",
            config_path.display()
        ))
    })
}

fn write_default_config_to_home(home_dir: &Path) -> Result<(), CoreError> {
    let home_engram = home_dir.join(ENGRAM_DIRECTORY);
    fs::create_dir_all(&home_engram).map_err(|error| {
        CoreError::InitFailed(format!(
            "failed to create {}: {error}",
            home_engram.display()
        ))
    })?;
    let config_path = home_engram.join(CONFIG_FILENAME);
    if config_path.exists() {
        return Ok(());
    }
    fs::write(&config_path, DEFAULT_CONFIG_TEMPLATE)
        .map_err(|error| CoreError::InitFailed(format!("failed to write config: {error}")))
}

fn initialize_database(project_dir: &Path) -> Result<(), CoreError> {
    let engram_dir = project_dir.join(ENGRAM_DIRECTORY);
    fs::create_dir_all(&engram_dir).map_err(|error| {
        CoreError::InitFailed(format!("failed to create database directory: {error}"))
    })?;
    let database_path = engram_dir.join(DATABASE_FILENAME);
    let database_path_str = database_path
        .to_str()
        .ok_or_else(|| CoreError::InitFailed("database path is not valid utf-8".into()))?;
    let _database = Database::open(database_path_str)?;
    println!("Database initialized at {database_path_str}");
    Ok(())
}

fn write_agent_instructions(project_dir: &Path) -> Result<(), CoreError> {
    let agent_md_path = project_dir.join(ENGRAM_DIRECTORY).join(AGENT_MD_FILENAME);
    fs::write(&agent_md_path, AGENT_MD_CONTENT)
        .map_err(|error| CoreError::InitFailed(format!("failed to write AGENT.md: {error}")))?;
    println!("Agent instructions written to {}", agent_md_path.display());
    Ok(())
}

fn resolve_mcp_server_command() -> McpCommand {
    if let Ok(env_path) = std::env::var("ENGRAM_MCP_SERVER_PATH") {
        let path = PathBuf::from(&env_path);
        if path.exists() {
            return McpCommand::Node { script_path: path };
        }
    }
    if let Some(bin_path) = resolve_engram_bin()
        && let Some(bin_dir) = bin_path.parent()
    {
        let candidate = bin_dir
            .join("..")
            .join("mcp-server")
            .join("dist")
            .join("index.js");
        if candidate.exists() {
            if let Ok(canonical) = candidate.canonicalize() {
                return McpCommand::Node {
                    script_path: canonical,
                };
            }
            return McpCommand::Node {
                script_path: candidate,
            };
        }
    }
    eprintln!(
        "warning: could not locate bundled engram mcp-server script; falling back to 'npx {MCP_NPX_PACKAGE}'. \
         Set ENGRAM_MCP_SERVER_PATH to an absolute path of mcp-server/dist/index.js to silence this warning."
    );
    McpCommand::Npx
}

fn resolve_engram_bin() -> Option<PathBuf> {
    if let Ok(value) = std::env::var("ENGRAM_BIN") {
        let candidate = PathBuf::from(value);
        if candidate.exists() {
            return Some(candidate);
        }
    }
    let output = std::process::Command::new("which")
        .arg(DEFAULT_ENGRAM_BIN_NAME)
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let stdout = String::from_utf8(output.stdout).ok()?;
    let trimmed = stdout.trim();
    if trimmed.is_empty() {
        return None;
    }
    Some(PathBuf::from(trimmed))
}

fn write_mcp_json(project_dir: &Path, command: &McpCommand) -> Result<(), CoreError> {
    let (command_name, args): (&str, Vec<String>) = match command {
        McpCommand::Node { script_path } => {
            let path_str = script_path
                .to_str()
                .ok_or_else(|| CoreError::InitFailed("mcp script path is not valid utf-8".into()))?
                .to_string();
            ("node", vec![path_str])
        }
        McpCommand::Npx => ("npx", vec![MCP_NPX_PACKAGE.to_string()]),
    };
    let engram_bin = resolve_engram_bin();
    let engram_bin_string = engram_bin.as_ref().and_then(|path| {
        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
        canonical.to_str().map(|value| value.to_string())
    });
    let mut server_entry = serde_json::json!({
        "command": command_name,
        "args": args,
    });
    if let Some(bin_string) = engram_bin_string.as_ref() {
        server_entry["env"] = serde_json::json!({ "ENGRAM_BIN": bin_string });
    } else {
        eprintln!(
            "warning: could not resolve engram binary; MCP config has no ENGRAM_BIN env — set it manually before starting Claude Code"
        );
    }
    let document = serde_json::json!({
        "mcpServers": {
            "engram": server_entry,
        }
    });
    let serialized = serde_json::to_string_pretty(&document)
        .map_err(|error| CoreError::InitFailed(format!("failed to serialize mcp json: {error}")))?;
    let mcp_path = project_dir.join(MCP_JSON_FILENAME);
    fs::write(&mcp_path, serialized)
        .map_err(|error| CoreError::InitFailed(format!("failed to write .mcp.json: {error}")))?;
    println!("MCP config written to {}", mcp_path.display());
    Ok(())
}

fn write_gitignore(project_dir: &Path) -> Result<(), CoreError> {
    let gitignore_path = project_dir.join(GITIGNORE_FILENAME);
    let existing = match fs::read_to_string(&gitignore_path) {
        Ok(content) => content,
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
        Err(error) => {
            return Err(CoreError::InitFailed(format!(
                "failed to read .gitignore: {error}"
            )));
        }
    };
    if gitignore_contains_marker(&existing) {
        return Ok(());
    }
    let mut updated = existing;
    if !updated.is_empty() && !updated.ends_with('\n') {
        updated.push('\n');
    }
    updated.push_str(GITIGNORE_MARKER);
    updated.push('\n');
    fs::write(&gitignore_path, updated)
        .map_err(|error| CoreError::InitFailed(format!("failed to write .gitignore: {error}")))
}

fn gitignore_contains_marker(content: &str) -> bool {
    content.lines().any(|line| {
        line.trim() == GITIGNORE_MARKER.trim_end_matches('/') || line.trim() == GITIGNORE_MARKER
    })
}

fn print_completion_info(project_dir: &Path) {
    println!();
    println!("Engram initialized at {}", project_dir.display());
    println!();
    println!("Set API keys via environment variables:");
    println!("  export ENGRAM_VOYAGE_API_KEY=your-voyage-key");
    println!("  export ENGRAM_OPENAI_API_KEY=your-openai-key");
    println!();
    println!("MCP config written to .mcp.json; Claude Code will pick it up automatically.");
    println!("Add to your project's CLAUDE.md:");
    println!("  Engram memory system: .engram/AGENT.md");
}

#[cfg(test)]
mod docs_invariant_tests {
    use super::DEFAULT_CONFIG_TEMPLATE;

    const README: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md"));
    const EMBEDDING_PROVIDERS: [&str; 3] = ["voyage", "ollama", "deterministic"];
    const LLM_PROVIDERS: [&str; 3] = ["openai", "ollama", "local"];

    #[test]
    fn every_provider_enum_appears_in_template_and_readme() {
        for provider in EMBEDDING_PROVIDERS.iter().chain(LLM_PROVIDERS.iter()) {
            assert!(
                DEFAULT_CONFIG_TEMPLATE.contains(provider),
                "config template must document provider `{provider}`"
            );
            assert!(
                README.contains(provider),
                "README must document provider `{provider}`"
            );
        }
    }
}