1use std::path::{Path, PathBuf};
4
5use crate::error::RecallError;
6use crate::graph::traverse::format_traversal;
7use crate::graph::types::*;
8use crate::graph::GraphMemory;
9
10const GREEN: &str = "\x1b[32m";
11const CYAN: &str = "\x1b[36m";
12const YELLOW: &str = "\x1b[33m";
13const BOLD: &str = "\x1b[1m";
14const DIM: &str = "\x1b[2m";
15const RESET: &str = "\x1b[0m";
16
17pub async fn init(memory_dir: &Path) -> Result<(), RecallError> {
19 let graph_dir = memory_dir.join("graph");
20 GraphMemory::open(&graph_dir).await?;
21 println!(
22 "{GREEN}✓{RESET} Graph store initialized at {}",
23 graph_dir.display()
24 );
25 Ok(())
26}
27
28pub async fn graph_status(memory_dir: &Path) -> Result<(), RecallError> {
30 let graph_dir = memory_dir.join("graph");
31 if !graph_dir.exists() {
32 return Err(RecallError::NotInitialized(
33 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
34 ));
35 }
36 let gm = GraphMemory::open(&graph_dir).await?;
37 let stats = gm.stats().await?;
38
39 println!("{BOLD}Graph Memory Status{RESET}");
40 println!(" Entities: {}", stats.entity_count);
41 println!(" Relationships: {}", stats.relationship_count);
42 println!(" Episodes: {}", stats.episode_count);
43
44 if !stats.entity_type_counts.is_empty() {
45 println!("\n {DIM}By type:{RESET}");
46 let mut types: Vec<_> = stats.entity_type_counts.iter().collect();
47 types.sort_by(|a, b| b.1.cmp(a.1));
48 for (t, count) in types {
49 println!(" {t}: {count}");
50 }
51 }
52 Ok(())
53}
54
55pub async fn add_entity(
57 memory_dir: &Path,
58 name: &str,
59 entity_type: &str,
60 abstract_text: &str,
61 overview: Option<&str>,
62 source: Option<&str>,
63) -> Result<(), RecallError> {
64 let graph_dir = memory_dir.join("graph");
65 let et: EntityType = entity_type
66 .parse()
67 .map_err(|e: String| RecallError::Other(e))?;
68
69 let gm = GraphMemory::open(&graph_dir).await?;
70
71 let entity = gm
72 .add_entity(NewEntity {
73 name: name.to_string(),
74 entity_type: et,
75 abstract_text: abstract_text.to_string(),
76 overview: overview.map(String::from),
77 content: None,
78 attributes: None,
79 source: source.map(String::from),
80 })
81 .await?;
82
83 println!(
84 "{GREEN}✓{RESET} Created entity: {BOLD}{}{RESET} ({}) [{}]",
85 entity.name,
86 entity.entity_type,
87 entity.id_string()
88 );
89 Ok(())
90}
91
92pub async fn relate(
94 memory_dir: &Path,
95 from: &str,
96 rel_type: &str,
97 to: &str,
98 description: Option<&str>,
99 source: Option<&str>,
100) -> Result<(), RecallError> {
101 let graph_dir = memory_dir.join("graph");
102 let gm = GraphMemory::open(&graph_dir).await?;
103
104 let rel = gm
105 .add_relationship(NewRelationship {
106 from_entity: from.to_string(),
107 to_entity: to.to_string(),
108 rel_type: rel_type.to_string(),
109 description: description.map(String::from),
110 confidence: None,
111 source: source.map(String::from),
112 })
113 .await?;
114
115 println!(
116 "{GREEN}✓{RESET} {from} {CYAN}—[{rel_type}]→{RESET} {to} [{}]",
117 rel.id_string()
118 );
119 Ok(())
120}
121
122pub async fn search(
124 memory_dir: &Path,
125 query: &str,
126 limit: usize,
127 entity_type: Option<&str>,
128 keyword: Option<&str>,
129) -> Result<(), RecallError> {
130 let graph_dir = memory_dir.join("graph");
131 let gm = GraphMemory::open(&graph_dir).await?;
132
133 let options = SearchOptions {
134 limit,
135 entity_type: entity_type.map(String::from),
136 keyword: keyword.map(String::from),
137 };
138
139 let results = gm.search_with_options(query, &options).await?;
140
141 if results.is_empty() {
142 println!("{YELLOW}No results.{RESET}");
143 return Ok(());
144 }
145
146 for (i, r) in results.iter().enumerate() {
147 println!(
148 "{BOLD}{}. {}{RESET} ({}) — score: {:.3}",
149 i + 1,
150 r.entity.name,
151 r.entity.entity_type,
152 r.score
153 );
154 println!(" {DIM}{}{RESET}", r.entity.abstract_text);
155 }
156 Ok(())
157}
158
159pub async fn ingest(memory_dir: &Path, archive_path: &Path) -> Result<(), RecallError> {
161 let graph_dir = memory_dir.join("graph");
162 if !graph_dir.exists() {
163 return Err(RecallError::NotInitialized(
164 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
165 ));
166 }
167
168 let content = std::fs::read_to_string(archive_path)?;
169
170 let (session_id, log_number) = extract_archive_metadata(&content, archive_path);
172
173 let gm = GraphMemory::open(&graph_dir).await?;
174
175 let report = gm
176 .ingest_archive(&content, &session_id, log_number, None)
177 .await?;
178
179 println!(
180 "{GREEN}✓{RESET} Ingested {}: {} episodes created",
181 archive_path.display(),
182 report.episodes_created
183 );
184 if !report.errors.is_empty() {
185 for err in &report.errors {
186 println!(" {YELLOW}warning:{RESET} {err}");
187 }
188 }
189 Ok(())
190}
191
192pub async fn ingest_all(memory_dir: &Path) -> Result<(), RecallError> {
194 let graph_dir = memory_dir.join("graph");
195 if !graph_dir.exists() {
196 return Err(RecallError::NotInitialized(
197 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
198 ));
199 }
200
201 let conversations_dir = find_conversations_dir(memory_dir)?;
202
203 let mut files: Vec<_> = std::fs::read_dir(&conversations_dir)?
205 .filter_map(|e| e.ok())
206 .filter(|e| {
207 let name = e.file_name().to_string_lossy().to_string();
208 name.starts_with("conversation-") || name.starts_with("archive-log-")
209 })
210 .collect();
211 files.sort_by_key(|e| e.file_name());
212
213 if files.is_empty() {
214 println!("{YELLOW}No conversation archives found.{RESET}");
215 return Ok(());
216 }
217
218 let gm = GraphMemory::open(&graph_dir).await?;
219
220 let mut total_episodes = 0u32;
221 let mut ingested = 0u32;
222 let mut skipped = 0u32;
223
224 for entry in &files {
225 let path = entry.path();
226 let content = std::fs::read_to_string(&path)?;
227
228 let (session_id, log_number) = extract_archive_metadata(&content, &path);
229
230 if let Some(ln) = log_number {
232 if let Ok(Some(_)) = gm.get_episode_by_log_number(ln).await {
233 skipped += 1;
234 continue;
235 }
236 }
237
238 let report = gm
239 .ingest_archive(&content, &session_id, log_number, None)
240 .await?;
241
242 total_episodes += report.episodes_created;
243 ingested += 1;
244
245 println!(
246 " {GREEN}✓{RESET} {} — {} episodes",
247 path.file_name().unwrap_or_default().to_string_lossy(),
248 report.episodes_created
249 );
250 }
251
252 println!(
253 "\n{GREEN}✓{RESET} Ingested {ingested} archives ({total_episodes} episodes), skipped {skipped} already ingested"
254 );
255 Ok(())
256}
257
258fn extract_archive_metadata(content: &str, path: &Path) -> (String, Option<u32>) {
260 let mut session_id = "unknown".to_string();
261 let mut log_number: Option<u32> = None;
262
263 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
265 let num_str = name
266 .strip_prefix("conversation-")
267 .or_else(|| name.strip_prefix("archive-log-"));
268 if let Some(num_str) = num_str {
269 if let Ok(n) = num_str.parse::<u32>() {
270 log_number = Some(n);
271 }
272 }
273 }
274
275 if let Some(stripped) = content.strip_prefix("---") {
277 if let Some(end) = stripped.find("---") {
278 let frontmatter = &stripped[..end];
279 for line in frontmatter.lines() {
280 let line = line.trim();
281 if let Some(val) = line.strip_prefix("session_id:") {
282 session_id = val.trim().trim_matches('"').to_string();
283 }
284 }
285 }
286 }
287
288 (session_id, log_number)
289}
290
291pub async fn traverse(
293 memory_dir: &Path,
294 entity_name: &str,
295 depth: u32,
296 type_filter: Option<&str>,
297) -> Result<(), RecallError> {
298 let graph_dir = memory_dir.join("graph");
299 let gm = GraphMemory::open(&graph_dir).await?;
300
301 let tree = gm
302 .traverse_filtered(entity_name, depth, type_filter)
303 .await?;
304
305 let output = format_traversal(&tree, 0);
306 print!("{output}");
307 Ok(())
308}
309
310pub async fn hybrid_query(
312 memory_dir: &Path,
313 query: &str,
314 limit: usize,
315 entity_type: Option<&str>,
316 keyword: Option<&str>,
317 depth: u32,
318 episodes: bool,
319) -> Result<(), RecallError> {
320 let graph_dir = memory_dir.join("graph");
321 let gm = GraphMemory::open(&graph_dir).await?;
322
323 let options = QueryOptions {
324 limit,
325 entity_type: entity_type.map(String::from),
326 keyword: keyword.map(String::from),
327 graph_depth: depth,
328 include_episodes: episodes,
329 };
330
331 let result = gm.query(query, &options).await?;
332
333 if result.entities.is_empty() && result.episodes.is_empty() {
334 println!("{YELLOW}No results.{RESET}");
335 return Ok(());
336 }
337
338 if !result.entities.is_empty() {
339 println!("{BOLD}Entities:{RESET}");
340 for (i, r) in result.entities.iter().enumerate() {
341 let source_tag = match &r.source {
342 MatchSource::Semantic => "semantic".to_string(),
343 MatchSource::Graph { parent, rel_type } => {
344 format!("graph: {parent} —[{rel_type}]")
345 }
346 MatchSource::Keyword => "keyword".to_string(),
347 };
348 println!(
349 " {BOLD}{}. {}{RESET} ({}) — {:.3} [{DIM}{source_tag}{RESET}]",
350 i + 1,
351 r.entity.name,
352 r.entity.entity_type,
353 r.score
354 );
355 println!(" {DIM}{}{RESET}", r.entity.abstract_text);
356 }
357 }
358
359 if !result.episodes.is_empty() {
360 println!("\n{BOLD}Episodes:{RESET}");
361 for (i, ep) in result.episodes.iter().enumerate() {
362 let log = ep
363 .episode
364 .log_number
365 .map(|n| format!("#{n}"))
366 .unwrap_or_default();
367 println!(
368 " {BOLD}{}. {}{RESET} ({}) — {:.3}",
369 i + 1,
370 ep.episode.session_id,
371 log,
372 ep.score
373 );
374 println!(" {DIM}{}{RESET}", ep.episode.abstract_text);
375 }
376 }
377
378 Ok(())
379}
380
381#[cfg(feature = "llm")]
383#[derive(Default)]
384struct ExtractionTotals {
385 entities_created: u32,
386 entities_merged: u32,
387 entities_skipped: u32,
388 relationships: u32,
389 errors: Vec<String>,
390 processed: u32,
391 estimated_tokens: u64,
392 quarantined: Vec<u32>,
393}
394
395#[cfg(feature = "llm")]
397fn print_extract_dry_run(conversations_dir: &Path, log_numbers: &[u32]) {
398 println!(
399 "{BOLD}Dry run — {}{RESET} archives to extract",
400 log_numbers.len()
401 );
402 for ln in log_numbers {
403 let path = find_archive_file(conversations_dir, *ln);
404 let label = match &path {
405 Ok(p) => p
406 .file_name()
407 .unwrap_or_default()
408 .to_string_lossy()
409 .to_string(),
410 Err(_) => format!("log {ln:03} (file not found)"),
411 };
412 println!(" {label}");
413 }
414}
415
416#[cfg(feature = "llm")]
418fn print_extract_summary(totals: &ExtractionTotals) {
419 println!(
420 "\n{GREEN}✓{RESET} Done: {} archives — +{} created, ~{} merged, -{} skipped, {} relationships",
421 totals.processed,
422 totals.entities_created,
423 totals.entities_merged,
424 totals.entities_skipped,
425 totals.relationships,
426 );
427 println!(
428 " Estimated tokens: ~{}",
429 format_tokens(totals.estimated_tokens)
430 );
431
432 if !totals.quarantined.is_empty() {
433 println!(
434 " {YELLOW}Quarantined: {} archives{RESET}",
435 totals.quarantined.len()
436 );
437 }
438
439 if !totals.errors.is_empty() {
440 println!("\n{YELLOW}Warnings ({}):{RESET}", totals.errors.len());
441 for err in totals.errors.iter().take(10) {
442 println!(" {DIM}{err}{RESET}");
443 }
444 if totals.errors.len() > 10 {
445 println!(" {DIM}... and {} more{RESET}", totals.errors.len() - 10);
446 }
447 }
448}
449
450#[cfg(feature = "llm")]
452fn format_tokens(tokens: u64) -> String {
453 if tokens >= 1_000_000 {
454 format!("{:.1}M", tokens as f64 / 1_000_000.0)
455 } else if tokens >= 1_000 {
456 format!("{:.0}K", tokens as f64 / 1_000.0)
457 } else {
458 tokens.to_string()
459 }
460}
461
462#[cfg(feature = "llm")]
464#[allow(clippy::too_many_arguments)]
465pub async fn extract(
466 memory_dir: &Path,
467 log: Option<u32>,
468 all: bool,
469 dry_run: bool,
470 model_override: Option<String>,
471 provider_override: Option<String>,
472 delay_ms: u64,
473 max_tokens: u64,
474) -> Result<(), RecallError> {
475 let graph_dir = memory_dir.join("graph");
476 if !graph_dir.exists() {
477 return Err(RecallError::NotInitialized(
478 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
479 ));
480 }
481
482 let gm = GraphMemory::open(&graph_dir).await?;
483
484 let log_numbers: Vec<u32> = if let Some(ln) = log {
486 vec![ln]
487 } else if all {
488 gm.unextracted_log_numbers()
489 .await?
490 .into_iter()
491 .map(|n| n as u32)
492 .collect()
493 } else {
494 return Err(RecallError::Other("Specify --log <N> or --all".into()));
495 };
496
497 if log_numbers.is_empty() {
498 println!("{YELLOW}No unextracted archives found.{RESET}");
499 return Ok(());
500 }
501
502 let conversations_dir = find_conversations_dir(memory_dir)?;
503
504 if dry_run {
505 print_extract_dry_run(&conversations_dir, &log_numbers);
506 return Ok(());
507 }
508
509 let (llm, model_name) = crate::llm_provider::create_provider(
511 memory_dir,
512 provider_override.as_deref(),
513 model_override.as_deref(),
514 )?;
515
516 let total_count = log_numbers.len();
517 let budget_label = if max_tokens > 0 {
518 format!(" (budget: {})", format_tokens(max_tokens))
519 } else {
520 String::new()
521 };
522 println!(
523 "{BOLD}Extracting entities from {total_count} archives using {model_name}{budget_label}{RESET}",
524 );
525
526 let quarantine_path = graph_dir.join("extraction-quarantine.txt");
527 let mut totals = ExtractionTotals::default();
528
529 for (idx, ln) in log_numbers.iter().enumerate() {
530 if max_tokens > 0 && totals.estimated_tokens >= max_tokens {
532 println!(
533 "\n{YELLOW}⚠ Token budget exhausted (~{} / {}). Stopping.{RESET}",
534 format_tokens(totals.estimated_tokens),
535 format_tokens(max_tokens),
536 );
537 println!(" Re-run to continue — resume is automatic via unextracted log numbers.");
538 break;
539 }
540
541 let archive_path = match find_archive_file(&conversations_dir, *ln) {
542 Ok(p) => p,
543 Err(e) => {
544 println!(
545 " {YELLOW}⚠{RESET} [{}/{}] log {ln:03}: {e}",
546 idx + 1,
547 total_count
548 );
549 totals.errors.push(format!("log {ln:03}: {e}"));
550 continue;
551 }
552 };
553
554 let content = std::fs::read_to_string(&archive_path)?;
555 let (session_id, _) = extract_archive_metadata(&content, &archive_path);
556
557 let report = match gm
559 .extract_from_archive(&content, &session_id, Some(*ln), &*llm)
560 .await
561 {
562 Ok(r) => r,
563 Err(e) => {
564 println!(
565 " {YELLOW}⚠{RESET} [{}/{}] log {ln:03}: failed, retrying... ({e})",
566 idx + 1,
567 total_count
568 );
569 match gm
570 .extract_from_archive(&content, &session_id, Some(*ln), &*llm)
571 .await
572 {
573 Ok(r) => r,
574 Err(e2) => {
575 println!(
576 " {YELLOW}✗{RESET} [{}/{}] log {ln:03}: quarantined ({e2})",
577 idx + 1,
578 total_count
579 );
580 totals.quarantined.push(*ln);
581 totals
582 .errors
583 .push(format!("log {ln:03}: quarantined after retry: {e2}"));
584 continue;
585 }
586 }
587 }
588 };
589
590 println!(
591 " {GREEN}✓{RESET} [{}/{}] log {ln:03}: +{} entities, ~{} merged, -{} skipped, {} rels (~{})",
592 idx + 1,
593 total_count,
594 report.entities_created,
595 report.entities_merged,
596 report.entities_skipped,
597 report.relationships_created,
598 format_tokens(report.estimated_tokens),
599 );
600
601 gm.mark_extracted(*ln).await?;
602
603 totals.entities_created += report.entities_created;
604 totals.entities_merged += report.entities_merged;
605 totals.entities_skipped += report.entities_skipped;
606 totals.relationships += report.relationships_created;
607 totals.errors.extend(report.errors);
608 totals.processed += 1;
609 totals.estimated_tokens += report.estimated_tokens;
610
611 if delay_ms > 0 && *ln != *log_numbers.last().unwrap() {
613 tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
614 }
615 }
616
617 if !totals.quarantined.is_empty() {
619 use std::io::Write;
620 let mut file = std::fs::OpenOptions::new()
621 .create(true)
622 .append(true)
623 .open(&quarantine_path)?;
624 for ln in &totals.quarantined {
625 writeln!(file, "{ln:03}")?;
626 }
627 println!(
628 "\n {YELLOW}Quarantined {} archives → {}{RESET}",
629 totals.quarantined.len(),
630 quarantine_path.display()
631 );
632 }
633
634 print_extract_summary(&totals);
635 Ok(())
636}
637
638pub async fn vigil_sync(
642 memory_dir: &Path,
643 signals_path: Option<&Path>,
644 outcomes_path: Option<&Path>,
645) -> Result<(), RecallError> {
646 let graph_dir = memory_dir.join("graph");
647 if !graph_dir.exists() {
648 return Err(RecallError::NotInitialized(
649 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
650 ));
651 }
652
653 let entity_root = memory_dir.parent().unwrap_or(memory_dir);
655
656 let default_signals = entity_root.join("vigil").join("signals.json");
657 let default_outcomes = entity_root.join("caliber").join("outcomes.json");
658
659 let sig_path = signals_path.unwrap_or(&default_signals);
660 let out_path = outcomes_path.unwrap_or(&default_outcomes);
661
662 let gm = GraphMemory::open(&graph_dir).await?;
663
664 let report = gm.sync_vigil(sig_path, out_path).await?;
665
666 println!("{BOLD}Vigil Sync{RESET}");
667 println!(" Measurements: +{}", report.measurements_created);
668 println!(" Outcomes: +{}", report.outcomes_created);
669 println!(" Relationships: +{}", report.relationships_created);
670 println!(" Skipped: {}", report.skipped);
671
672 if !report.errors.is_empty() {
673 println!("\n {YELLOW}Warnings:{RESET}");
674 for err in &report.errors {
675 println!(" {DIM}{err}{RESET}");
676 }
677 }
678
679 if report.measurements_created == 0 && report.outcomes_created == 0 {
680 println!("\n {DIM}No new data — graph is in sync.{RESET}");
681 }
682
683 Ok(())
684}
685
686pub async fn pipeline_sync(
690 memory_dir: &Path,
691 docs_dir_override: Option<&Path>,
692) -> Result<(), RecallError> {
693 let graph_dir = memory_dir.join("graph");
694 if !graph_dir.exists() {
695 return Err(RecallError::NotInitialized(
696 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
697 ));
698 }
699
700 let docs_dir = if let Some(d) = docs_dir_override {
702 d.to_path_buf()
703 } else {
704 let cfg = crate::config::load_from_dir(memory_dir);
705 match cfg.pipeline.and_then(|p| p.docs_dir) {
706 Some(d) => {
707 let path = PathBuf::from(shellexpand(&d));
708 if !path.exists() {
709 return Err(RecallError::Config(format!(
710 "Configured docs_dir does not exist: {}",
711 path.display()
712 )));
713 }
714 path
715 }
716 None => {
717 return Err(
718 "No docs directory specified. Use --docs-dir or set [pipeline] docs_dir in config.".into(),
719 );
720 }
721 }
722 };
723
724 let docs = read_pipeline_docs(&docs_dir)?;
726
727 let gm = GraphMemory::open(&graph_dir).await?;
728
729 let report = gm.sync_pipeline(&docs).await?;
730
731 println!("{BOLD}Pipeline Sync{RESET}");
732 println!(" Created: {}", report.entities_created);
733 println!(" Updated: {}", report.entities_updated);
734 println!(" Archived: {}", report.entities_archived);
735 println!(
736 " Relationships: +{} / ~{} skipped",
737 report.relationships_created, report.relationships_skipped
738 );
739
740 if !report.errors.is_empty() {
741 println!("\n {YELLOW}Warnings:{RESET}");
742 for err in &report.errors {
743 println!(" {DIM}{err}{RESET}");
744 }
745 }
746
747 if report.entities_created == 0 && report.entities_updated == 0 && report.entities_archived == 0
748 {
749 println!("\n {DIM}No changes — graph is in sync.{RESET}");
750 }
751
752 Ok(())
753}
754
755pub async fn pipeline_status(memory_dir: &Path, staleness_days: u32) -> Result<(), RecallError> {
757 let graph_dir = memory_dir.join("graph");
758 if !graph_dir.exists() {
759 return Err(RecallError::NotInitialized(
760 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
761 ));
762 }
763
764 let gm = GraphMemory::open(&graph_dir).await?;
765
766 let stats = gm.pipeline_stats(staleness_days).await?;
767
768 println!(
769 "{BOLD}Pipeline Status{RESET} ({} entities)",
770 stats.total_entities
771 );
772
773 if stats.by_stage.is_empty() {
774 println!(" {DIM}No pipeline entities in graph. Run `graph pipeline sync` first.{RESET}");
775 return Ok(());
776 }
777
778 let stage_order = ["learning", "thoughts", "curiosity", "reflections", "praxis"];
780 for stage in &stage_order {
781 if let Some(statuses) = stats.by_stage.get(*stage) {
782 println!("\n {CYAN}{}{RESET}", stage.to_uppercase());
783 let mut items: Vec<_> = statuses.iter().collect();
784 items.sort_by_key(|(s, _)| (*s).clone());
785 for (status, count) in items {
786 println!(" {status}: {count}");
787 }
788 }
789 }
790
791 if !stats.stale_thoughts.is_empty() {
792 println!("\n {YELLOW}Stale thoughts (>{staleness_days}d):{RESET}");
793 for entity in &stats.stale_thoughts {
794 println!(" {DIM}•{RESET} {}", entity.name);
795 }
796 }
797
798 if !stats.stale_questions.is_empty() {
799 println!(
800 "\n {YELLOW}Stale questions (>{}d):{RESET}",
801 staleness_days * 2
802 );
803 for entity in &stats.stale_questions {
804 println!(" {DIM}•{RESET} {}", entity.name);
805 }
806 }
807
808 if let Some(ref last) = stats.last_movement {
809 println!("\n {DIM}Last movement: {last}{RESET}");
810 }
811
812 Ok(())
813}
814
815pub async fn pipeline_flow(memory_dir: &Path, entity_name: &str) -> Result<(), RecallError> {
817 let graph_dir = memory_dir.join("graph");
818 if !graph_dir.exists() {
819 return Err(RecallError::NotInitialized(
820 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
821 ));
822 }
823
824 let gm = GraphMemory::open(&graph_dir).await?;
825
826 let chain = gm.pipeline_flow(entity_name).await?;
827
828 if chain.is_empty() {
829 println!("{YELLOW}No pipeline relationships found for \"{entity_name}\".{RESET}");
830 return Ok(());
831 }
832
833 println!("{BOLD}Pipeline Flow: {entity_name}{RESET}\n");
834 for (source, rel_type, target) in &chain {
835 println!(
836 " {} ({}) {CYAN}—[{rel_type}]→{RESET} {} ({})",
837 source.name, source.entity_type, target.name, target.entity_type
838 );
839 }
840
841 Ok(())
842}
843
844pub async fn pipeline_stale(memory_dir: &Path, staleness_days: u32) -> Result<(), RecallError> {
846 let graph_dir = memory_dir.join("graph");
847 if !graph_dir.exists() {
848 return Err(RecallError::NotInitialized(
849 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
850 ));
851 }
852
853 let gm = GraphMemory::open(&graph_dir).await?;
854
855 let stats = gm.pipeline_stats(staleness_days).await?;
856
857 let total_stale = stats.stale_thoughts.len() + stats.stale_questions.len();
858 if total_stale == 0 {
859 println!("{GREEN}✓{RESET} No stale pipeline entities.");
860 return Ok(());
861 }
862
863 println!("{BOLD}Stale Pipeline Entities{RESET}\n");
864
865 if !stats.stale_thoughts.is_empty() {
866 println!(" {YELLOW}Thoughts (>{staleness_days} days):{RESET}");
867 for entity in &stats.stale_thoughts {
868 println!(" • {} {DIM}({}){RESET}", entity.name, entity.entity_type);
869 }
870 }
871
872 if !stats.stale_questions.is_empty() {
873 println!(" {YELLOW}Questions (>{} days):{RESET}", staleness_days * 2);
874 for entity in &stats.stale_questions {
875 println!(" • {} {DIM}({}){RESET}", entity.name, entity.entity_type);
876 }
877 }
878
879 Ok(())
880}
881
882fn read_pipeline_docs(dir: &Path) -> Result<PipelineDocuments, RecallError> {
884 let read_or_empty = |name: &str| -> String {
885 let path = dir.join(name);
886 std::fs::read_to_string(&path).unwrap_or_default()
887 };
888
889 Ok(PipelineDocuments {
890 learning: read_or_empty("LEARNING.md"),
891 thoughts: read_or_empty("THOUGHTS.md"),
892 curiosity: read_or_empty("CURIOSITY.md"),
893 reflections: read_or_empty("REFLECTIONS.md"),
894 praxis: read_or_empty("PRAXIS.md"),
895 })
896}
897
898fn shellexpand(path: &str) -> String {
900 if let Some(rest) = path.strip_prefix("~/") {
901 if let Some(home) = dirs::home_dir() {
902 return home.join(rest).to_string_lossy().to_string();
903 }
904 }
905 path.to_string()
906}
907
908fn find_conversations_dir(memory_dir: &Path) -> Result<PathBuf, RecallError> {
910 let conv = memory_dir.join("conversations");
911 if conv.exists() {
912 return Ok(conv);
913 }
914 if let Some(parent) = memory_dir.parent() {
915 let parent_conv = parent.join("conversations");
916 if parent_conv.exists() {
917 return Ok(parent_conv);
918 }
919 }
920 Err(RecallError::NotInitialized(
921 "conversations/ directory not found".into(),
922 ))
923}
924
925#[allow(clippy::too_many_arguments)]
927pub async fn gc(
928 memory_dir: &Path,
929 execute: bool,
930 stale_days: u64,
931 stale_confidence: f64,
932 dead_confidence: f64,
933 dead_min_age_days: u64,
934 stats_only: bool,
935) -> Result<(), RecallError> {
936 use crate::graph::gc::{GcActionKind, GcConfig};
937
938 let graph_dir = memory_dir.join("graph");
939 if !graph_dir.exists() {
940 return Err(RecallError::NotInitialized(
941 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
942 ));
943 }
944
945 let gm = GraphMemory::open(&graph_dir).await?;
946
947 if stats_only {
948 let stats = gm.gc_stats().await?;
949 println!("{BOLD}Graph Health{RESET}");
950 println!(" Entities: {}", stats.total_entities);
951 println!(" Relationships: {}", stats.total_relationships);
952 println!(
953 " Pipeline entities: {} {DIM}(protected){RESET}",
954 stats.pipeline_entities
955 );
956 println!(" Zero-access entities: {}", stats.zero_access_entities);
957 println!(
958 " Low confidence rels: {} {DIM}(< 0.5){RESET}",
959 stats.low_confidence_rels
960 );
961 println!(
962 " Very low conf. rels: {} {DIM}(< 0.2){RESET}",
963 stats.very_low_confidence_rels
964 );
965 println!(" Superseded rels: {}", stats.superseded_rels);
966 return Ok(());
967 }
968
969 let config = GcConfig {
970 stale_days,
971 stale_confidence,
972 dead_confidence,
973 dead_min_age_days,
974 dry_run: !execute,
975 protect_pipeline: true,
976 };
977
978 let report = gm.run_gc(&config).await?;
979
980 if report.dry_run {
982 println!("{BOLD}{YELLOW}GC Dry Run{RESET} {DIM}(pass --execute to actually delete){RESET}");
983 } else {
984 println!("{BOLD}{GREEN}GC Executed{RESET}");
985 }
986
987 println!("\n{BOLD}Scan{RESET}");
988 println!(" Entities scanned: {}", report.entities_scanned);
989 println!(" Relationships scanned: {}", report.relationships_scanned);
990
991 println!("\n{BOLD}Results{RESET}");
992 println!(" Stale relationships: {}", report.stale_relationships);
993 println!(" Dead relationships: {}", report.dead_relationships);
994 println!(" Orphaned entities: {}", report.orphaned_entities);
995
996 let verb = if report.dry_run {
997 "would remove"
998 } else {
999 "removed"
1000 };
1001 println!(" Total {verb}: {}", report.total_removed);
1002
1003 if !report.actions.is_empty() {
1005 println!("\n{BOLD}Actions{RESET}");
1006 for action in &report.actions {
1007 let icon = match action.kind {
1008 GcActionKind::StaleRelationship => format!("{YELLOW}⚠{RESET}"),
1009 GcActionKind::DeadRelationship => format!("{YELLOW}✗{RESET}"),
1010 GcActionKind::OrphanedEntity => format!("{CYAN}○{RESET}"),
1011 };
1012 println!(
1013 " {icon} [{kind}] {name}",
1014 kind = action.kind,
1015 name = action.target_name,
1016 );
1017 println!(" {DIM}{reason}{RESET}", reason = action.reason);
1018 }
1019 }
1020
1021 if !report.errors.is_empty() {
1022 println!("\n{BOLD}Errors{RESET}");
1023 for err in &report.errors {
1024 println!(" \x1b[31m✗\x1b[0m {err}");
1025 }
1026 }
1027
1028 Ok(())
1029}
1030
1031pub async fn decay_report(
1033 memory_dir: &Path,
1034 entity_name: Option<&str>,
1035 show_all: bool,
1036) -> Result<(), RecallError> {
1037 use crate::graph::confidence;
1038 use crate::graph::types::Direction;
1039
1040 let graph_dir = memory_dir.join("graph");
1041 if !graph_dir.exists() {
1042 return Err(RecallError::NotInitialized(
1043 "Graph store not initialized. Run `recall-echo graph init` first.".into(),
1044 ));
1045 }
1046
1047 let gm = GraphMemory::open(&graph_dir).await?;
1048
1049 let now = chrono::Utc::now();
1050
1051 let rels = if let Some(name) = entity_name {
1052 gm.get_relationships(name, Direction::Both).await?
1053 } else {
1054 crate::graph::crud::list_all_relationships(gm.db()).await?
1055 };
1056
1057 if rels.is_empty() {
1058 println!("{YELLOW}No relationships found.{RESET}");
1059 return Ok(());
1060 }
1061
1062 println!(
1063 "{BOLD}Decay Report{RESET} ({} relationships, half-life: {} days)\n",
1064 rels.len(),
1065 confidence::DEFAULT_HALF_LIFE_DAYS
1066 );
1067
1068 let mut decayed_count = 0u32;
1069 let mut total_decay = 0.0_f64;
1070
1071 for rel in &rels {
1072 let effective = confidence::effective_confidence(
1073 rel.confidence,
1074 rel.last_reinforced.as_ref(),
1075 &rel.valid_from,
1076 &now,
1077 );
1078
1079 let decay_amount = rel.confidence - effective;
1080 if decay_amount > 0.001 {
1081 decayed_count += 1;
1082 }
1083 total_decay += decay_amount;
1084
1085 if !show_all && decay_amount < 0.001 {
1086 continue;
1087 }
1088
1089 let from_short = match &rel.from_id {
1090 serde_json::Value::String(s) => s.split(':').next_back().unwrap_or(s).to_string(),
1091 other => other.to_string(),
1092 };
1093 let to_short = match &rel.to_id {
1094 serde_json::Value::String(s) => s.split(':').next_back().unwrap_or(s).to_string(),
1095 other => other.to_string(),
1096 };
1097
1098 let reinforced_tag = match &rel.last_reinforced {
1099 Some(serde_json::Value::String(s)) => format!(" {DIM}(reinforced: {s}){RESET}"),
1100 _ => String::new(),
1101 };
1102
1103 let decay_indicator = if decay_amount > 0.2 {
1104 format!("\x1b[31m↓{:.0}%\x1b[0m", decay_amount * 100.0)
1105 } else if decay_amount > 0.05 {
1106 format!("{YELLOW}↓{:.0}%{RESET}", decay_amount * 100.0)
1107 } else {
1108 format!("{DIM}≈{RESET}")
1109 };
1110
1111 println!(
1112 " {from_short} {CYAN}—[{}]→{RESET} {to_short} stored:{:.2} effective:{:.2} {decay_indicator}{reinforced_tag}",
1113 rel.rel_type, rel.confidence, effective,
1114 );
1115 }
1116
1117 println!(
1118 "\n{BOLD}Summary{RESET}: {decayed_count}/{} relationships decayed, avg decay: {:.3}",
1119 rels.len(),
1120 if rels.is_empty() {
1121 0.0
1122 } else {
1123 total_decay / rels.len() as f64
1124 }
1125 );
1126
1127 Ok(())
1128}
1129
1130#[cfg(feature = "llm")]
1132fn find_archive_file(conversations_dir: &Path, log_number: u32) -> Result<PathBuf, RecallError> {
1133 let patterns = [
1135 format!("conversation-{log_number:03}.md"),
1136 format!("conversation-{log_number}.md"),
1137 format!("archive-log-{log_number:03}.md"),
1138 format!("archive-log-{log_number}.md"),
1139 ];
1140
1141 for name in &patterns {
1142 let path = conversations_dir.join(name);
1143 if path.exists() {
1144 return Ok(path);
1145 }
1146 }
1147
1148 Err(RecallError::Other(format!(
1149 "no archive file for log {log_number:03}",
1150 )))
1151}