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