Skip to main content

recall_echo/
graph_cli.rs

1//! Graph memory CLI subcommands (behind `graph` feature flag).
2
3use 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
17/// Initialize the graph store at {memory_dir}/graph/.
18pub 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
28/// Show graph stats.
29pub 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
55/// Add an entity to the graph.
56pub 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
92/// Create a relationship between two entities.
93pub 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
122/// Semantic search across entities.
123pub 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
159/// Ingest a single archive file into the graph (episodes only, no LLM extraction).
160pub 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    // Extract session_id and log_number from frontmatter if available
171    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
192/// Ingest all un-ingested archives in conversations/.
193pub 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    // Collect all conversation files, sorted
204    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        // Check if already ingested (has episodes for this log_number)
231        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
258/// Extract session_id and log_number from a conversation archive's frontmatter.
259fn 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    // Try to extract log number from filename
264    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    // Try to extract session_id from frontmatter
276    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
291/// Traverse the graph from an entity.
292pub 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
310/// Hybrid query: semantic + graph expansion + optional episodes.
311pub 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/// Accumulated extraction totals across multiple archives.
382#[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/// Print a dry-run listing of archives that would be extracted.
396#[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/// Print the final extraction summary.
417#[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/// Format token count human-readable (e.g., "1.2M", "350K").
451#[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/// Extract entities from already-ingested archives using an LLM.
463#[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    // Determine which log numbers to process
485    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    // Build LLM provider from .recall-echo.toml (CLI flags override)
510    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        // Budget check
531        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        // Try extraction, retry once on failure, quarantine on second failure
558        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        // Rate limiting between archives
612        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    // Write quarantine file if any archives failed
618    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
638// ── Vigil sync commands ──────────────────────────────────────────────
639
640/// Sync vigil-pulse signals and outcomes into the graph.
641pub 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    // Default paths: look for vigil/ and caliber/ relative to memory_dir's parent (entity root)
654    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
686// ── Pipeline commands ──────────────────────────────────────────────────
687
688/// Sync pipeline documents into the graph.
689pub 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    // Resolve docs directory: CLI flag > config > error
701    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    // Read pipeline documents
725    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
755/// Show pipeline health stats.
756pub 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    // Display stages in pipeline order
779    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
815/// Trace pipeline flow for an entity.
816pub 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
844/// List stale pipeline entities.
845pub 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
882/// Read pipeline documents from a directory.
883fn 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
898/// Expand ~ to home directory in paths.
899fn 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
908/// Find the conversations directory — checks memory_dir/conversations/ then parent/conversations/.
909fn 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/// Run garbage collection on the graph.
926#[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    // Header
981    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    // Details
1004    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
1031/// Show relationship decay report — lists all relationships with their stored vs effective confidence.
1032pub 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/// Find the archive file for a given log number.
1131#[cfg(feature = "llm")]
1132fn find_archive_file(conversations_dir: &Path, log_number: u32) -> Result<PathBuf, RecallError> {
1133    // Try both naming conventions
1134    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}