use std::fs;
use std::io::{self, BufRead, Write as _};
use std::path::Path;
use crate::agent_cli::{self, AgentCli, McpReport, McpStatus};
use crate::config::{self, Config, LlmSection, Provider};
use crate::error::RecallError;
use crate::paths;
use crate::transcript::Source;
const GREEN: &str = "\x1b[32m";
const YELLOW: &str = "\x1b[33m";
const RED: &str = "\x1b[31m";
const BOLD: &str = "\x1b[1m";
const DIM: &str = "\x1b[2m";
const RESET: &str = "\x1b[0m";
const MEMORY_TEMPLATE: &str = "# Memory\n\n\
<!-- recall-echo: Curated memory. Distilled facts, preferences, patterns. -->\n\
<!-- Keep under 200 lines. Only write confirmed, stable information. -->\n";
const ARCHIVE_TEMPLATE: &str = "# Conversation Archive\n\n\
| # | Date | Session | Topics | Messages | Duration |\n\
|---|------|---------|--------|----------|----------|\n";
const MODEL_DOWNLOAD_SIZE: &str = "~127 MB";
enum Status {
Created,
Exists,
Error,
}
fn print_status(status: Status, msg: &str) {
match status {
Status::Created => eprintln!(" {GREEN}✓{RESET} {msg}"),
Status::Exists => eprintln!(" {YELLOW}~{RESET} {msg}"),
Status::Error => eprintln!(" {RED}✗{RESET} {msg}"),
}
}
fn ensure_dir(path: &Path) {
if !path.exists() {
if let Err(e) = fs::create_dir_all(path) {
print_status(
Status::Error,
&format!("Failed to create {}: {e}", path.display()),
);
}
}
}
fn write_if_not_exists(path: &Path, content: &str, label: &str) {
if path.exists() {
print_status(
Status::Exists,
&format!("{label} already exists — preserved"),
);
} else {
match fs::write(path, content) {
Ok(()) => print_status(Status::Created, &format!("Created {label}")),
Err(e) => print_status(Status::Error, &format!("Failed to create {label}: {e}")),
}
}
}
fn select_provider(reader: &mut dyn BufRead, detected: &[AgentCli]) -> Option<Provider> {
match detected {
[only] => {
print_status(
Status::Created,
&format!("found {only} — using it for extraction"),
);
Some(only.provider())
}
[] => {
eprintln!(
"\n {YELLOW}~{RESET} No agent CLI found. Extraction needs a model provider — \
{BOLD}ollama{RESET} is the free, local option."
);
prompt_any_provider(reader)
}
several => prompt_installed_cli(reader, several),
}
}
fn default_cli(detected: &[AgentCli]) -> AgentCli {
let running_under = agent_cli::current().filter(|cli| detected.contains(cli));
running_under
.or_else(|| {
detected
.contains(&AgentCli::ClaudeCode)
.then_some(AgentCli::ClaudeCode)
})
.or_else(|| detected.first().copied())
.unwrap_or(AgentCli::ClaudeCode)
}
fn prompt_installed_cli(reader: &mut dyn BufRead, detected: &[AgentCli]) -> Option<Provider> {
let default = default_cli(detected);
if !atty_check() {
print_status(
Status::Created,
&format!(
"{} agent CLIs found — using {default} for extraction",
detected.len()
),
);
return Some(default.provider());
}
let default_index = detected.iter().position(|cli| *cli == default).unwrap_or(0) + 1;
eprintln!("\n{BOLD}Which CLI should recall-echo use to extract knowledge?{RESET}");
for (index, cli) in detected.iter().enumerate() {
let note = if *cli == default {
if agent_cli::current() == Some(*cli) {
"— you're running under it (default)"
} else {
"— (default)"
}
} else {
""
};
eprintln!(
" {BOLD}{}{RESET}) {:<12}{DIM}{note}{RESET}",
index + 1,
cli.label()
);
}
eprintln!(" {BOLD}o{RESET}) other {DIM}— Claude API, Ollama, or decide later{RESET}");
eprint!("\n Choice [{default_index}]: ");
io::stderr().flush().ok();
let mut input = String::new();
if reader.read_line(&mut input).is_err() {
return Some(default.provider());
}
let answer = input.trim().to_lowercase();
if answer.is_empty() {
return Some(default.provider());
}
if answer == "o" || answer == "other" {
return prompt_any_provider(reader);
}
if let Some(cli) = answer
.parse::<usize>()
.ok()
.and_then(|n| detected.get(n.wrapping_sub(1)))
{
return Some(cli.provider());
}
if let Some(cli) = detected.iter().find(|cli| cli.label() == answer) {
return Some(cli.provider());
}
eprintln!(" {YELLOW}~{RESET} Unknown choice, defaulting to {default}");
Some(default.provider())
}
fn prompt_any_provider(reader: &mut dyn BufRead) -> Option<Provider> {
if !atty_check() {
return Some(Provider::Anthropic);
}
eprintln!("\n{BOLD}LLM provider for entity extraction:{RESET}");
eprintln!(" {BOLD}1{RESET}) anthropic {DIM}— Claude API (default){RESET}");
eprintln!(" {BOLD}2{RESET}) ollama {DIM}— Local models via Ollama, free{RESET}");
eprintln!(
" {BOLD}3{RESET}) claude-code {DIM}— Spawns your `claude` CLI (subscription){RESET}"
);
eprintln!(
" {BOLD}4{RESET}) gemini {DIM}— Spawns your `gemini` CLI (subscription){RESET}"
);
eprintln!(" {BOLD}5{RESET}) grok {DIM}— Spawns your `grok` CLI (subscription){RESET}");
eprintln!(" {BOLD}6{RESET}) codex {DIM}— Spawns your `codex` CLI (subscription){RESET}");
eprintln!(
" {BOLD}7{RESET}) skip {DIM}— Configure later with `recall-echo config`{RESET}"
);
eprint!("\n Choice [1]: ");
io::stderr().flush().ok();
let mut input = String::new();
if reader.read_line(&mut input).is_err() {
return None;
}
match input.trim() {
"" | "1" | "anthropic" => Some(Provider::Anthropic),
"2" | "ollama" => Some(Provider::Openai),
"3" | "claude-code" => Some(Provider::ClaudeCode),
"4" | "gemini" => Some(Provider::Gemini),
"5" | "grok" => Some(Provider::Grok),
"6" | "codex" => Some(Provider::Codex),
"7" | "skip" => None,
_ => {
eprintln!(" {YELLOW}~{RESET} Unknown choice, defaulting to anthropic");
Some(Provider::Anthropic)
}
}
}
fn configure_llm(
reader: &mut dyn BufRead,
memory_dir: &Path,
detected: &[AgentCli],
) -> Option<Provider> {
if config::exists(memory_dir) {
print_status(
Status::Exists,
".recall-echo.toml already exists — preserved",
);
return Some(config::load(memory_dir).llm.provider);
}
let Some(provider) = select_provider(reader, detected) else {
print_status(
Status::Exists,
"Skipped LLM config — run `recall-echo config set provider <name>` later",
);
return None;
};
let cfg = Config {
llm: LlmSection {
provider: provider.clone(),
..LlmSection::default()
},
..Config::default()
};
match config::save(memory_dir, &cfg) {
Ok(()) => {
print_status(
Status::Created,
&format!(
"Created .recall-echo.toml (provider: {})",
label_of(&provider)
),
);
Some(provider)
}
Err(e) => {
print_status(Status::Error, &format!("Failed to write config: {e}"));
None
}
}
}
fn label_of(provider: &Provider) -> String {
match provider {
Provider::Openai => "ollama (openai-compat)".to_string(),
other => other.to_string(),
}
}
fn extraction_line(provider: &Provider) -> String {
match provider {
Provider::Anthropic => "anthropic (Claude API — set ANTHROPIC_API_KEY)".into(),
Provider::Openai => "ollama (local models — free)".into(),
Provider::Cli => "custom CLI (from `[llm.cli]`)".into(),
cli => format!("{cli} (your subscription — no API billing)"),
}
}
fn init_graph(runtime: &tokio::runtime::Runtime, memory_dir: &Path) {
let graph_dir = memory_dir.join("graph");
if graph_dir.exists() {
print_status(Status::Exists, "graph/ already exists — preserved");
return;
}
match runtime.block_on(crate::graph::GraphMemory::open(&graph_dir)) {
Ok(_) => print_status(Status::Created, "Created graph/ (SurrealDB)"),
Err(e) => print_status(Status::Error, &format!("Failed to init graph: {e}")),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum WarmOutcome {
Ready,
Skipped(&'static str),
Failed(String),
}
fn warm_embedding_model(memory_dir: &Path) -> WarmOutcome {
let exe = recall_binary();
if is_build_dir(&exe) {
return WarmOutcome::Skipped("running from a build directory");
}
let models_dir = memory_dir.join("graph").join("models");
if let Err(e) = fs::create_dir_all(&models_dir) {
return WarmOutcome::Failed(format!("could not create {}: {e}", models_dir.display()));
}
let cached = fs::read_dir(&models_dir).is_ok_and(|mut entries| entries.next().is_some());
if cached {
eprintln!(" {DIM}… loading the embedding model{RESET}");
} else {
eprintln!(
" {DIM}… downloading the embedding model ({MODEL_DOWNLOAD_SIZE}, once) — \
everything else is already set up, Ctrl-C is safe{RESET}"
);
}
match crate::graph::embed::FastEmbedder::new(&models_dir) {
Ok(_) => WarmOutcome::Ready,
Err(e) => WarmOutcome::Failed(e.to_string()),
}
}
fn recall_binary() -> String {
std::env::current_exe()
.ok()
.and_then(|p| p.to_str().map(String::from))
.unwrap_or_else(|| "recall-echo".into())
}
fn is_build_dir(exe: &str) -> bool {
exe.contains("/target/debug/") || exe.contains("/target/release/")
}
fn configure_hooks(_entity_root: &Path) -> bool {
let claude_dir = match paths::detect_claude_code() {
Some(dir) => dir,
None => return false,
};
let settings_path = claude_dir.join("settings.json");
let recall_bin = recall_binary();
if is_build_dir(&recall_bin) {
print_status(
Status::Exists,
"Skipped hook install — running from a build directory",
);
return false;
}
let archive_cmd = format!("{recall_bin} archive-session");
let checkpoint_cmd = format!("{recall_bin} checkpoint --trigger precompact");
let consume_cmd = format!("{recall_bin} consume");
let mut settings: serde_json::Value = if settings_path.exists() {
fs::read_to_string(&settings_path)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_else(|| serde_json::json!({}))
} else {
serde_json::json!({})
};
let hooks = settings.as_object_mut().and_then(|o| {
o.entry("hooks")
.or_insert_with(|| serde_json::json!({}))
.as_object_mut()
});
let hooks = match hooks {
Some(h) => h,
None => {
print_status(Status::Error, "Could not parse settings.json hooks");
return false;
}
};
let mut changed = false;
if !hook_exists(hooks, "SessionStart", &consume_cmd) {
let arr = hooks
.entry("SessionStart")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut();
if let Some(arr) = arr {
arr.push(serde_json::json!({
"matcher": "startup|resume",
"hooks": [{"type": "command", "command": consume_cmd}]
}));
changed = true;
}
}
if !hook_exists(hooks, "SessionEnd", &archive_cmd) {
let arr = hooks
.entry("SessionEnd")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut();
if let Some(arr) = arr {
arr.push(serde_json::json!({
"hooks": [{"type": "command", "command": archive_cmd}]
}));
changed = true;
}
}
if !hook_exists(hooks, "PreCompact", &checkpoint_cmd) {
let arr = hooks
.entry("PreCompact")
.or_insert_with(|| serde_json::json!([]))
.as_array_mut();
if let Some(arr) = arr {
arr.push(serde_json::json!({
"hooks": [{"type": "command", "command": checkpoint_cmd}]
}));
changed = true;
}
}
if changed {
match serde_json::to_string_pretty(&settings) {
Ok(content) => match fs::write(&settings_path, content) {
Ok(()) => {
print_status(
Status::Created,
"Configured SessionStart + SessionEnd + PreCompact hooks in settings.json",
);
return true;
}
Err(e) => print_status(
Status::Error,
&format!("Failed to write settings.json: {e}"),
),
},
Err(e) => print_status(Status::Error, &format!("Failed to serialize settings: {e}")),
}
} else {
print_status(Status::Exists, "Hooks already configured in settings.json");
return true;
}
false
}
fn hook_exists(
hooks: &serde_json::Map<String, serde_json::Value>,
event: &str,
command: &str,
) -> bool {
if let Some(arr) = hooks.get(event).and_then(|v| v.as_array()) {
for group in arr {
if let Some(inner) = group.get("hooks").and_then(|h| h.as_array()) {
for hook in inner {
if let Some(cmd) = hook.get("command").and_then(|c| c.as_str()) {
if cmd.contains("recall-echo archive-session")
&& command.contains("archive-session")
{
return true;
}
if cmd.contains("recall-echo checkpoint") && command.contains("checkpoint")
{
return true;
}
if cmd.contains("recall-echo consume") && command.contains("consume") {
return true;
}
}
}
}
}
}
false
}
fn register_mcp_clients(
runtime: &tokio::runtime::Runtime,
detected: &[AgentCli],
entity_root: &Path,
) -> Vec<McpReport> {
if detected.is_empty() {
return Vec::new();
}
let exe = recall_binary();
if is_build_dir(&exe) {
print_status(
Status::Exists,
"Skipped MCP registration — running from a build directory",
);
return Vec::new();
}
let root = fs::canonicalize(entity_root).unwrap_or_else(|_| entity_root.to_path_buf());
let reports: Vec<McpReport> = runtime.block_on(async {
let mut reports = Vec::with_capacity(detected.len());
for cli in detected {
reports.push(agent_cli::register_mcp(*cli, &exe, &root).await);
}
reports
});
for report in &reports {
match &report.status {
McpStatus::Registered => print_status(
Status::Created,
&format!("Registered MCP server with {}", report.cli),
),
McpStatus::AlreadyRegistered => print_status(
Status::Exists,
&format!("MCP server already registered with {}", report.cli),
),
McpStatus::Failed(detail) => {
print_status(
Status::Error,
&format!("Could not register MCP with {}: {detail}", report.cli),
);
eprintln!(" {DIM}run it yourself: {}{RESET}", report.command);
}
}
}
reports
}
struct Summary {
memory_dir: std::path::PathBuf,
provider: Option<Provider>,
capture: Vec<Source>,
mcp: Vec<McpReport>,
embedder: WarmOutcome,
}
impl Summary {
fn mcp_ready(&self) -> Vec<&'static str> {
self.mcp
.iter()
.filter(|report| !matches!(report.status, McpStatus::Failed(_)))
.map(|report| report.cli.label())
.collect()
}
}
fn print_summary(summary: &Summary) {
eprintln!("\n{BOLD}Setup complete.{RESET}\n");
print_status(
Status::Created,
&format!("memory initialised at {}", summary.memory_dir.display()),
);
match &summary.provider {
Some(provider) => print_status(
Status::Created,
&format!("extraction: {}", extraction_line(provider)),
),
None => print_status(
Status::Exists,
"extraction: not configured — `recall-echo config set provider <name>`",
),
}
if summary.capture.is_empty() {
print_status(
Status::Exists,
"capture: no agent CLI has recorded sessions here yet",
);
} else {
let names: Vec<&str> = summary.capture.iter().map(Source::as_str).collect();
print_status(Status::Created, &format!("capture: {}", names.join(", ")));
}
let ready = summary.mcp_ready();
if !ready.is_empty() {
print_status(
Status::Created,
&format!("MCP registered: {}", ready.join(", ")),
);
}
match &summary.embedder {
WarmOutcome::Ready => print_status(Status::Created, "embedding model ready"),
WarmOutcome::Skipped(reason) => print_status(
Status::Exists,
&format!("embedding model not warmed ({reason}) — downloads on first use"),
),
WarmOutcome::Failed(detail) => print_status(
Status::Exists,
&format!("embedding model not downloaded ({detail}) — retries on first use"),
),
}
eprintln!("\n {BOLD}Your next session will be remembered.{RESET}\n");
eprintln!(" {DIM}recall-echo status — is it healthy, what has it got{RESET}");
eprintln!(" {DIM}recall-echo config show — what it decided{RESET}");
eprintln!();
}
fn atty_check() -> bool {
use std::io::IsTerminal;
std::io::stderr().is_terminal()
}
pub fn run(entity_root: &Path) -> Result<(), RecallError> {
let stdin = io::stdin();
let mut reader = stdin.lock();
run_with_reader(entity_root, &mut reader)
}
pub fn run_with_reader(entity_root: &Path, reader: &mut dyn BufRead) -> Result<(), RecallError> {
if !entity_root.exists() {
return Err(RecallError::NotInitialized(format!(
"Directory not found: {}\n Create the directory first, or run from a valid path.",
entity_root.display()
)));
}
eprintln!("\n{BOLD}recall-echo{RESET} — initializing memory system\n");
let memory_dir = entity_root.join("memory");
let conversations_dir = memory_dir.join("conversations");
ensure_dir(&memory_dir);
ensure_dir(&conversations_dir);
write_if_not_exists(&memory_dir.join("MEMORY.md"), MEMORY_TEMPLATE, "MEMORY.md");
write_if_not_exists(&memory_dir.join("EPHEMERAL.md"), "", "EPHEMERAL.md");
write_if_not_exists(
&memory_dir.join("ARCHIVE.md"),
ARCHIVE_TEMPLATE,
"ARCHIVE.md",
);
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build();
let runtime = match runtime {
Ok(runtime) => Some(runtime),
Err(e) => {
print_status(Status::Error, &format!("Failed to start runtime: {e}"));
None
}
};
if let Some(runtime) = &runtime {
init_graph(runtime, &memory_dir);
}
let detected = agent_cli::installed();
let provider = configure_llm(reader, &memory_dir, &detected);
configure_hooks(entity_root);
let mcp = match &runtime {
Some(runtime) => register_mcp_clients(runtime, &detected, entity_root),
None => Vec::new(),
};
let embedder = warm_embedding_model(&memory_dir);
print_summary(&Summary {
memory_dir,
provider,
capture: agent_cli::capturing(),
mcp,
embedder,
});
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
#[test]
fn the_test_binary_is_recognised_as_a_build_directory() {
assert!(
is_build_dir(&recall_binary()),
"test binary should be treated as a build directory: {}",
recall_binary()
);
assert!(!is_build_dir("/usr/local/bin/recall-echo"));
assert!(!is_build_dir("/home/d/.cargo/bin/recall-echo"));
}
#[test]
fn init_creates_directories_and_files() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let mut reader = Cursor::new(b"skip\n" as &[u8]);
run_with_reader(&root, &mut reader).unwrap();
assert!(root.join("memory/MEMORY.md").exists());
assert!(root.join("memory/EPHEMERAL.md").exists());
assert!(root.join("memory/ARCHIVE.md").exists());
assert!(root.join("memory/conversations").exists());
}
#[test]
fn init_is_idempotent() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let mut reader = Cursor::new(b"skip\n" as &[u8]);
run_with_reader(&root, &mut reader).unwrap();
fs::write(root.join("memory/MEMORY.md"), "custom content").unwrap();
let mut reader2 = Cursor::new(b"skip\n" as &[u8]);
run_with_reader(&root, &mut reader2).unwrap();
let content = fs::read_to_string(root.join("memory/MEMORY.md")).unwrap();
assert_eq!(content, "custom content");
}
#[test]
fn a_second_init_preserves_the_configured_provider() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path().to_path_buf();
let memory_dir = root.join("memory");
fs::create_dir_all(&memory_dir).unwrap();
let chosen = configure_llm(
&mut Cursor::new(b"" as &[u8]),
&memory_dir,
&[AgentCli::Grok],
);
assert_eq!(chosen, Some(Provider::Grok));
let again = configure_llm(
&mut Cursor::new(b"" as &[u8]),
&memory_dir,
&[AgentCli::ClaudeCode, AgentCli::Codex],
);
assert_eq!(again, Some(Provider::Grok));
}
#[test]
fn init_fails_if_root_missing() {
let mut reader = Cursor::new(b"" as &[u8]);
let result = run_with_reader(Path::new("/nonexistent/path"), &mut reader);
assert!(result.is_err());
}
#[test]
fn a_single_installed_cli_is_chosen_without_asking() {
let mut reader = Cursor::new(b"" as &[u8]);
assert_eq!(
select_provider(&mut reader, &[AgentCli::Codex]),
Some(Provider::Codex)
);
assert_eq!(reader.position(), 0, "nothing should have been read");
}
#[test]
fn several_installed_clis_default_without_blocking() {
let mut reader = Cursor::new(b"" as &[u8]);
let chosen = select_provider(&mut reader, &[AgentCli::Grok, AgentCli::Codex]);
assert_eq!(chosen, Some(Provider::Grok));
}
#[test]
fn the_default_prefers_claude_code_over_install_order() {
assert_eq!(
default_cli(&[AgentCli::Codex, AgentCli::ClaudeCode]),
AgentCli::ClaudeCode
);
assert_eq!(
default_cli(&[AgentCli::Gemini, AgentCli::Grok]),
AgentCli::Gemini
);
assert_eq!(default_cli(&[]), AgentCli::ClaudeCode);
}
#[test]
fn no_installed_cli_falls_back_to_the_full_menu() {
let mut reader = Cursor::new(b"" as &[u8]);
assert_eq!(select_provider(&mut reader, &[]), Some(Provider::Anthropic));
}
#[test]
fn the_summary_names_the_cost_of_each_provider() {
assert!(extraction_line(&Provider::Grok).contains("no API billing"));
assert!(extraction_line(&Provider::Anthropic).contains("ANTHROPIC_API_KEY"));
assert!(extraction_line(&Provider::Openai).contains("free"));
}
#[test]
fn the_summary_lists_only_the_clients_that_registered() {
let summary = Summary {
memory_dir: std::path::PathBuf::from("/tmp/memory"),
provider: Some(Provider::Grok),
capture: vec![Source::Grok],
mcp: vec![
McpReport {
cli: AgentCli::ClaudeCode,
status: McpStatus::Registered,
command: String::new(),
},
McpReport {
cli: AgentCli::Grok,
status: McpStatus::AlreadyRegistered,
command: String::new(),
},
McpReport {
cli: AgentCli::Gemini,
status: McpStatus::Failed("no".into()),
command: String::new(),
},
],
embedder: WarmOutcome::Ready,
};
assert_eq!(summary.mcp_ready(), ["claude-code", "grok"]);
}
#[test]
fn hook_exists_recognizes_consume_command() {
let hooks_json: serde_json::Value = serde_json::json!({
"SessionStart": [{
"matcher": "startup|resume",
"hooks": [{"type": "command", "command": "/usr/local/bin/recall-echo consume"}]
}]
});
let hooks = hooks_json.as_object().unwrap();
assert!(hook_exists(hooks, "SessionStart", "recall-echo consume"));
assert!(!hook_exists(hooks, "SessionEnd", "recall-echo consume"));
}
#[test]
fn hook_exists_distinguishes_archive_from_consume() {
let hooks_json: serde_json::Value = serde_json::json!({
"SessionEnd": [{
"hooks": [{"type": "command", "command": "recall-echo archive-session"}]
}]
});
let hooks = hooks_json.as_object().unwrap();
assert!(hook_exists(
hooks,
"SessionEnd",
"recall-echo archive-session"
));
}
#[test]
fn archive_template_has_header() {
let tmp = tempfile::tempdir().unwrap();
let mut reader = Cursor::new(b"skip\n" as &[u8]);
run_with_reader(tmp.path(), &mut reader).unwrap();
let content = fs::read_to_string(tmp.path().join("memory/ARCHIVE.md")).unwrap();
assert!(content.contains("# Conversation Archive"));
assert!(content.contains("| # | Date"));
}
}