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