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