Skip to main content

recall_echo/
init.rs

1//! Initialize the recall-echo memory system.
2//!
3//! Creates the directory structure and template files needed for
4//! three-layer memory, knowledge graph, hooks, and LLM provider config.
5
6use std::fs;
7use std::io::{self, BufRead, Write as _};
8use std::path::Path;
9
10use crate::config::{self, Config, LlmSection, Provider};
11use crate::paths;
12
13// ANSI color helpers
14const GREEN: &str = "\x1b[32m";
15const YELLOW: &str = "\x1b[33m";
16const RED: &str = "\x1b[31m";
17const BOLD: &str = "\x1b[1m";
18const DIM: &str = "\x1b[2m";
19const RESET: &str = "\x1b[0m";
20
21const MEMORY_TEMPLATE: &str = "# Memory\n\n\
22<!-- recall-echo: Curated memory. Distilled facts, preferences, patterns. -->\n\
23<!-- Keep under 200 lines. Only write confirmed, stable information. -->\n";
24
25const ARCHIVE_TEMPLATE: &str = "# Conversation Archive\n\n\
26| # | Date | Session | Topics | Messages | Duration |\n\
27|---|------|---------|--------|----------|----------|\n";
28
29enum Status {
30    Created,
31    Exists,
32    Error,
33}
34
35fn print_status(status: Status, msg: &str) {
36    match status {
37        Status::Created => eprintln!("  {GREEN}✓{RESET} {msg}"),
38        Status::Exists => eprintln!("  {YELLOW}~{RESET} {msg}"),
39        Status::Error => eprintln!("  {RED}✗{RESET} {msg}"),
40    }
41}
42
43fn ensure_dir(path: &Path) {
44    if !path.exists() {
45        if let Err(e) = fs::create_dir_all(path) {
46            print_status(
47                Status::Error,
48                &format!("Failed to create {}: {e}", path.display()),
49            );
50        }
51    }
52}
53
54fn write_if_not_exists(path: &Path, content: &str, label: &str) {
55    if path.exists() {
56        print_status(
57            Status::Exists,
58            &format!("{label} already exists — preserved"),
59        );
60    } else {
61        match fs::write(path, content) {
62            Ok(()) => print_status(Status::Created, &format!("Created {label}")),
63            Err(e) => print_status(Status::Error, &format!("Failed to create {label}: {e}")),
64        }
65    }
66}
67
68/// Prompt for LLM provider selection during init.
69/// Returns None if stdin is not a terminal (non-interactive).
70fn prompt_provider(reader: &mut dyn BufRead) -> Option<Provider> {
71    // Check if stdin is a terminal
72    if !atty_check() {
73        // Non-interactive: default to claude-code if detected, else anthropic
74        return if paths::detect_claude_code().is_some() {
75            Some(Provider::ClaudeCode)
76        } else {
77            Some(Provider::Anthropic)
78        };
79    }
80
81    let is_cc = paths::detect_claude_code().is_some();
82    let default_label = if is_cc { "3" } else { "1" };
83
84    eprintln!("\n{BOLD}LLM provider for entity extraction:{RESET}");
85    eprintln!(
86        "  {BOLD}1{RESET}) anthropic   {DIM}— Claude API{}",
87        if !is_cc { " (default)" } else { "" }
88    );
89    eprintln!("  {BOLD}2{RESET}) ollama      {DIM}— Local models via Ollama{RESET}");
90    eprintln!(
91        "  {BOLD}3{RESET}) claude-code {DIM}— Uses `claude -p` subprocess{}",
92        if is_cc { " (default)" } else { "" }
93    );
94    eprintln!(
95        "  {BOLD}4{RESET}) skip        {DIM}— Configure later with `recall-echo config`{RESET}"
96    );
97    eprint!("\n  Choice [{default_label}]: ");
98    io::stderr().flush().ok();
99
100    let mut input = String::new();
101    if reader.read_line(&mut input).is_err() {
102        return None;
103    }
104
105    match input.trim() {
106        "" => {
107            if is_cc {
108                Some(Provider::ClaudeCode)
109            } else {
110                Some(Provider::Anthropic)
111            }
112        }
113        "1" | "anthropic" => Some(Provider::Anthropic),
114        "2" | "ollama" => Some(Provider::Openai),
115        "3" | "claude-code" => Some(Provider::ClaudeCode),
116        "4" | "skip" => None,
117        _ => {
118            let default = if is_cc {
119                Provider::ClaudeCode
120            } else {
121                Provider::Anthropic
122            };
123            eprintln!(
124                "  {YELLOW}~{RESET} Unknown choice, defaulting to {}",
125                default
126            );
127            Some(default)
128        }
129    }
130}
131
132/// Configure LLM provider. Returns true if the chosen provider is claude-code
133/// (indicating this is likely a Claude Code user).
134fn configure_llm(reader: &mut dyn BufRead, memory_dir: &Path) -> bool {
135    if !config::exists(memory_dir) {
136        if let Some(provider) = prompt_provider(reader) {
137            let is_cc = provider == Provider::ClaudeCode;
138            let cfg = Config {
139                llm: LlmSection {
140                    provider: provider.clone(),
141                    model: String::new(),
142                    api_base: String::new(),
143                },
144                ..Config::default()
145            };
146            match config::save(memory_dir, &cfg) {
147                Ok(()) => {
148                    let display_name = match &provider {
149                        Provider::Anthropic => "anthropic",
150                        Provider::Openai => "ollama (openai-compat)",
151                        Provider::ClaudeCode => "claude-code",
152                    };
153                    print_status(
154                        Status::Created,
155                        &format!("Created .recall-echo.toml (provider: {display_name})"),
156                    );
157                }
158                Err(e) => print_status(Status::Error, &format!("Failed to write config: {e}")),
159            }
160            return is_cc;
161        }
162        print_status(
163            Status::Exists,
164            "Skipped LLM config — run `recall-echo config set provider <name>` later",
165        );
166    } else {
167        print_status(
168            Status::Exists,
169            ".recall-echo.toml already exists — preserved",
170        );
171        // Check existing config
172        let cfg = config::load(memory_dir);
173        return cfg.llm.provider == Provider::ClaudeCode;
174    }
175    false
176}
177
178/// Initialize the graph store in memory/graph/.
179#[cfg(feature = "graph")]
180fn init_graph(memory_dir: &Path) {
181    let graph_dir = memory_dir.join("graph");
182    if graph_dir.exists() {
183        print_status(Status::Exists, "graph/ already exists — preserved");
184        return;
185    }
186
187    match tokio::runtime::Runtime::new() {
188        Ok(rt) => match rt.block_on(crate::graph::GraphMemory::open(&graph_dir)) {
189            Ok(_) => print_status(Status::Created, "Created graph/ (SurrealDB + fastembed)"),
190            Err(e) => print_status(Status::Error, &format!("Failed to init graph: {e}")),
191        },
192        Err(e) => print_status(Status::Error, &format!("Failed to start runtime: {e}")),
193    }
194}
195
196/// Auto-configure Claude Code hooks (settings.json).
197/// Returns true if hooks were configured.
198fn configure_hooks(entity_root: &Path) -> bool {
199    let claude_dir = match paths::detect_claude_code() {
200        Some(dir) => dir,
201        None => return false,
202    };
203
204    // Only configure hooks if entity_root is the Claude Code dir
205    if entity_root != claude_dir {
206        return false;
207    }
208
209    let settings_path = claude_dir.join("settings.json");
210    let recall_bin = std::env::current_exe()
211        .ok()
212        .and_then(|p| p.to_str().map(String::from))
213        .unwrap_or_else(|| "recall-echo".into());
214
215    let archive_cmd = format!("{recall_bin} archive-session");
216    let checkpoint_cmd = format!("{recall_bin} checkpoint --trigger precompact");
217
218    // Load existing settings or start fresh
219    let mut settings: serde_json::Value = if settings_path.exists() {
220        fs::read_to_string(&settings_path)
221            .ok()
222            .and_then(|s| serde_json::from_str(&s).ok())
223            .unwrap_or_else(|| serde_json::json!({}))
224    } else {
225        serde_json::json!({})
226    };
227
228    let hooks = settings.as_object_mut().and_then(|o| {
229        o.entry("hooks")
230            .or_insert_with(|| serde_json::json!({}))
231            .as_object_mut()
232    });
233
234    let hooks = match hooks {
235        Some(h) => h,
236        None => {
237            print_status(Status::Error, "Could not parse settings.json hooks");
238            return false;
239        }
240    };
241
242    let mut changed = false;
243
244    // Add SessionEnd hook if not already present
245    if !hook_exists(hooks, "SessionEnd", &archive_cmd) {
246        let arr = hooks
247            .entry("SessionEnd")
248            .or_insert_with(|| serde_json::json!([]))
249            .as_array_mut();
250        if let Some(arr) = arr {
251            arr.push(serde_json::json!({
252                "hooks": [{"type": "command", "command": archive_cmd}]
253            }));
254            changed = true;
255        }
256    }
257
258    // Add PreCompact hook if not already present
259    if !hook_exists(hooks, "PreCompact", &checkpoint_cmd) {
260        let arr = hooks
261            .entry("PreCompact")
262            .or_insert_with(|| serde_json::json!([]))
263            .as_array_mut();
264        if let Some(arr) = arr {
265            arr.push(serde_json::json!({
266                "hooks": [{"type": "command", "command": checkpoint_cmd}]
267            }));
268            changed = true;
269        }
270    }
271
272    if changed {
273        match serde_json::to_string_pretty(&settings) {
274            Ok(content) => match fs::write(&settings_path, content) {
275                Ok(()) => {
276                    print_status(
277                        Status::Created,
278                        "Configured SessionEnd + PreCompact hooks in settings.json",
279                    );
280                    return true;
281                }
282                Err(e) => print_status(
283                    Status::Error,
284                    &format!("Failed to write settings.json: {e}"),
285                ),
286            },
287            Err(e) => print_status(Status::Error, &format!("Failed to serialize settings: {e}")),
288        }
289    } else {
290        print_status(Status::Exists, "Hooks already configured in settings.json");
291        return true;
292    }
293
294    false
295}
296
297/// Check if a hook command already exists in a hook event array.
298fn hook_exists(
299    hooks: &serde_json::Map<String, serde_json::Value>,
300    event: &str,
301    command: &str,
302) -> bool {
303    if let Some(arr) = hooks.get(event).and_then(|v| v.as_array()) {
304        for group in arr {
305            if let Some(inner) = group.get("hooks").and_then(|h| h.as_array()) {
306                for hook in inner {
307                    if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) {
308                        // Match on the base command name, not the full path
309                        if cmd.contains("recall-echo archive-session")
310                            && command.contains("archive-session")
311                        {
312                            return true;
313                        }
314                        if cmd.contains("recall-echo checkpoint") && command.contains("checkpoint")
315                        {
316                            return true;
317                        }
318                    }
319                }
320            }
321        }
322    }
323    false
324}
325
326/// Check if stderr is a terminal (for interactive prompts).
327fn atty_check() -> bool {
328    #[cfg(unix)]
329    {
330        extern "C" {
331            fn isatty(fd: std::os::raw::c_int) -> std::os::raw::c_int;
332        }
333        unsafe { isatty(2) != 0 }
334    }
335    #[cfg(not(unix))]
336    {
337        false
338    }
339}
340
341/// Initialize memory structure at the given entity root.
342///
343/// Creates:
344/// ```text
345/// {entity_root}/memory/
346/// ├── MEMORY.md
347/// ├── EPHEMERAL.md
348/// ├── ARCHIVE.md
349/// ├── .recall-echo.toml
350/// └── conversations/
351/// ```
352pub fn run(entity_root: &Path) -> Result<(), String> {
353    let stdin = io::stdin();
354    let mut reader = stdin.lock();
355    run_with_reader(entity_root, &mut reader)
356}
357
358/// Testable init with injectable reader.
359pub fn run_with_reader(entity_root: &Path, reader: &mut dyn BufRead) -> Result<(), String> {
360    if !entity_root.exists() {
361        return Err(format!(
362            "Directory not found: {}\n  Create the directory first, or run from a valid path.",
363            entity_root.display()
364        ));
365    }
366
367    eprintln!("\n{BOLD}recall-echo{RESET} — initializing memory system\n");
368
369    let memory_dir = entity_root.join("memory");
370    let conversations_dir = memory_dir.join("conversations");
371    ensure_dir(&memory_dir);
372    ensure_dir(&conversations_dir);
373
374    // Write MEMORY.md (never overwrite)
375    write_if_not_exists(&memory_dir.join("MEMORY.md"), MEMORY_TEMPLATE, "MEMORY.md");
376
377    // Write EPHEMERAL.md (never overwrite)
378    write_if_not_exists(&memory_dir.join("EPHEMERAL.md"), "", "EPHEMERAL.md");
379
380    // Write ARCHIVE.md (never overwrite)
381    write_if_not_exists(
382        &memory_dir.join("ARCHIVE.md"),
383        ARCHIVE_TEMPLATE,
384        "ARCHIVE.md",
385    );
386
387    // Initialize graph store
388    #[cfg(feature = "graph")]
389    init_graph(&memory_dir);
390
391    // Configure LLM provider if no config exists yet
392    let is_claude_code = configure_llm(reader, &memory_dir);
393
394    // Auto-configure Claude Code hooks if applicable
395    let hooks_configured = if is_claude_code {
396        configure_hooks(entity_root)
397    } else {
398        false
399    };
400
401    // Summary
402    eprintln!("\n{BOLD}Setup complete.{RESET} Memory system is ready.\n");
403    eprintln!("  Layer 1 (MEMORY.md)     — Curated facts, always in context");
404    eprintln!("  Layer 2 (EPHEMERAL.md)  — Rolling window of recent sessions (FIFO, max 5)");
405    eprintln!("  Layer 3 (Archive)       — Full conversations in memory/conversations/");
406    #[cfg(feature = "graph")]
407    eprintln!("  Layer 0 (Graph)         — Knowledge graph with semantic search");
408    eprintln!();
409    eprintln!("  Run `recall-echo status` to check memory health.");
410    eprintln!("  Run `recall-echo config show` to view configuration.");
411    if hooks_configured {
412        eprintln!("  Hooks configured — archiving happens automatically.");
413    }
414    eprintln!();
415
416    Ok(())
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422    use std::io::Cursor;
423
424    #[test]
425    fn init_creates_directories_and_files() {
426        let tmp = tempfile::tempdir().unwrap();
427        let root = tmp.path().to_path_buf();
428        let mut reader = Cursor::new(b"4\n" as &[u8]); // skip provider prompt
429
430        run_with_reader(&root, &mut reader).unwrap();
431
432        assert!(root.join("memory/MEMORY.md").exists());
433        assert!(root.join("memory/EPHEMERAL.md").exists());
434        assert!(root.join("memory/ARCHIVE.md").exists());
435        assert!(root.join("memory/conversations").exists());
436    }
437
438    #[test]
439    fn init_is_idempotent() {
440        let tmp = tempfile::tempdir().unwrap();
441        let root = tmp.path().to_path_buf();
442        let mut reader = Cursor::new(b"4\n" as &[u8]);
443
444        run_with_reader(&root, &mut reader).unwrap();
445        fs::write(root.join("memory/MEMORY.md"), "custom content").unwrap();
446
447        let mut reader2 = Cursor::new(b"4\n" as &[u8]);
448        run_with_reader(&root, &mut reader2).unwrap();
449        let content = fs::read_to_string(root.join("memory/MEMORY.md")).unwrap();
450        assert_eq!(content, "custom content");
451    }
452
453    #[test]
454    fn init_fails_if_root_missing() {
455        let mut reader = Cursor::new(b"" as &[u8]);
456        let result = run_with_reader(Path::new("/nonexistent/path"), &mut reader);
457        assert!(result.is_err());
458    }
459
460    #[test]
461    fn archive_template_has_header() {
462        let tmp = tempfile::tempdir().unwrap();
463        let mut reader = Cursor::new(b"4\n" as &[u8]);
464        run_with_reader(tmp.path(), &mut reader).unwrap();
465        let content = fs::read_to_string(tmp.path().join("memory/ARCHIVE.md")).unwrap();
466        assert!(content.contains("# Conversation Archive"));
467        assert!(content.contains("| # | Date"));
468    }
469}