Skip to main content

recall_echo/
init.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! Initialize the recall-echo memory system.
6//!
7//! Creates the directory structure and template files needed for four-layer
8//! memory (graph, curated, short-term, long-term), picks an extraction
9//! provider, installs Claude Code's hooks, registers the MCP server with every
10//! agent CLI on the machine, and downloads the embedding model.
11//!
12//! # What `init` asks
13//!
14//! As little as it can get away with. Setup friction is what loses users, so
15//! every question here has to earn itself:
16//!
17//! - one agent CLI installed — no question at all, that is the provider;
18//! - several — one short menu, defaulted to the CLI the session is running
19//!   under, because that is the subscription the user just proved they have;
20//! - none — the full provider menu, since now the choice really is open.
21//!
22//! Nothing prompts unless stderr is a terminal ([`atty_check`]); a scripted or
23//! piped install takes the same defaults without blocking.
24//!
25//! # What it does without asking
26//!
27//! Hooks, MCP registration and the model download are consequences of what is
28//! installed, not preferences, so they happen. Each is idempotent, each reports
29//! itself, and none of them can fail the command:
30//!
31//! - hooks are matched by command name, so re-running never duplicates one;
32//! - MCP servers live in a map keyed by name in every client, so re-registering
33//!   the same name is a no-op (see [`crate::agent_cli`]);
34//! - the model is a content-addressed cache, so a second warm is a no-op.
35//!
36//! # The build-directory guard
37//!
38//! A binary under `target/debug` or `target/release` is a test harness or a
39//! working copy, not something a user's hooks and MCP configs should be pinned
40//! to for the life of the install. Everything that writes *outside* the entity
41//! root — hooks, MCP registration — is skipped there, which is also what keeps
42//! `cargo test` from repointing the developer's live tooling at a test binary
43//! or downloading 127 MB per test.
44
45use std::fs;
46use std::io::{self, BufRead, Write as _};
47use std::path::Path;
48
49use crate::agent_cli::{self, AgentCli, McpReport, McpStatus};
50use crate::config::{self, Config, LlmSection, Provider};
51use crate::error::RecallError;
52use crate::paths;
53use crate::transcript::Source;
54
55// ANSI color helpers
56const GREEN: &str = "\x1b[32m";
57const YELLOW: &str = "\x1b[33m";
58const RED: &str = "\x1b[31m";
59const BOLD: &str = "\x1b[1m";
60const DIM: &str = "\x1b[2m";
61const RESET: &str = "\x1b[0m";
62
63const MEMORY_TEMPLATE: &str = "# Memory\n\n\
64<!-- recall-echo: Curated memory. Distilled facts, preferences, patterns. -->\n\
65<!-- Keep under 200 lines. Only write confirmed, stable information. -->\n";
66
67const ARCHIVE_TEMPLATE: &str = "# Conversation Archive\n\n\
68| # | Date | Session | Topics | Messages | Duration |\n\
69|---|------|---------|--------|----------|----------|\n";
70
71/// Roughly what the BGE-Small-EN-v1.5 ONNX weights weigh, for the one line
72/// that tells the user why their terminal is busy.
73const MODEL_DOWNLOAD_SIZE: &str = "~127 MB";
74
75enum Status {
76    Created,
77    Exists,
78    Error,
79}
80
81fn print_status(status: Status, msg: &str) {
82    match status {
83        Status::Created => eprintln!("  {GREEN}✓{RESET} {msg}"),
84        Status::Exists => eprintln!("  {YELLOW}~{RESET} {msg}"),
85        Status::Error => eprintln!("  {RED}✗{RESET} {msg}"),
86    }
87}
88
89fn ensure_dir(path: &Path) {
90    if !path.exists() {
91        if let Err(e) = fs::create_dir_all(path) {
92            print_status(
93                Status::Error,
94                &format!("Failed to create {}: {e}", path.display()),
95            );
96        }
97    }
98}
99
100fn write_if_not_exists(path: &Path, content: &str, label: &str) {
101    if path.exists() {
102        print_status(
103            Status::Exists,
104            &format!("{label} already exists — preserved"),
105        );
106    } else {
107        match fs::write(path, content) {
108            Ok(()) => print_status(Status::Created, &format!("Created {label}")),
109            Err(e) => print_status(Status::Error, &format!("Failed to create {label}: {e}")),
110        }
111    }
112}
113
114// ── Choosing an extraction provider ──────────────────────────────────────
115
116/// Pick the provider that will turn conversations into knowledge.
117///
118/// `detected` is every agent CLI whose binary is on this machine, in
119/// preference order. `None` means the user chose to configure it later.
120fn select_provider(reader: &mut dyn BufRead, detected: &[AgentCli]) -> Option<Provider> {
121    match detected {
122        // Nothing to choose between: the answer is obvious, so do not ask it.
123        [only] => {
124            print_status(
125                Status::Created,
126                &format!("found {only} — using it for extraction"),
127            );
128            Some(only.provider())
129        }
130        [] => {
131            eprintln!(
132                "\n  {YELLOW}~{RESET} No agent CLI found. Extraction needs a model provider — \
133                 {BOLD}ollama{RESET} is the free, local option."
134            );
135            prompt_any_provider(reader)
136        }
137        several => prompt_installed_cli(reader, several),
138    }
139}
140
141/// The CLI a menu should default to: the one this session is running under,
142/// else Claude Code, else the first installed.
143fn default_cli(detected: &[AgentCli]) -> AgentCli {
144    let running_under = agent_cli::current().filter(|cli| detected.contains(cli));
145    running_under
146        .or_else(|| {
147            detected
148                .contains(&AgentCli::ClaudeCode)
149                .then_some(AgentCli::ClaudeCode)
150        })
151        .or_else(|| detected.first().copied())
152        .unwrap_or(AgentCli::ClaudeCode)
153}
154
155/// Short menu over the CLIs that are actually installed.
156fn prompt_installed_cli(reader: &mut dyn BufRead, detected: &[AgentCli]) -> Option<Provider> {
157    let default = default_cli(detected);
158    if !atty_check() {
159        print_status(
160            Status::Created,
161            &format!(
162                "{} agent CLIs found — using {default} for extraction",
163                detected.len()
164            ),
165        );
166        return Some(default.provider());
167    }
168
169    let default_index = detected.iter().position(|cli| *cli == default).unwrap_or(0) + 1;
170
171    eprintln!("\n{BOLD}Which CLI should recall-echo use to extract knowledge?{RESET}");
172    for (index, cli) in detected.iter().enumerate() {
173        let note = if *cli == default {
174            if agent_cli::current() == Some(*cli) {
175                "— you're running under it (default)"
176            } else {
177                "— (default)"
178            }
179        } else {
180            ""
181        };
182        eprintln!(
183            "  {BOLD}{}{RESET}) {:<12}{DIM}{note}{RESET}",
184            index + 1,
185            cli.label()
186        );
187    }
188    eprintln!("  {BOLD}o{RESET}) other       {DIM}— Claude API, Ollama, or decide later{RESET}");
189    eprint!("\n  Choice [{default_index}]: ");
190    io::stderr().flush().ok();
191
192    let mut input = String::new();
193    if reader.read_line(&mut input).is_err() {
194        return Some(default.provider());
195    }
196
197    let answer = input.trim().to_lowercase();
198    if answer.is_empty() {
199        return Some(default.provider());
200    }
201    if answer == "o" || answer == "other" {
202        return prompt_any_provider(reader);
203    }
204    if let Some(cli) = answer
205        .parse::<usize>()
206        .ok()
207        .and_then(|n| detected.get(n.wrapping_sub(1)))
208    {
209        return Some(cli.provider());
210    }
211    if let Some(cli) = detected.iter().find(|cli| cli.label() == answer) {
212        return Some(cli.provider());
213    }
214    eprintln!("  {YELLOW}~{RESET} Unknown choice, defaulting to {default}");
215    Some(default.provider())
216}
217
218/// The full provider menu — every provider recall-echo speaks, installed or
219/// not. Reached when nothing was detected, or when the user asks for it.
220///
221/// Returns `None` if the user chose to configure it later.
222fn prompt_any_provider(reader: &mut dyn BufRead) -> Option<Provider> {
223    if !atty_check() {
224        return Some(Provider::Anthropic);
225    }
226
227    eprintln!("\n{BOLD}LLM provider for entity extraction:{RESET}");
228    eprintln!("  {BOLD}1{RESET}) anthropic   {DIM}— Claude API (default){RESET}");
229    eprintln!("  {BOLD}2{RESET}) ollama      {DIM}— Local models via Ollama, free{RESET}");
230    eprintln!(
231        "  {BOLD}3{RESET}) claude-code {DIM}— Spawns your `claude` CLI (subscription){RESET}"
232    );
233    eprintln!(
234        "  {BOLD}4{RESET}) gemini      {DIM}— Spawns your `gemini` CLI (subscription){RESET}"
235    );
236    eprintln!("  {BOLD}5{RESET}) grok        {DIM}— Spawns your `grok` CLI (subscription){RESET}");
237    eprintln!("  {BOLD}6{RESET}) codex       {DIM}— Spawns your `codex` CLI (subscription){RESET}");
238    eprintln!(
239        "  {BOLD}7{RESET}) skip        {DIM}— Configure later with `recall-echo config`{RESET}"
240    );
241    eprint!("\n  Choice [1]: ");
242    io::stderr().flush().ok();
243
244    let mut input = String::new();
245    if reader.read_line(&mut input).is_err() {
246        return None;
247    }
248
249    match input.trim() {
250        "" | "1" | "anthropic" => Some(Provider::Anthropic),
251        "2" | "ollama" => Some(Provider::Openai),
252        "3" | "claude-code" => Some(Provider::ClaudeCode),
253        "4" | "gemini" => Some(Provider::Gemini),
254        "5" | "grok" => Some(Provider::Grok),
255        "6" | "codex" => Some(Provider::Codex),
256        "7" | "skip" => None,
257        _ => {
258            eprintln!("  {YELLOW}~{RESET} Unknown choice, defaulting to anthropic");
259            Some(Provider::Anthropic)
260        }
261    }
262}
263
264/// Write `.recall-echo.toml` if there is none, and report the provider in
265/// force either way. `None` means extraction is not configured.
266fn configure_llm(
267    reader: &mut dyn BufRead,
268    memory_dir: &Path,
269    detected: &[AgentCli],
270) -> Option<Provider> {
271    if config::exists(memory_dir) {
272        print_status(
273            Status::Exists,
274            ".recall-echo.toml already exists — preserved",
275        );
276        return Some(config::load(memory_dir).llm.provider);
277    }
278
279    let Some(provider) = select_provider(reader, detected) else {
280        print_status(
281            Status::Exists,
282            "Skipped LLM config — run `recall-echo config set provider <name>` later",
283        );
284        return None;
285    };
286
287    let cfg = Config {
288        llm: LlmSection {
289            provider: provider.clone(),
290            ..LlmSection::default()
291        },
292        ..Config::default()
293    };
294    match config::save(memory_dir, &cfg) {
295        Ok(()) => {
296            print_status(
297                Status::Created,
298                &format!(
299                    "Created .recall-echo.toml (provider: {})",
300                    label_of(&provider)
301                ),
302            );
303            Some(provider)
304        }
305        Err(e) => {
306            print_status(Status::Error, &format!("Failed to write config: {e}"));
307            None
308        }
309    }
310}
311
312/// The provider's name as a user knows it.
313fn label_of(provider: &Provider) -> String {
314    match provider {
315        Provider::Openai => "ollama (openai-compat)".to_string(),
316        other => other.to_string(),
317    }
318}
319
320/// The provider's name plus what it will cost.
321fn extraction_line(provider: &Provider) -> String {
322    match provider {
323        Provider::Anthropic => "anthropic (Claude API — set ANTHROPIC_API_KEY)".into(),
324        Provider::Openai => "ollama (local models — free)".into(),
325        Provider::Cli => "custom CLI (from `[llm.cli]`)".into(),
326        cli => format!("{cli} (your subscription — no API billing)"),
327    }
328}
329
330// ── Graph and embedding model ────────────────────────────────────────────
331
332/// Initialize the graph store in memory/graph/.
333fn init_graph(runtime: &tokio::runtime::Runtime, memory_dir: &Path) {
334    let graph_dir = memory_dir.join("graph");
335    if graph_dir.exists() {
336        print_status(Status::Exists, "graph/ already exists — preserved");
337        return;
338    }
339
340    match runtime.block_on(crate::graph::GraphMemory::open(&graph_dir)) {
341        Ok(_) => print_status(Status::Created, "Created graph/ (SurrealDB)"),
342        Err(e) => print_status(Status::Error, &format!("Failed to init graph: {e}")),
343    }
344}
345
346/// What became of the embedding model.
347#[derive(Debug, Clone, PartialEq, Eq)]
348enum WarmOutcome {
349    Ready,
350    Skipped(&'static str),
351    Failed(String),
352}
353
354/// Download and load the embedding model now, rather than on first use.
355///
356/// The first embedding a user ever asks for otherwise stalls for a ~127 MB
357/// download with no explanation — the single most convincing way to look
358/// broken. Doing it here, last and announced, makes it a setup step.
359///
360/// Interruptible: nothing after this point is required, so Ctrl-C leaves a
361/// working install and the model downloads on first use instead. Failure is
362/// reported and never fatal, so an offline install still succeeds.
363fn warm_embedding_model(memory_dir: &Path) -> WarmOutcome {
364    let exe = recall_binary();
365    if is_build_dir(&exe) {
366        return WarmOutcome::Skipped("running from a build directory");
367    }
368
369    let models_dir = memory_dir.join("graph").join("models");
370    if let Err(e) = fs::create_dir_all(&models_dir) {
371        return WarmOutcome::Failed(format!("could not create {}: {e}", models_dir.display()));
372    }
373
374    let cached = fs::read_dir(&models_dir).is_ok_and(|mut entries| entries.next().is_some());
375    if cached {
376        eprintln!("  {DIM}… loading the embedding model{RESET}");
377    } else {
378        eprintln!(
379            "  {DIM}… downloading the embedding model ({MODEL_DOWNLOAD_SIZE}, once) — \
380             everything else is already set up, Ctrl-C is safe{RESET}"
381        );
382    }
383
384    match crate::graph::embed::FastEmbedder::new(&models_dir) {
385        Ok(_) => WarmOutcome::Ready,
386        Err(e) => WarmOutcome::Failed(e.to_string()),
387    }
388}
389
390// ── Claude Code hooks ────────────────────────────────────────────────────
391
392/// The recall-echo binary that hooks and MCP registrations should point at.
393fn recall_binary() -> String {
394    std::env::current_exe()
395        .ok()
396        .and_then(|p| p.to_str().map(String::from))
397        .unwrap_or_else(|| "recall-echo".into())
398}
399
400/// True when this binary lives in a Cargo build directory.
401///
402/// Such a path is a test harness or a working copy, and pinning a user's hooks
403/// or MCP config to it would break the moment the tree is cleaned.
404fn is_build_dir(exe: &str) -> bool {
405    exe.contains("/target/debug/") || exe.contains("/target/release/")
406}
407
408/// Auto-configure Claude Code hooks (settings.json).
409/// Returns true if hooks were configured.
410/// Hooks always go in ~/.claude/settings.json regardless of where entity_root is.
411fn configure_hooks(_entity_root: &Path) -> bool {
412    let claude_dir = match paths::detect_claude_code() {
413        Some(dir) => dir,
414        None => return false,
415    };
416
417    let settings_path = claude_dir.join("settings.json");
418    let recall_bin = recall_binary();
419
420    // A path under target/ is a test harness or a debug build, not something
421    // a user's hooks should be pinned to for the life of the install.
422    if is_build_dir(&recall_bin) {
423        print_status(
424            Status::Exists,
425            "Skipped hook install — running from a build directory",
426        );
427        return false;
428    }
429
430    let archive_cmd = format!("{recall_bin} archive-session");
431    let checkpoint_cmd = format!("{recall_bin} checkpoint --trigger precompact");
432    let consume_cmd = format!("{recall_bin} consume");
433
434    // Load existing settings or start fresh
435    let mut settings: serde_json::Value = if settings_path.exists() {
436        fs::read_to_string(&settings_path)
437            .ok()
438            .and_then(|s| serde_json::from_str(&s).ok())
439            .unwrap_or_else(|| serde_json::json!({}))
440    } else {
441        serde_json::json!({})
442    };
443
444    let hooks = settings.as_object_mut().and_then(|o| {
445        o.entry("hooks")
446            .or_insert_with(|| serde_json::json!({}))
447            .as_object_mut()
448    });
449
450    let hooks = match hooks {
451        Some(h) => h,
452        None => {
453            print_status(Status::Error, "Could not parse settings.json hooks");
454            return false;
455        }
456    };
457
458    let mut changed = false;
459
460    // Add SessionStart hook if not already present
461    // Fires once per session (on startup or resume) — injects EPHEMERAL.md
462    // into context via stdout. Skips `clear` (user reset) and `compact`
463    // (we just recovered from a compaction, no prior session to surface).
464    if !hook_exists(hooks, "SessionStart", &consume_cmd) {
465        let arr = hooks
466            .entry("SessionStart")
467            .or_insert_with(|| serde_json::json!([]))
468            .as_array_mut();
469        if let Some(arr) = arr {
470            arr.push(serde_json::json!({
471                "matcher": "startup|resume",
472                "hooks": [{"type": "command", "command": consume_cmd}]
473            }));
474            changed = true;
475        }
476    }
477
478    // Add SessionEnd hook if not already present
479    if !hook_exists(hooks, "SessionEnd", &archive_cmd) {
480        let arr = hooks
481            .entry("SessionEnd")
482            .or_insert_with(|| serde_json::json!([]))
483            .as_array_mut();
484        if let Some(arr) = arr {
485            arr.push(serde_json::json!({
486                "hooks": [{"type": "command", "command": archive_cmd}]
487            }));
488            changed = true;
489        }
490    }
491
492    // Add PreCompact hook if not already present
493    if !hook_exists(hooks, "PreCompact", &checkpoint_cmd) {
494        let arr = hooks
495            .entry("PreCompact")
496            .or_insert_with(|| serde_json::json!([]))
497            .as_array_mut();
498        if let Some(arr) = arr {
499            arr.push(serde_json::json!({
500                "hooks": [{"type": "command", "command": checkpoint_cmd}]
501            }));
502            changed = true;
503        }
504    }
505
506    if changed {
507        match serde_json::to_string_pretty(&settings) {
508            Ok(content) => match fs::write(&settings_path, content) {
509                Ok(()) => {
510                    print_status(
511                        Status::Created,
512                        "Configured SessionStart + SessionEnd + PreCompact hooks in settings.json",
513                    );
514                    return true;
515                }
516                Err(e) => print_status(
517                    Status::Error,
518                    &format!("Failed to write settings.json: {e}"),
519                ),
520            },
521            Err(e) => print_status(Status::Error, &format!("Failed to serialize settings: {e}")),
522        }
523    } else {
524        print_status(Status::Exists, "Hooks already configured in settings.json");
525        return true;
526    }
527
528    false
529}
530
531/// Check if a hook command already exists in a hook event array.
532fn hook_exists(
533    hooks: &serde_json::Map<String, serde_json::Value>,
534    event: &str,
535    command: &str,
536) -> bool {
537    if let Some(arr) = hooks.get(event).and_then(|v| v.as_array()) {
538        for group in arr {
539            if let Some(inner) = group.get("hooks").and_then(|h| h.as_array()) {
540                for hook in inner {
541                    if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) {
542                        // Match on the base command name, not the full path
543                        if cmd.contains("recall-echo archive-session")
544                            && command.contains("archive-session")
545                        {
546                            return true;
547                        }
548                        if cmd.contains("recall-echo checkpoint") && command.contains("checkpoint")
549                        {
550                            return true;
551                        }
552                        if cmd.contains("recall-echo consume") && command.contains("consume") {
553                            return true;
554                        }
555                    }
556                }
557            }
558        }
559    }
560    false
561}
562
563// ── MCP registration ─────────────────────────────────────────────────────
564
565/// Register the MCP server with every agent CLI on the machine.
566///
567/// Without this the graph is read-only in theory and unread in practice: the
568/// server exists, and every user has to find the `mcp add` line in the README
569/// to reach it. Doing it here means memory is queryable from the next session
570/// on, in every client the user already has.
571fn register_mcp_clients(
572    runtime: &tokio::runtime::Runtime,
573    detected: &[AgentCli],
574    entity_root: &Path,
575) -> Vec<McpReport> {
576    if detected.is_empty() {
577        return Vec::new();
578    }
579
580    let exe = recall_binary();
581    if is_build_dir(&exe) {
582        print_status(
583            Status::Exists,
584            "Skipped MCP registration — running from a build directory",
585        );
586        return Vec::new();
587    }
588
589    let root = fs::canonicalize(entity_root).unwrap_or_else(|_| entity_root.to_path_buf());
590    let reports: Vec<McpReport> = runtime.block_on(async {
591        let mut reports = Vec::with_capacity(detected.len());
592        for cli in detected {
593            reports.push(agent_cli::register_mcp(*cli, &exe, &root).await);
594        }
595        reports
596    });
597
598    for report in &reports {
599        match &report.status {
600            McpStatus::Registered => print_status(
601                Status::Created,
602                &format!("Registered MCP server with {}", report.cli),
603            ),
604            McpStatus::AlreadyRegistered => print_status(
605                Status::Exists,
606                &format!("MCP server already registered with {}", report.cli),
607            ),
608            McpStatus::Failed(detail) => {
609                print_status(
610                    Status::Error,
611                    &format!("Could not register MCP with {}: {detail}", report.cli),
612                );
613                eprintln!("    {DIM}run it yourself: {}{RESET}", report.command);
614            }
615        }
616    }
617    reports
618}
619
620// ── Summary ──────────────────────────────────────────────────────────────
621
622/// Everything `init` decided, as the closing summary needs it.
623struct Summary {
624    memory_dir: std::path::PathBuf,
625    provider: Option<Provider>,
626    capture: Vec<Source>,
627    mcp: Vec<McpReport>,
628    embedder: WarmOutcome,
629}
630
631impl Summary {
632    /// Clients that will be able to query memory over MCP.
633    fn mcp_ready(&self) -> Vec<&'static str> {
634        self.mcp
635            .iter()
636            .filter(|report| !matches!(report.status, McpStatus::Failed(_)))
637            .map(|report| report.cli.label())
638            .collect()
639    }
640}
641
642/// Tell the user what will now happen without them doing anything.
643fn print_summary(summary: &Summary) {
644    eprintln!("\n{BOLD}Setup complete.{RESET}\n");
645    print_status(
646        Status::Created,
647        &format!("memory initialised at {}", summary.memory_dir.display()),
648    );
649
650    match &summary.provider {
651        Some(provider) => print_status(
652            Status::Created,
653            &format!("extraction: {}", extraction_line(provider)),
654        ),
655        None => print_status(
656            Status::Exists,
657            "extraction: not configured — `recall-echo config set provider <name>`",
658        ),
659    }
660
661    if summary.capture.is_empty() {
662        print_status(
663            Status::Exists,
664            "capture: no agent CLI has recorded sessions here yet",
665        );
666    } else {
667        let names: Vec<&str> = summary.capture.iter().map(Source::as_str).collect();
668        print_status(Status::Created, &format!("capture: {}", names.join(", ")));
669    }
670
671    let ready = summary.mcp_ready();
672    if !ready.is_empty() {
673        print_status(
674            Status::Created,
675            &format!("MCP registered: {}", ready.join(", ")),
676        );
677    }
678
679    match &summary.embedder {
680        WarmOutcome::Ready => print_status(Status::Created, "embedding model ready"),
681        WarmOutcome::Skipped(reason) => print_status(
682            Status::Exists,
683            &format!("embedding model not warmed ({reason}) — downloads on first use"),
684        ),
685        WarmOutcome::Failed(detail) => print_status(
686            Status::Exists,
687            &format!("embedding model not downloaded ({detail}) — retries on first use"),
688        ),
689    }
690
691    eprintln!("\n  {BOLD}Your next session will be remembered.{RESET}\n");
692    eprintln!("  {DIM}recall-echo status       — is it healthy, what has it got{RESET}");
693    eprintln!("  {DIM}recall-echo config show  — what it decided{RESET}");
694    eprintln!();
695}
696
697/// Check if stderr is a terminal (for interactive prompts).
698fn atty_check() -> bool {
699    use std::io::IsTerminal;
700    std::io::stderr().is_terminal()
701}
702
703// ── Entry point ──────────────────────────────────────────────────────────
704
705/// Initialize memory structure at the given entity root.
706///
707/// Creates:
708/// ```text
709/// {entity_root}/memory/
710/// ├── MEMORY.md
711/// ├── EPHEMERAL.md
712/// ├── ARCHIVE.md
713/// ├── .recall-echo.toml
714/// ├── graph/
715/// └── conversations/
716/// ```
717pub fn run(entity_root: &Path) -> Result<(), RecallError> {
718    let stdin = io::stdin();
719    let mut reader = stdin.lock();
720    run_with_reader(entity_root, &mut reader)
721}
722
723/// Testable init with injectable reader.
724pub fn run_with_reader(entity_root: &Path, reader: &mut dyn BufRead) -> Result<(), RecallError> {
725    if !entity_root.exists() {
726        return Err(RecallError::NotInitialized(format!(
727            "Directory not found: {}\n  Create the directory first, or run from a valid path.",
728            entity_root.display()
729        )));
730    }
731
732    eprintln!("\n{BOLD}recall-echo{RESET} — initializing memory system\n");
733
734    let memory_dir = entity_root.join("memory");
735    let conversations_dir = memory_dir.join("conversations");
736    ensure_dir(&memory_dir);
737    ensure_dir(&conversations_dir);
738
739    // Write MEMORY.md (never overwrite)
740    write_if_not_exists(&memory_dir.join("MEMORY.md"), MEMORY_TEMPLATE, "MEMORY.md");
741
742    // Write EPHEMERAL.md (never overwrite)
743    write_if_not_exists(&memory_dir.join("EPHEMERAL.md"), "", "EPHEMERAL.md");
744
745    // Write ARCHIVE.md (never overwrite)
746    write_if_not_exists(
747        &memory_dir.join("ARCHIVE.md"),
748        ARCHIVE_TEMPLATE,
749        "ARCHIVE.md",
750    );
751
752    let runtime = tokio::runtime::Builder::new_current_thread()
753        .enable_all()
754        .build();
755    let runtime = match runtime {
756        Ok(runtime) => Some(runtime),
757        Err(e) => {
758            print_status(Status::Error, &format!("Failed to start runtime: {e}"));
759            None
760        }
761    };
762
763    if let Some(runtime) = &runtime {
764        init_graph(runtime, &memory_dir);
765    }
766
767    let detected = agent_cli::installed();
768    let provider = configure_llm(reader, &memory_dir, &detected);
769
770    // Hooks are Claude Code's capture mechanism, not a consequence of the
771    // extraction provider: a user who extracts with grok still wants their
772    // Claude Code sessions archived. `configure_hooks` no-ops when Claude Code
773    // is not installed.
774    configure_hooks(entity_root);
775
776    let mcp = match &runtime {
777        Some(runtime) => register_mcp_clients(runtime, &detected, entity_root),
778        None => Vec::new(),
779    };
780
781    // Last, so an interrupted download costs nothing already done.
782    let embedder = warm_embedding_model(&memory_dir);
783
784    print_summary(&Summary {
785        memory_dir,
786        provider,
787        capture: agent_cli::capturing(),
788        mcp,
789        embedder,
790    });
791
792    Ok(())
793}
794
795#[cfg(test)]
796mod tests {
797    use super::*;
798    use std::io::Cursor;
799
800    /// Init under `cargo test` runs from `target/debug/deps/…`, which is what
801    /// keeps these tests off the developer's real hooks, MCP configs and
802    /// network. Assert it, so a change in harness layout fails here rather
803    /// than by rewriting someone's settings.json.
804    #[test]
805    fn the_test_binary_is_recognised_as_a_build_directory() {
806        assert!(
807            is_build_dir(&recall_binary()),
808            "test binary should be treated as a build directory: {}",
809            recall_binary()
810        );
811        assert!(!is_build_dir("/usr/local/bin/recall-echo"));
812        assert!(!is_build_dir("/home/d/.cargo/bin/recall-echo"));
813    }
814
815    #[test]
816    fn init_creates_directories_and_files() {
817        let tmp = tempfile::tempdir().unwrap();
818        let root = tmp.path().to_path_buf();
819        let mut reader = Cursor::new(b"skip\n" as &[u8]); // skip provider prompt
820
821        run_with_reader(&root, &mut reader).unwrap();
822
823        assert!(root.join("memory/MEMORY.md").exists());
824        assert!(root.join("memory/EPHEMERAL.md").exists());
825        assert!(root.join("memory/ARCHIVE.md").exists());
826        assert!(root.join("memory/conversations").exists());
827    }
828
829    #[test]
830    fn init_is_idempotent() {
831        let tmp = tempfile::tempdir().unwrap();
832        let root = tmp.path().to_path_buf();
833        let mut reader = Cursor::new(b"skip\n" as &[u8]);
834
835        run_with_reader(&root, &mut reader).unwrap();
836        fs::write(root.join("memory/MEMORY.md"), "custom content").unwrap();
837
838        let mut reader2 = Cursor::new(b"skip\n" as &[u8]);
839        run_with_reader(&root, &mut reader2).unwrap();
840        let content = fs::read_to_string(root.join("memory/MEMORY.md")).unwrap();
841        assert_eq!(content, "custom content");
842    }
843
844    /// A second `init` must not re-run the provider prompt or rewrite the
845    /// config the user has since edited.
846    #[test]
847    fn a_second_init_preserves_the_configured_provider() {
848        let tmp = tempfile::tempdir().unwrap();
849        let root = tmp.path().to_path_buf();
850        let memory_dir = root.join("memory");
851        fs::create_dir_all(&memory_dir).unwrap();
852
853        let chosen = configure_llm(
854            &mut Cursor::new(b"" as &[u8]),
855            &memory_dir,
856            &[AgentCli::Grok],
857        );
858        assert_eq!(chosen, Some(Provider::Grok));
859
860        // Empty reader: a prompt here would take the default and lose grok.
861        let again = configure_llm(
862            &mut Cursor::new(b"" as &[u8]),
863            &memory_dir,
864            &[AgentCli::ClaudeCode, AgentCli::Codex],
865        );
866        assert_eq!(again, Some(Provider::Grok));
867    }
868
869    #[test]
870    fn init_fails_if_root_missing() {
871        let mut reader = Cursor::new(b"" as &[u8]);
872        let result = run_with_reader(Path::new("/nonexistent/path"), &mut reader);
873        assert!(result.is_err());
874    }
875
876    /// One installed CLI is not a choice, so it is not a question — the reader
877    /// is never touched.
878    #[test]
879    fn a_single_installed_cli_is_chosen_without_asking() {
880        let mut reader = Cursor::new(b"" as &[u8]);
881        assert_eq!(
882            select_provider(&mut reader, &[AgentCli::Codex]),
883            Some(Provider::Codex)
884        );
885        assert_eq!(reader.position(), 0, "nothing should have been read");
886    }
887
888    /// Non-interactive (the tests, and any scripted install): pick the default
889    /// rather than block on a prompt nobody can answer.
890    #[test]
891    fn several_installed_clis_default_without_blocking() {
892        let mut reader = Cursor::new(b"" as &[u8]);
893        let chosen = select_provider(&mut reader, &[AgentCli::Grok, AgentCli::Codex]);
894        assert_eq!(chosen, Some(Provider::Grok));
895    }
896
897    #[test]
898    fn the_default_prefers_claude_code_over_install_order() {
899        assert_eq!(
900            default_cli(&[AgentCli::Codex, AgentCli::ClaudeCode]),
901            AgentCli::ClaudeCode
902        );
903        assert_eq!(
904            default_cli(&[AgentCli::Gemini, AgentCli::Grok]),
905            AgentCli::Gemini
906        );
907        assert_eq!(default_cli(&[]), AgentCli::ClaudeCode);
908    }
909
910    #[test]
911    fn no_installed_cli_falls_back_to_the_full_menu() {
912        let mut reader = Cursor::new(b"" as &[u8]);
913        assert_eq!(select_provider(&mut reader, &[]), Some(Provider::Anthropic));
914    }
915
916    #[test]
917    fn the_summary_names_the_cost_of_each_provider() {
918        assert!(extraction_line(&Provider::Grok).contains("no API billing"));
919        assert!(extraction_line(&Provider::Anthropic).contains("ANTHROPIC_API_KEY"));
920        assert!(extraction_line(&Provider::Openai).contains("free"));
921    }
922
923    #[test]
924    fn the_summary_lists_only_the_clients_that_registered() {
925        let summary = Summary {
926            memory_dir: std::path::PathBuf::from("/tmp/memory"),
927            provider: Some(Provider::Grok),
928            capture: vec![Source::Grok],
929            mcp: vec![
930                McpReport {
931                    cli: AgentCli::ClaudeCode,
932                    status: McpStatus::Registered,
933                    command: String::new(),
934                },
935                McpReport {
936                    cli: AgentCli::Grok,
937                    status: McpStatus::AlreadyRegistered,
938                    command: String::new(),
939                },
940                McpReport {
941                    cli: AgentCli::Gemini,
942                    status: McpStatus::Failed("no".into()),
943                    command: String::new(),
944                },
945            ],
946            embedder: WarmOutcome::Ready,
947        };
948        assert_eq!(summary.mcp_ready(), ["claude-code", "grok"]);
949    }
950
951    #[test]
952    fn hook_exists_recognizes_consume_command() {
953        let hooks_json: serde_json::Value = serde_json::json!({
954            "SessionStart": [{
955                "matcher": "startup|resume",
956                "hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo consume"}]
957            }]
958        });
959        let hooks = hooks_json.as_object().unwrap();
960        assert!(hook_exists(hooks, "SessionStart", "recall-echo consume"));
961        assert!(!hook_exists(hooks, "SessionEnd", "recall-echo consume"));
962    }
963
964    #[test]
965    fn hook_exists_distinguishes_archive_from_consume() {
966        let hooks_json: serde_json::Value = serde_json::json!({
967            "SessionEnd": [{
968                "hooks": [{"type": "command", "command": "recall-echo archive-session"}]
969            }]
970        });
971        let hooks = hooks_json.as_object().unwrap();
972        assert!(hook_exists(
973            hooks,
974            "SessionEnd",
975            "recall-echo archive-session"
976        ));
977    }
978
979    #[test]
980    fn archive_template_has_header() {
981        let tmp = tempfile::tempdir().unwrap();
982        let mut reader = Cursor::new(b"skip\n" as &[u8]);
983        run_with_reader(tmp.path(), &mut reader).unwrap();
984        let content = fs::read_to_string(tmp.path().join("memory/ARCHIVE.md")).unwrap();
985        assert!(content.contains("# Conversation Archive"));
986        assert!(content.contains("| # | Date"));
987    }
988}