1use 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
14const 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
69fn prompt_provider(reader: &mut dyn BufRead) -> Option<Provider> {
72 if !atty_check() {
74 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
130fn 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 let cfg = config::load(memory_dir);
171 return cfg.llm.provider == Provider::ClaudeCode;
172 }
173 false
174}
175
176fn 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
199fn 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 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, "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 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 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
315fn 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 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
347fn atty_check() -> bool {
349 use std::io::IsTerminal;
350 std::io::stderr().is_terminal()
351}
352
353pub 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
370pub 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_if_not_exists(&memory_dir.join("MEMORY.md"), MEMORY_TEMPLATE, "MEMORY.md");
388
389 write_if_not_exists(&memory_dir.join("EPHEMERAL.md"), "", "EPHEMERAL.md");
391
392 write_if_not_exists(
394 &memory_dir.join("ARCHIVE.md"),
395 ARCHIVE_TEMPLATE,
396 "ARCHIVE.md",
397 );
398
399 init_graph(&memory_dir);
401
402 let is_claude_code = configure_llm(reader, &memory_dir);
404
405 let hooks_configured = if is_claude_code {
407 configure_hooks(entity_root)
408 } else {
409 false
410 };
411
412 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]); 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}