Skip to main content

engram_core/
init_handler.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use engram_storage::Database;
5
6use crate::config::home_directory;
7use crate::error::CoreError;
8
9const ENGRAM_DIRECTORY: &str = ".engram";
10const CONFIG_FILENAME: &str = "engram.toml";
11const AGENT_MD_FILENAME: &str = "AGENT.md";
12const DATABASE_FILENAME: &str = "engram.db";
13const SOCKET_FILENAME: &str = "engram.sock";
14const MCP_JSON_FILENAME: &str = ".mcp.json";
15const GITIGNORE_FILENAME: &str = ".gitignore";
16const GITIGNORE_MARKER: &str = ".engram/";
17const AGENT_MD_CONTENT: &str = include_str!("../AGENT.md");
18const UNIX_SOCKET_PATH_MAX_BYTES: usize = 104;
19const MCP_NPX_PACKAGE: &str = "@engramm/engram-mcp-server";
20const DEFAULT_ENGRAM_BIN_NAME: &str = "engram";
21
22const DEFAULT_CONFIG_TEMPLATE: &str = r#"# Global engram config. Runtime always prefers per-project state under
23# <project>/.engram/{engram.db, engram.sock} (discovered by walking up from cwd
24# like .git). No database.path setting is needed for normal use; the runtime
25# resolves it from the project marker, and ENGRAM_DB_PATH env var overrides
26# both.
27
28[embedding]
29provider = "voyage"        # voyage | ollama | deterministic
30model = "voyage-4"
31dimension = 1024
32# host: only for provider = "ollama". Defaults to http://localhost:11434 (ENGRAM_OLLAMA_HOST overrides).
33# hyde_threshold: opt-in HyDE. 0 = disabled (default). N>0 = enable HyDE
34# when the query has fewer than N words. HyDE adds ~1.5s latency on cache
35# miss but improves recall on terse queries; cache is keyed by the
36# original query so repeated calls are instant.
37hyde_threshold = 0
38
39[llm]
40provider = "openai"        # openai | ollama | local
41model = "gpt-4o-mini"
42# host: only for provider = "ollama". Defaults to http://localhost:11434 (ENGRAM_OLLAMA_HOST overrides).
43
44[server]
45# socket_path is optional and only used as a last-resort fallback when no
46# project .engram/ marker is found and ENGRAM_SOCKET_PATH is not set.
47# Uncomment to provide an explicit fallback location:
48# socket_path = "~/.engram/engram.sock"
49reindex_interval_secs = 3600
50
51[hnsw]
52max_connections = 16
53ef_construction = 200
54ef_search = 40
55dimension = 1024
56
57[consolidation]
58stale_days = 90
59min_score = 0.3
60# fts_similarity_floor: minimum mean |bm25| rank an FTS duplicate group must
61# reach to appear in consolidation preview. Same unit as the preview
62# "similarity" of fts groups (NOT cosine). 0.0 disables the floor; exact
63# groups are never filtered.
64fts_similarity_floor = 0.0
65"#;
66
67const PROJECT_LOCAL_CONFIG_TEMPLATE: &str = r#"# Project-local engram config. Every value here is deep-merged over the
68# global ~/.engram/engram.toml — uncomment only the fields this project
69# needs to override; anything left commented is inherited from global.
70# Effective priority: ENGRAM_* env vars > this file > global config.
71#
72# Provider API keys are intentionally absent here — they are always taken
73# from the global config and can never be set or overridden per project.
74
75# [embedding]
76# model = "voyage-4"
77# dimension = 1024
78# output_dimension = 1024
79# hyde_threshold = 0
80# host = "http://localhost:11434"   # only for provider = "ollama"
81
82# [llm]
83# model = "gpt-4o-mini"
84# host = "http://localhost:11434"   # only for provider = "ollama"
85
86# [server]
87# reindex_interval_secs = 3600
88
89# [hnsw]
90# max_connections = 16
91# ef_construction = 200
92# ef_search = 40
93# dimension = 1024
94
95# [consolidation]
96# stale_days = 90
97# min_score = 0.3
98# fts_similarity_floor = 0.0
99"#;
100
101#[derive(Debug)]
102pub enum McpCommand {
103    Node { script_path: PathBuf },
104    Npx,
105}
106
107pub fn execute() -> Result<(), CoreError> {
108    let cwd = std::env::current_dir()
109        .map_err(|error| CoreError::InitFailed(format!("cwd unavailable: {error}")))?;
110    let home = home_directory()
111        .ok_or_else(|| CoreError::InitFailed("HOME environment variable not set".into()))?;
112    try_interactive_wizard();
113    execute_with_dirs(&cwd, Path::new(&home))
114}
115
116pub fn execute_with_dirs(project_dir: &Path, home_dir: &Path) -> Result<(), CoreError> {
117    validate_socket_path(project_dir)?;
118
119    let home_config_path = home_dir.join(ENGRAM_DIRECTORY).join(CONFIG_FILENAME);
120    let mcp_command = resolve_mcp_server_command();
121
122    if home_config_path.exists() {
123        create_engram_directory(project_dir)?;
124        write_project_local_config(project_dir)?;
125        initialize_database(project_dir)?;
126        write_agent_instructions(project_dir)?;
127        write_mcp_json(project_dir, &mcp_command)?;
128        write_gitignore(project_dir)?;
129        return Ok(());
130    }
131
132    create_engram_directory(project_dir)?;
133    write_project_local_config(project_dir)?;
134    write_default_config_to_home(home_dir)?;
135    write_mcp_json(project_dir, &mcp_command)?;
136    initialize_database(project_dir)?;
137    write_agent_instructions(project_dir)?;
138    write_gitignore(project_dir)?;
139    print_completion_info(project_dir);
140    Ok(())
141}
142
143fn try_interactive_wizard() {
144    if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
145        return;
146    }
147    let _ = std::process::Command::new("engram-tui")
148        .arg("init")
149        .status();
150}
151
152fn validate_socket_path(project_dir: &Path) -> Result<(), CoreError> {
153    let socket_path = project_dir.join(ENGRAM_DIRECTORY).join(SOCKET_FILENAME);
154    let length = socket_path.as_os_str().as_encoded_bytes().len();
155    if length >= UNIX_SOCKET_PATH_MAX_BYTES {
156        return Err(CoreError::InitFailed(format!(
157            "project path too deep: socket path '{}' is {length} bytes, max {} (UNIX limit)",
158            socket_path.display(),
159            UNIX_SOCKET_PATH_MAX_BYTES - 1
160        )));
161    }
162    Ok(())
163}
164
165fn create_engram_directory(project_dir: &Path) -> Result<(), CoreError> {
166    let target = project_dir.join(ENGRAM_DIRECTORY);
167    fs::create_dir_all(&target).map_err(|error| {
168        CoreError::InitFailed(format!("failed to create {}: {error}", target.display()))
169    })
170}
171
172fn write_project_local_config(project_dir: &Path) -> Result<(), CoreError> {
173    let config_path = project_dir.join(ENGRAM_DIRECTORY).join(CONFIG_FILENAME);
174    if config_path.exists() {
175        return Ok(());
176    }
177    fs::write(&config_path, PROJECT_LOCAL_CONFIG_TEMPLATE).map_err(|error| {
178        CoreError::InitFailed(format!(
179            "failed to write {}: {error}",
180            config_path.display()
181        ))
182    })
183}
184
185fn write_default_config_to_home(home_dir: &Path) -> Result<(), CoreError> {
186    let home_engram = home_dir.join(ENGRAM_DIRECTORY);
187    fs::create_dir_all(&home_engram).map_err(|error| {
188        CoreError::InitFailed(format!(
189            "failed to create {}: {error}",
190            home_engram.display()
191        ))
192    })?;
193    let config_path = home_engram.join(CONFIG_FILENAME);
194    if config_path.exists() {
195        return Ok(());
196    }
197    fs::write(&config_path, DEFAULT_CONFIG_TEMPLATE)
198        .map_err(|error| CoreError::InitFailed(format!("failed to write config: {error}")))
199}
200
201fn initialize_database(project_dir: &Path) -> Result<(), CoreError> {
202    let engram_dir = project_dir.join(ENGRAM_DIRECTORY);
203    fs::create_dir_all(&engram_dir).map_err(|error| {
204        CoreError::InitFailed(format!("failed to create database directory: {error}"))
205    })?;
206    let database_path = engram_dir.join(DATABASE_FILENAME);
207    let database_path_str = database_path
208        .to_str()
209        .ok_or_else(|| CoreError::InitFailed("database path is not valid utf-8".into()))?;
210    let _database = Database::open(database_path_str)?;
211    println!("Database initialized at {database_path_str}");
212    Ok(())
213}
214
215fn write_agent_instructions(project_dir: &Path) -> Result<(), CoreError> {
216    let agent_md_path = project_dir.join(ENGRAM_DIRECTORY).join(AGENT_MD_FILENAME);
217    fs::write(&agent_md_path, AGENT_MD_CONTENT)
218        .map_err(|error| CoreError::InitFailed(format!("failed to write AGENT.md: {error}")))?;
219    println!("Agent instructions written to {}", agent_md_path.display());
220    Ok(())
221}
222
223fn resolve_mcp_server_command() -> McpCommand {
224    if let Ok(env_path) = std::env::var("ENGRAM_MCP_SERVER_PATH") {
225        let path = PathBuf::from(&env_path);
226        if path.exists() {
227            return McpCommand::Node { script_path: path };
228        }
229    }
230    if let Some(bin_path) = resolve_engram_bin()
231        && let Some(bin_dir) = bin_path.parent()
232    {
233        let candidate = bin_dir
234            .join("..")
235            .join("mcp-server")
236            .join("dist")
237            .join("index.js");
238        if candidate.exists() {
239            if let Ok(canonical) = candidate.canonicalize() {
240                return McpCommand::Node {
241                    script_path: canonical,
242                };
243            }
244            return McpCommand::Node {
245                script_path: candidate,
246            };
247        }
248    }
249    eprintln!(
250        "warning: could not locate bundled engram mcp-server script; falling back to 'npx {MCP_NPX_PACKAGE}'. \
251         Set ENGRAM_MCP_SERVER_PATH to an absolute path of mcp-server/dist/index.js to silence this warning."
252    );
253    McpCommand::Npx
254}
255
256fn resolve_engram_bin() -> Option<PathBuf> {
257    if let Ok(value) = std::env::var("ENGRAM_BIN") {
258        let candidate = PathBuf::from(value);
259        if candidate.exists() {
260            return Some(candidate);
261        }
262    }
263    let output = std::process::Command::new("which")
264        .arg(DEFAULT_ENGRAM_BIN_NAME)
265        .output()
266        .ok()?;
267    if !output.status.success() {
268        return None;
269    }
270    let stdout = String::from_utf8(output.stdout).ok()?;
271    let trimmed = stdout.trim();
272    if trimmed.is_empty() {
273        return None;
274    }
275    Some(PathBuf::from(trimmed))
276}
277
278fn write_mcp_json(project_dir: &Path, command: &McpCommand) -> Result<(), CoreError> {
279    let (command_name, args): (&str, Vec<String>) = match command {
280        McpCommand::Node { script_path } => {
281            let path_str = script_path
282                .to_str()
283                .ok_or_else(|| CoreError::InitFailed("mcp script path is not valid utf-8".into()))?
284                .to_string();
285            ("node", vec![path_str])
286        }
287        McpCommand::Npx => ("npx", vec![MCP_NPX_PACKAGE.to_string()]),
288    };
289    let engram_bin = resolve_engram_bin();
290    let engram_bin_string = engram_bin.as_ref().and_then(|path| {
291        let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
292        canonical.to_str().map(|value| value.to_string())
293    });
294    let mut server_entry = serde_json::json!({
295        "command": command_name,
296        "args": args,
297    });
298    if let Some(bin_string) = engram_bin_string.as_ref() {
299        server_entry["env"] = serde_json::json!({ "ENGRAM_BIN": bin_string });
300    } else {
301        eprintln!(
302            "warning: could not resolve engram binary; MCP config has no ENGRAM_BIN env — set it manually before starting Claude Code"
303        );
304    }
305    let document = serde_json::json!({
306        "mcpServers": {
307            "engram": server_entry,
308        }
309    });
310    let serialized = serde_json::to_string_pretty(&document)
311        .map_err(|error| CoreError::InitFailed(format!("failed to serialize mcp json: {error}")))?;
312    let mcp_path = project_dir.join(MCP_JSON_FILENAME);
313    fs::write(&mcp_path, serialized)
314        .map_err(|error| CoreError::InitFailed(format!("failed to write .mcp.json: {error}")))?;
315    println!("MCP config written to {}", mcp_path.display());
316    Ok(())
317}
318
319fn write_gitignore(project_dir: &Path) -> Result<(), CoreError> {
320    let gitignore_path = project_dir.join(GITIGNORE_FILENAME);
321    let existing = match fs::read_to_string(&gitignore_path) {
322        Ok(content) => content,
323        Err(error) if error.kind() == std::io::ErrorKind::NotFound => String::new(),
324        Err(error) => {
325            return Err(CoreError::InitFailed(format!(
326                "failed to read .gitignore: {error}"
327            )));
328        }
329    };
330    if gitignore_contains_marker(&existing) {
331        return Ok(());
332    }
333    let mut updated = existing;
334    if !updated.is_empty() && !updated.ends_with('\n') {
335        updated.push('\n');
336    }
337    updated.push_str(GITIGNORE_MARKER);
338    updated.push('\n');
339    fs::write(&gitignore_path, updated)
340        .map_err(|error| CoreError::InitFailed(format!("failed to write .gitignore: {error}")))
341}
342
343fn gitignore_contains_marker(content: &str) -> bool {
344    content.lines().any(|line| {
345        line.trim() == GITIGNORE_MARKER.trim_end_matches('/') || line.trim() == GITIGNORE_MARKER
346    })
347}
348
349fn print_completion_info(project_dir: &Path) {
350    println!();
351    println!("Engram initialized at {}", project_dir.display());
352    println!();
353    println!("Set API keys via environment variables:");
354    println!("  export ENGRAM_VOYAGE_API_KEY=your-voyage-key");
355    println!("  export ENGRAM_OPENAI_API_KEY=your-openai-key");
356    println!();
357    println!("MCP config written to .mcp.json; Claude Code will pick it up automatically.");
358    println!("Add to your project's CLAUDE.md:");
359    println!("  Engram memory system: .engram/AGENT.md");
360}
361
362#[cfg(test)]
363mod docs_invariant_tests {
364    use super::DEFAULT_CONFIG_TEMPLATE;
365
366    const README: &str = include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/../../README.md"));
367    const EMBEDDING_PROVIDERS: [&str; 3] = ["voyage", "ollama", "deterministic"];
368    const LLM_PROVIDERS: [&str; 3] = ["openai", "ollama", "local"];
369    const BANNED_ROUTING_CLAIMS: [&str; 7] = [
370        "feeds the adaptive router",
371        "trains the adaptive router",
372        "per-call bandit update",
373        "self-learning optimization",
374        "three learning loops",
375        "self-learning trainer",
376        "self-learning",
377    ];
378
379    #[test]
380    fn every_provider_enum_appears_in_template_and_readme() {
381        for provider in EMBEDDING_PROVIDERS.iter().chain(LLM_PROVIDERS.iter()) {
382            assert!(
383                DEFAULT_CONFIG_TEMPLATE.contains(provider),
384                "config template must document provider `{provider}`"
385            );
386            assert!(
387                README.contains(provider),
388                "README must document provider `{provider}`"
389            );
390        }
391    }
392
393    #[test]
394    fn docs_make_no_active_adaptive_routing_claim() {
395        for doc in [README, super::AGENT_MD_CONTENT] {
396            let lower = doc.to_lowercase();
397            for phrase in BANNED_ROUTING_CLAIMS {
398                assert!(
399                    !lower.contains(phrase),
400                    "docs must not claim active adaptive routing: `{phrase}`"
401                );
402            }
403        }
404    }
405}