1use 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
13const 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
68fn prompt_provider(reader: &mut dyn BufRead) -> Option<Provider> {
71 if !atty_check() {
73 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
132fn 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 let cfg = config::load(memory_dir);
173 return cfg.llm.provider == Provider::ClaudeCode;
174 }
175 false
176}
177
178#[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
196fn 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 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 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 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 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
297fn 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 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
326fn 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
341pub 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
358pub 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_if_not_exists(&memory_dir.join("MEMORY.md"), MEMORY_TEMPLATE, "MEMORY.md");
376
377 write_if_not_exists(&memory_dir.join("EPHEMERAL.md"), "", "EPHEMERAL.md");
379
380 write_if_not_exists(
382 &memory_dir.join("ARCHIVE.md"),
383 ARCHIVE_TEMPLATE,
384 "ARCHIVE.md",
385 );
386
387 #[cfg(feature = "graph")]
389 init_graph(&memory_dir);
390
391 let is_claude_code = configure_llm(reader, &memory_dir);
393
394 let hooks_configured = if is_claude_code {
396 configure_hooks(entity_root)
397 } else {
398 false
399 };
400
401 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]); 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}