Skip to main content

recall_echo/
graph_cli.rs

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