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::correct::{CorrectTarget, Correction, CorrectionReport, EdgeCorrection, Removal};
11use crate::graph::edge_view::EdgeView;
12use crate::graph::traverse::format_traversal;
13use crate::graph::types::*;
14use crate::graph::{IngestContext, Provenance};
15use crate::serve::{
16    AddEntityArgs, CorrectArgs, IngestArchiveArgs, QueryArgs, RelateArgs, Request, SearchArgs,
17    TraverseArgs,
18};
19use crate::serve_client;
20
21const GREEN: &str = "\x1b[32m";
22const CYAN: &str = "\x1b[36m";
23const YELLOW: &str = "\x1b[33m";
24const BOLD: &str = "\x1b[1m";
25const DIM: &str = "\x1b[2m";
26const RESET: &str = "\x1b[0m";
27
28/// Initialize the graph store at {memory_dir}/graph/.
29pub async fn init(memory_dir: &Path) -> Result<(), RecallError> {
30    let graph_dir = memory_dir.join("graph");
31    serve_client::exclusive(memory_dir, |_graph| async { Ok(()) }).await?;
32    println!(
33        "{GREEN}✓{RESET} Graph store initialized at {}",
34        graph_dir.display()
35    );
36    Ok(())
37}
38
39/// Show graph stats.
40pub async fn graph_status(memory_dir: &Path) -> Result<(), RecallError> {
41    let graph_dir = memory_dir.join("graph");
42    if !graph_dir.exists() {
43        return Err(RecallError::NotInitialized(
44            "Graph store not initialized. Run `recall-echo graph init` first.".into(),
45        ));
46    }
47    let data = serve_client::execute(memory_dir, &Request::Status).await?;
48    let stats: GraphStats = serde_json::from_value(data)?;
49
50    println!("{BOLD}Graph Memory Status{RESET}");
51    println!("  Entities:      {}", stats.entity_count);
52    println!("  Relationships: {}", stats.relationship_count);
53    println!("  Episodes:      {}", stats.episode_count);
54
55    // Episodes arrive automatically on SessionEnd; turning them into entities
56    // is an LLM-costing pass. The daemon now runs it once the machine is
57    // quiet, but a store with episodes and no entities is still worth
58    // explaining — the wait, or the opt-out, is the answer either way.
59    if stats.episode_count > 0 && stats.entity_count == 0 {
60        let extraction = crate::config::load_from_dir(memory_dir).extraction;
61        println!(
62            "\n  {YELLOW}No entities yet.{RESET} Episodes are ingested automatically; turning"
63        );
64        println!("  them into entities is a separate LLM pass.");
65        if extraction.background_enabled {
66            println!(
67                "  The daemon runs it after {}s of quiet. To run it now:",
68                extraction.idle_after_secs
69            );
70        }
71        println!("    {DIM}recall-echo graph extract --all{RESET}");
72    }
73
74    if !stats.entity_type_counts.is_empty() {
75        println!("\n  {DIM}By type:{RESET}");
76        let mut types: Vec<_> = stats.entity_type_counts.iter().collect();
77        types.sort_by(|a, b| b.1.cmp(a.1));
78        for (t, count) in types {
79            println!("    {t}: {count}");
80        }
81    }
82
83    print_daemon_line(memory_dir).await;
84    Ok(())
85}
86
87/// Print the identity of the daemon serving this graph, or why there is none.
88async fn print_daemon_line(memory_dir: &Path) {
89    println!();
90    match serve_client::daemon_info(memory_dir).await {
91        Ok(Some(info)) => println!(
92            "  {DIM}Daemon:{RESET} running — pid {}, v{}, up {}s",
93            info.pid, info.version, info.uptime_secs
94        ),
95        Ok(None) if serve_client::graph_mode(memory_dir) == "server" => {
96            println!("  {DIM}Daemon:{RESET} not used — [graph] mode = server");
97        }
98        Ok(None) => println!("  {DIM}Daemon:{RESET} not running"),
99        Err(e) => println!("  {DIM}Daemon:{RESET} unknown ({e})"),
100    }
101}
102
103/// Show daemon status without touching the graph.
104pub async fn daemon_status(memory_dir: &Path) -> Result<(), RecallError> {
105    println!("{BOLD}Graph Daemon{RESET}");
106    println!(
107        "  Socket: {}",
108        serve_client::socket_path(memory_dir)?.display()
109    );
110    match serve_client::daemon_info(memory_dir).await? {
111        Some(info) => {
112            println!("  State:  {GREEN}running{RESET}");
113            println!("  Pid:    {}", info.pid);
114            println!("  Version: {}", info.version);
115            println!("  Uptime: {}s", info.uptime_secs);
116            print_extraction_lines(&info.extraction);
117        }
118        None if serve_client::graph_mode(memory_dir) == "server" => {
119            println!("  State:  not used — [graph] mode = server");
120        }
121        None => println!("  State:  {YELLOW}not running{RESET}"),
122    }
123    println!(
124        "  Log:    {}",
125        serve_client::daemon_log_path(memory_dir).display()
126    );
127    Ok(())
128}
129
130/// Report what the daemon's background extraction worker has done.
131fn print_extraction_lines(status: &crate::serve::ExtractionStatus) {
132    if !status.enabled {
133        let reason = status.disabled_reason.as_deref().unwrap_or("not running");
134        println!("  Extraction: {YELLOW}off{RESET} — {DIM}{reason}{RESET}");
135        return;
136    }
137    match status.last_run_secs_ago {
138        Some(secs) => println!(
139            "  Extraction: {GREEN}on{RESET} — {} archives in {} runs, last {}s ago ({}ms)",
140            status.archives,
141            status.runs,
142            secs,
143            status.last_run_ms.unwrap_or(0)
144        ),
145        None => println!("  Extraction: {GREEN}on{RESET} — nothing extracted yet"),
146    }
147    if let Some(error) = &status.last_error {
148        println!("  {DIM}Last extraction error: {error}{RESET}");
149    }
150}
151
152/// Stop the daemon serving this graph, if one is running.
153pub async fn daemon_stop(memory_dir: &Path) -> Result<(), RecallError> {
154    if serve_client::stop_daemon(memory_dir).await? {
155        println!("{GREEN}✓{RESET} Graph daemon stopped");
156    } else {
157        println!("{YELLOW}No graph daemon running.{RESET}");
158    }
159    Ok(())
160}
161
162/// Add an entity to the graph.
163pub async fn add_entity(
164    memory_dir: &Path,
165    name: &str,
166    entity_type: &str,
167    abstract_text: &str,
168    overview: Option<&str>,
169    source: Option<&str>,
170) -> Result<(), RecallError> {
171    let request = Request::AddEntity(AddEntityArgs {
172        name: name.to_string(),
173        entity_type: entity_type.to_string(),
174        abstract_text: abstract_text.to_string(),
175        overview: overview.map(String::from),
176        source: source.map(String::from),
177    });
178    let entity: Entity =
179        serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
180
181    println!(
182        "{GREEN}✓{RESET} Created entity: {BOLD}{}{RESET} ({}) [{}]",
183        entity.name,
184        entity.entity_type,
185        entity.id_string()
186    );
187    Ok(())
188}
189
190/// Create a relationship between two entities.
191pub async fn relate(
192    memory_dir: &Path,
193    from: &str,
194    rel_type: &str,
195    to: &str,
196    description: Option<&str>,
197    source: Option<&str>,
198) -> Result<(), RecallError> {
199    let request = Request::Relate(RelateArgs {
200        from: from.to_string(),
201        rel_type: rel_type.to_string(),
202        to: to.to_string(),
203        description: description.map(String::from),
204        source: source.map(String::from),
205    });
206    let rel: Relationship =
207        serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
208
209    println!(
210        "{GREEN}✓{RESET} {from} {CYAN}—[{rel_type}]→{RESET} {to} [{}]",
211        rel.id_string()
212    );
213    Ok(())
214}
215
216/// Semantic search across entities.
217pub async fn search(
218    memory_dir: &Path,
219    query: &str,
220    limit: usize,
221    entity_type: Option<&str>,
222    keyword: Option<&str>,
223) -> Result<(), RecallError> {
224    let request = Request::Search(SearchArgs {
225        query: query.to_string(),
226        limit,
227        entity_type: entity_type.map(String::from),
228        keyword: keyword.map(String::from),
229    });
230    let results: Vec<ScoredEntity> =
231        serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
232
233    if results.is_empty() {
234        println!("{YELLOW}No results.{RESET}");
235        return Ok(());
236    }
237
238    for (i, r) in results.iter().enumerate() {
239        println!(
240            "{BOLD}{}. {}{RESET} ({}) — score: {:.3}",
241            i + 1,
242            r.entity.name,
243            r.entity.entity_type,
244            r.score
245        );
246        println!("   {DIM}{}{RESET}", r.entity.abstract_text);
247    }
248    Ok(())
249}
250
251/// Ingest a single archive file into the graph (episodes only, no LLM extraction).
252///
253/// `provenance` forces an authorship class on every episode of the run — how
254/// `--external` marks genuinely external material. `None` infers per chunk
255/// from conversation turn roles.
256pub async fn ingest(
257    memory_dir: &Path,
258    archive_path: &Path,
259    provenance: Option<Provenance>,
260) -> Result<(), RecallError> {
261    let graph_dir = memory_dir.join("graph");
262    if !graph_dir.exists() {
263        return Err(RecallError::NotInitialized(
264            "Graph store not initialized. Run `recall-echo graph init` first.".into(),
265        ));
266    }
267
268    let content = std::fs::read_to_string(archive_path)?;
269
270    // Extract session_id and log_number from frontmatter if available
271    let (session_id, log_number) = extract_archive_metadata(&content, archive_path);
272
273    let request = Request::IngestArchive(IngestArchiveArgs {
274        content,
275        session_id,
276        log_number,
277        provenance,
278    });
279    let report: IngestionReport =
280        serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
281
282    println!(
283        "{GREEN}✓{RESET} Ingested {}: {} episodes created {DIM}(provenance: {}){RESET}",
284        archive_path.display(),
285        report.episodes_created,
286        provenance_label(provenance)
287    );
288    if !report.errors.is_empty() {
289        for err in &report.errors {
290            println!("  {YELLOW}warning:{RESET} {err}");
291        }
292    }
293    Ok(())
294}
295
296/// How an ingest run's provenance choice reads in its summary line.
297fn provenance_label(provenance: Option<Provenance>) -> &'static str {
298    match provenance {
299        Some(Provenance::External) => "external",
300        Some(Provenance::User) => "user",
301        Some(Provenance::SelfGenerated) => "self",
302        None => "per turn role",
303    }
304}
305
306/// Ingest all un-ingested archives in conversations/.
307pub async fn ingest_all(
308    memory_dir: &Path,
309    provenance: Option<Provenance>,
310) -> Result<(), RecallError> {
311    let graph_dir = memory_dir.join("graph");
312    if !graph_dir.exists() {
313        return Err(RecallError::NotInitialized(
314            "Graph store not initialized. Run `recall-echo graph init` first.".into(),
315        ));
316    }
317
318    let conversations_dir = find_conversations_dir(memory_dir)?;
319
320    // Collect all conversation files, sorted
321    let mut files: Vec<_> = std::fs::read_dir(&conversations_dir)?
322        .filter_map(|e| e.ok())
323        .filter(|e| {
324            let name = e.file_name().to_string_lossy().to_string();
325            name.starts_with("conversation-") || name.starts_with("archive-log-")
326        })
327        .collect();
328    files.sort_by_key(|e| e.file_name());
329
330    if files.is_empty() {
331        println!("{YELLOW}No conversation archives found.{RESET}");
332        return Ok(());
333    }
334
335    serve_client::exclusive(memory_dir, |gm| async move {
336        let mut total_episodes = 0u32;
337        let mut ingested = 0u32;
338        let mut skipped = 0u32;
339
340        for entry in &files {
341            let path = entry.path();
342            let content = std::fs::read_to_string(&path)?;
343
344            let (session_id, log_number) = extract_archive_metadata(&content, &path);
345
346            // Check if already ingested (has episodes for this log_number)
347            if let Some(ln) = log_number {
348                if let Ok(Some(_)) = gm.get_episode_by_log_number(ln).await {
349                    skipped += 1;
350                    continue;
351                }
352            }
353
354            let context = IngestContext::new(session_id, log_number).with_override(provenance);
355            let report = gm.ingest_archive(&content, &context, None).await?;
356
357            total_episodes += report.episodes_created;
358            ingested += 1;
359
360            println!(
361                "  {GREEN}✓{RESET} {} — {} episodes",
362                path.file_name().unwrap_or_default().to_string_lossy(),
363                report.episodes_created
364            );
365        }
366
367        println!(
368            "\n{GREEN}✓{RESET} Ingested {ingested} archives ({total_episodes} episodes), skipped {skipped} already ingested {DIM}(provenance: {}){RESET}",
369            provenance_label(provenance)
370        );
371        Ok(())
372    })
373    .await
374}
375
376/// Extract session_id and log_number from a conversation archive's frontmatter.
377pub(crate) fn extract_archive_metadata(content: &str, path: &Path) -> (String, Option<u32>) {
378    let mut session_id = "unknown".to_string();
379    let mut log_number: Option<u32> = None;
380
381    // Try to extract log number from filename
382    if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
383        let num_str = name
384            .strip_prefix("conversation-")
385            .or_else(|| name.strip_prefix("archive-log-"));
386        if let Some(num_str) = num_str {
387            if let Ok(n) = num_str.parse::<u32>() {
388                log_number = Some(n);
389            }
390        }
391    }
392
393    // Try to extract session_id from frontmatter
394    if let Some(stripped) = content.strip_prefix("---") {
395        if let Some(end) = stripped.find("---") {
396            let frontmatter = &stripped[..end];
397            for line in frontmatter.lines() {
398                let line = line.trim();
399                if let Some(val) = line.strip_prefix("session_id:") {
400                    session_id = val.trim().trim_matches('"').to_string();
401                }
402            }
403        }
404    }
405
406    (session_id, log_number)
407}
408
409/// Traverse the graph from an entity.
410pub async fn traverse(
411    memory_dir: &Path,
412    entity_name: &str,
413    depth: u32,
414    type_filter: Option<&str>,
415) -> Result<(), RecallError> {
416    let request = Request::Traverse(TraverseArgs {
417        entity: entity_name.to_string(),
418        depth,
419        type_filter: type_filter.map(String::from),
420    });
421    let tree: TraversalNode =
422        serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
423
424    let output = format_traversal(&tree, 0);
425    print!("{output}");
426    Ok(())
427}
428
429/// Hybrid query: semantic + graph expansion + optional episodes.
430pub async fn hybrid_query(
431    memory_dir: &Path,
432    query: &str,
433    limit: usize,
434    entity_type: Option<&str>,
435    keyword: Option<&str>,
436    depth: u32,
437    episodes: bool,
438) -> Result<(), RecallError> {
439    let request = Request::Query(QueryArgs {
440        query: query.to_string(),
441        limit,
442        entity_type: entity_type.map(String::from),
443        keyword: keyword.map(String::from),
444        depth,
445        episodes,
446    });
447    let result: QueryResult =
448        serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
449
450    if result.entities.is_empty() && result.episodes.is_empty() {
451        println!("{YELLOW}No results.{RESET}");
452        return Ok(());
453    }
454
455    if !result.entities.is_empty() {
456        println!("{BOLD}Entities:{RESET}");
457        for (i, r) in result.entities.iter().enumerate() {
458            let source_tag = match &r.source {
459                MatchSource::Semantic => "semantic".to_string(),
460                MatchSource::Graph { parent, rel_type } => {
461                    format!("graph: {parent} —[{rel_type}]")
462                }
463                MatchSource::Keyword => "keyword".to_string(),
464            };
465            println!(
466                "  {BOLD}{}. {}{RESET} ({}) — {:.3} [{DIM}{source_tag}{RESET}]",
467                i + 1,
468                r.entity.name,
469                r.entity.entity_type,
470                r.score
471            );
472            println!("     {DIM}{}{RESET}", r.entity.abstract_text);
473        }
474    }
475
476    if !result.episodes.is_empty() {
477        println!("\n{BOLD}Episodes:{RESET}");
478        for (i, ep) in result.episodes.iter().enumerate() {
479            let log = ep
480                .episode
481                .log_number
482                .map(|n| format!("#{n}"))
483                .unwrap_or_default();
484            println!(
485                "  {BOLD}{}. {}{RESET} ({}) — {:.3}",
486                i + 1,
487                ep.episode.session_id,
488                log,
489                ep.score
490            );
491            println!("     {DIM}{}{RESET}", ep.episode.abstract_text);
492        }
493    }
494
495    Ok(())
496}
497
498/// Accumulated extraction totals across multiple archives.
499#[cfg(feature = "llm")]
500#[derive(Default)]
501struct ExtractionTotals {
502    entities_created: u32,
503    entities_merged: u32,
504    entities_skipped: u32,
505    relationships: u32,
506    errors: Vec<String>,
507    processed: u32,
508    estimated_tokens: u64,
509    measured_tokens: u64,
510    quarantined: Vec<u32>,
511    dedup_llm_calls: u32,
512    dedup_fast_path: u32,
513}
514
515/// Print a dry-run listing of archives that would be extracted.
516#[cfg(feature = "llm")]
517fn print_extract_dry_run(conversations_dir: &Path, log_numbers: &[u32]) {
518    println!(
519        "{BOLD}Dry run — {}{RESET} archives to extract",
520        log_numbers.len()
521    );
522    for ln in log_numbers {
523        let path = find_archive_file(conversations_dir, *ln);
524        let label = match &path {
525            Ok(p) => p
526                .file_name()
527                .unwrap_or_default()
528                .to_string_lossy()
529                .to_string(),
530            Err(_) => format!("log {ln:03} (file not found)"),
531        };
532        println!("  {label}");
533    }
534}
535
536/// Print the final extraction summary.
537#[cfg(feature = "llm")]
538fn print_extract_summary(totals: &ExtractionTotals) {
539    println!(
540        "\n{GREEN}✓{RESET} Done: {} archives — +{} created, ~{} merged, -{} skipped, {} relationships",
541        totals.processed,
542        totals.entities_created,
543        totals.entities_merged,
544        totals.entities_skipped,
545        totals.relationships,
546    );
547    println!(
548        "  Tokens: {}",
549        format_token_bill(totals.measured_tokens, totals.estimated_tokens)
550    );
551    let dedup_total = totals.dedup_llm_calls + totals.dedup_fast_path;
552    if dedup_total > 0 {
553        println!(
554            "  Dedup: {} of {} candidates needed a model call ({} resolved locally)",
555            totals.dedup_llm_calls, dedup_total, totals.dedup_fast_path
556        );
557    }
558
559    if !totals.quarantined.is_empty() {
560        println!(
561            "  {YELLOW}Quarantined: {} archives{RESET}",
562            totals.quarantined.len()
563        );
564    }
565
566    if !totals.errors.is_empty() {
567        println!("\n{YELLOW}Warnings ({}):{RESET}", totals.errors.len());
568        for err in totals.errors.iter().take(10) {
569            println!("  {DIM}{err}{RESET}");
570        }
571        if totals.errors.len() > 10 {
572            println!("  {DIM}... and {} more{RESET}", totals.errors.len() - 10);
573        }
574    }
575}
576
577/// State a token bill without overstating it.
578///
579/// A provider that reports usage (codex, grok, the HTTP APIs) is quoted; one
580/// that does not (claude-code's prose output, any CLI without usage paths) is
581/// estimated, and the `~` says so. A run that did some of each prints both
582/// rather than adding a measurement to a guess and calling the sum measured.
583#[cfg(feature = "llm")]
584fn format_token_bill(measured: u64, estimated: u64) -> String {
585    match (measured, estimated) {
586        (0, 0) => "0".to_string(),
587        (measured, 0) => format!("{} measured", format_tokens(measured)),
588        (0, estimated) => format!("~{} estimated", format_tokens(estimated)),
589        (measured, estimated) => format!(
590            "{} measured + ~{} estimated",
591            format_tokens(measured),
592            format_tokens(estimated)
593        ),
594    }
595}
596
597/// Format token count human-readable (e.g., "1.2M", "350K").
598#[cfg(feature = "llm")]
599fn format_tokens(tokens: u64) -> String {
600    if tokens >= 1_000_000 {
601        format!("{:.1}M", tokens as f64 / 1_000_000.0)
602    } else if tokens >= 1_000 {
603        format!("{:.0}K", tokens as f64 / 1_000.0)
604    } else {
605        tokens.to_string()
606    }
607}
608
609/// Extract entities from already-ingested archives using an LLM.
610#[cfg(feature = "llm")]
611#[allow(clippy::too_many_arguments)]
612pub async fn extract(
613    memory_dir: &Path,
614    log: Option<u32>,
615    all: bool,
616    dry_run: bool,
617    model_override: Option<String>,
618    provider_override: Option<String>,
619    delay_ms: u64,
620    max_tokens: u64,
621) -> Result<(), RecallError> {
622    let graph_dir = memory_dir.join("graph");
623    if !graph_dir.exists() {
624        return Err(RecallError::NotInitialized(
625            "Graph store not initialized. Run `recall-echo graph init` first.".into(),
626        ));
627    }
628
629    serve_client::exclusive(memory_dir, |gm| async move {
630        // Determine which log numbers to process
631        let log_numbers: Vec<u32> = if let Some(ln) = log {
632            vec![ln]
633        } else if all {
634            gm.unextracted_log_numbers()
635                .await?
636                .into_iter()
637                .map(|n| n as u32)
638                .collect()
639        } else {
640            return Err(RecallError::Other("Specify --log <N> or --all".into()));
641        };
642
643        if log_numbers.is_empty() {
644            println!("{YELLOW}No unextracted archives found.{RESET}");
645            return Ok(());
646        }
647
648        let conversations_dir = find_conversations_dir(memory_dir)?;
649
650        if dry_run {
651            print_extract_dry_run(&conversations_dir, &log_numbers);
652            return Ok(());
653        }
654
655        // Build LLM provider from .recall-echo.toml (CLI flags override)
656        let (llm, model_name) = crate::llm_provider::create_provider(
657            memory_dir,
658            provider_override.as_deref(),
659            model_override.as_deref(),
660        )?;
661
662        let total_count = log_numbers.len();
663        let budget_label = if max_tokens > 0 {
664            format!(" (budget: {})", format_tokens(max_tokens))
665        } else {
666            String::new()
667        };
668        // A CLI provider with no configured model uses its own default, and
669        // says so rather than printing "using ".
670        let model_label = if model_name.is_empty() {
671            "the provider default"
672        } else {
673            &model_name
674        };
675        println!(
676            "{BOLD}Extracting entities from {total_count} archives using {model_label}{budget_label}{RESET}",
677        );
678
679        let quarantine_path = graph_dir.join("extraction-quarantine.txt");
680        let mut totals = ExtractionTotals::default();
681
682        for (idx, ln) in log_numbers.iter().enumerate() {
683            // Budget check. Measured and estimated tokens are both spent
684            // tokens, so the budget is charged the sum of the two.
685            let spent = totals.measured_tokens + totals.estimated_tokens;
686            if max_tokens > 0 && spent >= max_tokens {
687                println!(
688                    "\n{YELLOW}⚠ Token budget exhausted ({} of {}). Stopping.{RESET}",
689                    format_token_bill(totals.measured_tokens, totals.estimated_tokens),
690                    format_tokens(max_tokens),
691                );
692                println!("  Re-run to continue — resume is automatic via unextracted log numbers.");
693                break;
694            }
695
696            let archive_path = match find_archive_file(&conversations_dir, *ln) {
697                Ok(p) => p,
698                Err(e) => {
699                    println!(
700                        "  {YELLOW}⚠{RESET} [{}/{}] log {ln:03}: {e}",
701                        idx + 1,
702                        total_count
703                    );
704                    totals.errors.push(format!("log {ln:03}: {e}"));
705                    continue;
706                }
707            };
708
709            let content = std::fs::read_to_string(&archive_path)?;
710            let (session_id, _) = extract_archive_metadata(&content, &archive_path);
711            let context = IngestContext::new(session_id, Some(*ln));
712
713            // Try extraction, retry once on failure, quarantine on second failure
714            let report = match gm.extract_from_archive(&content, &context, &*llm).await {
715                Ok(r) => r,
716                Err(e) => {
717                    println!(
718                        "  {YELLOW}⚠{RESET} [{}/{}] log {ln:03}: failed, retrying... ({e})",
719                        idx + 1,
720                        total_count
721                    );
722                    match gm.extract_from_archive(&content, &context, &*llm).await {
723                        Ok(r) => r,
724                        Err(e2) => {
725                            println!(
726                                "  {YELLOW}✗{RESET} [{}/{}] log {ln:03}: quarantined ({e2})",
727                                idx + 1,
728                                total_count
729                            );
730                            totals.quarantined.push(*ln);
731                            totals
732                                .errors
733                                .push(format!("log {ln:03}: quarantined after retry: {e2}"));
734                            continue;
735                        }
736                    }
737                }
738            };
739
740            println!(
741                "  {GREEN}✓{RESET} [{}/{}] log {ln:03}: +{} entities, ~{} merged, -{} skipped, {} rels ({})",
742                idx + 1,
743                total_count,
744                report.entities_created,
745                report.entities_merged,
746                report.entities_skipped,
747                report.relationships_created,
748                format_token_bill(report.measured_tokens, report.estimated_tokens),
749            );
750
751            gm.mark_extracted(*ln).await?;
752
753            totals.entities_created += report.entities_created;
754            totals.entities_merged += report.entities_merged;
755            totals.entities_skipped += report.entities_skipped;
756            totals.relationships += report.relationships_created;
757            totals.errors.extend(report.errors);
758            totals.processed += 1;
759            totals.estimated_tokens += report.estimated_tokens;
760            totals.measured_tokens += report.measured_tokens;
761            totals.dedup_llm_calls += report.dedup_llm_calls;
762            totals.dedup_fast_path += report.dedup_fast_path;
763
764            // Rate limiting between archives
765            if delay_ms > 0 && *ln != *log_numbers.last().unwrap() {
766                tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await;
767            }
768        }
769
770        // Write quarantine file if any archives failed
771        if !totals.quarantined.is_empty() {
772            use std::io::Write;
773            let mut file = std::fs::OpenOptions::new()
774                .create(true)
775                .append(true)
776                .open(&quarantine_path)?;
777            for ln in &totals.quarantined {
778                writeln!(file, "{ln:03}")?;
779            }
780            println!(
781                "\n  {YELLOW}Quarantined {} archives → {}{RESET}",
782                totals.quarantined.len(),
783                quarantine_path.display()
784            );
785        }
786
787        print_extract_summary(&totals);
788        Ok(())
789    })
790    .await
791}
792
793// ── Vigil sync commands ──────────────────────────────────────────────
794
795/// Sync vigil-pulse signals and outcomes into the graph.
796pub async fn vigil_sync(
797    memory_dir: &Path,
798    signals_path: Option<&Path>,
799    outcomes_path: Option<&Path>,
800) -> Result<(), RecallError> {
801    let graph_dir = memory_dir.join("graph");
802    if !graph_dir.exists() {
803        return Err(RecallError::NotInitialized(
804            "Graph store not initialized. Run `recall-echo graph init` first.".into(),
805        ));
806    }
807
808    // Default paths: look for vigil/ and caliber/ relative to memory_dir's parent (entity root)
809    let entity_root = memory_dir.parent().unwrap_or(memory_dir);
810
811    let default_signals = entity_root.join("vigil").join("signals.json");
812    let default_outcomes = entity_root.join("caliber").join("outcomes.json");
813
814    let sig_path = signals_path.unwrap_or(&default_signals);
815    let out_path = outcomes_path.unwrap_or(&default_outcomes);
816
817    serve_client::exclusive(memory_dir, |gm| async move {
818        let report = gm.sync_vigil(sig_path, out_path).await?;
819
820        println!("{BOLD}Vigil Sync{RESET}");
821        println!("  Measurements: +{}", report.measurements_created);
822        println!("  Outcomes:     +{}", report.outcomes_created);
823        println!("  Relationships: +{}", report.relationships_created);
824        println!("  Skipped:       {}", report.skipped);
825
826        if !report.errors.is_empty() {
827            println!("\n  {YELLOW}Warnings:{RESET}");
828            for err in &report.errors {
829                println!("    {DIM}{err}{RESET}");
830            }
831        }
832
833        if report.measurements_created == 0 && report.outcomes_created == 0 {
834            println!("\n  {DIM}No new data — graph is in sync.{RESET}");
835        }
836
837        Ok(())
838    })
839    .await
840}
841
842// ── Pipeline commands ──────────────────────────────────────────────────
843
844/// Sync pipeline documents into the graph.
845pub async fn pipeline_sync(
846    memory_dir: &Path,
847    docs_dir_override: Option<&Path>,
848) -> Result<(), RecallError> {
849    let graph_dir = memory_dir.join("graph");
850    if !graph_dir.exists() {
851        return Err(RecallError::NotInitialized(
852            "Graph store not initialized. Run `recall-echo graph init` first.".into(),
853        ));
854    }
855
856    // Resolve docs directory: CLI flag > config > error
857    let docs_dir = if let Some(d) = docs_dir_override {
858        d.to_path_buf()
859    } else {
860        let cfg = crate::config::load_from_dir(memory_dir);
861        match cfg.pipeline.and_then(|p| p.docs_dir) {
862            Some(d) => {
863                let path = PathBuf::from(shellexpand(&d));
864                if !path.exists() {
865                    return Err(RecallError::Config(format!(
866                        "Configured docs_dir does not exist: {}",
867                        path.display()
868                    )));
869                }
870                path
871            }
872            None => {
873                return Err(
874                    "No docs directory specified. Use --docs-dir or set [pipeline] docs_dir in config.".into(),
875                );
876            }
877        }
878    };
879
880    // Read pipeline documents
881    let docs = read_pipeline_docs(&docs_dir)?;
882
883    let report = crate::graph_bridge::sync_pipeline_into_graph(memory_dir, docs).await?;
884
885    println!("{BOLD}Pipeline Sync{RESET}");
886    println!("  Created:      {}", report.entities_created);
887    println!("  Updated:      {}", report.entities_updated);
888    println!("  Archived:     {}", report.entities_archived);
889    println!(
890        "  Relationships: +{} / ~{} skipped",
891        report.relationships_created, report.relationships_skipped
892    );
893
894    if !report.errors.is_empty() {
895        println!("\n  {YELLOW}Warnings:{RESET}");
896        for err in &report.errors {
897            println!("    {DIM}{err}{RESET}");
898        }
899    }
900
901    if report.entities_created == 0 && report.entities_updated == 0 && report.entities_archived == 0
902    {
903        println!("\n  {DIM}No changes — graph is in sync.{RESET}");
904    }
905
906    Ok(())
907}
908
909/// Show pipeline health stats.
910pub async fn pipeline_status(memory_dir: &Path, staleness_days: u32) -> Result<(), RecallError> {
911    let graph_dir = memory_dir.join("graph");
912    if !graph_dir.exists() {
913        return Err(RecallError::NotInitialized(
914            "Graph store not initialized. Run `recall-echo graph init` first.".into(),
915        ));
916    }
917
918    serve_client::exclusive(memory_dir, |gm| async move {
919        let stats = gm.pipeline_stats(staleness_days).await?;
920
921        println!(
922            "{BOLD}Pipeline Status{RESET} ({} entities)",
923            stats.total_entities
924        );
925
926        if stats.by_stage.is_empty() {
927            println!(
928                "  {DIM}No pipeline entities in graph. Run `graph pipeline sync` first.{RESET}"
929            );
930            return Ok(());
931        }
932
933        // Display stages in pipeline order
934        let stage_order = ["learning", "thoughts", "curiosity", "reflections", "praxis"];
935        for stage in &stage_order {
936            if let Some(statuses) = stats.by_stage.get(*stage) {
937                println!("\n  {CYAN}{}{RESET}", stage.to_uppercase());
938                let mut items: Vec<_> = statuses.iter().collect();
939                items.sort_by_key(|(s, _)| (*s).clone());
940                for (status, count) in items {
941                    println!("    {status}: {count}");
942                }
943            }
944        }
945
946        if !stats.stale_thoughts.is_empty() {
947            println!("\n  {YELLOW}Stale thoughts (>{staleness_days}d):{RESET}");
948            for entity in &stats.stale_thoughts {
949                println!("    {DIM}•{RESET} {}", entity.name);
950            }
951        }
952
953        if !stats.stale_questions.is_empty() {
954            println!(
955                "\n  {YELLOW}Stale questions (>{}d):{RESET}",
956                staleness_days * 2
957            );
958            for entity in &stats.stale_questions {
959                println!("    {DIM}•{RESET} {}", entity.name);
960            }
961        }
962
963        if let Some(ref last) = stats.last_movement {
964            println!("\n  {DIM}Last movement: {last}{RESET}");
965        }
966
967        Ok(())
968    })
969    .await
970}
971
972/// Trace pipeline flow for an entity.
973pub async fn pipeline_flow(memory_dir: &Path, entity_name: &str) -> Result<(), RecallError> {
974    let graph_dir = memory_dir.join("graph");
975    if !graph_dir.exists() {
976        return Err(RecallError::NotInitialized(
977            "Graph store not initialized. Run `recall-echo graph init` first.".into(),
978        ));
979    }
980
981    serve_client::exclusive(memory_dir, |gm| async move {
982        let chain = gm.pipeline_flow(entity_name).await?;
983
984        if chain.is_empty() {
985            println!("{YELLOW}No pipeline relationships found for \"{entity_name}\".{RESET}");
986            return Ok(());
987        }
988
989        println!("{BOLD}Pipeline Flow: {entity_name}{RESET}\n");
990        for (source, rel_type, target) in &chain {
991            println!(
992                "  {} ({}) {CYAN}—[{rel_type}]→{RESET} {} ({})",
993                source.name, source.entity_type, target.name, target.entity_type
994            );
995        }
996
997        Ok(())
998    })
999    .await
1000}
1001
1002/// List stale pipeline entities.
1003pub async fn pipeline_stale(memory_dir: &Path, staleness_days: u32) -> Result<(), RecallError> {
1004    let graph_dir = memory_dir.join("graph");
1005    if !graph_dir.exists() {
1006        return Err(RecallError::NotInitialized(
1007            "Graph store not initialized. Run `recall-echo graph init` first.".into(),
1008        ));
1009    }
1010
1011    serve_client::exclusive(memory_dir, |gm| async move {
1012        let stats = gm.pipeline_stats(staleness_days).await?;
1013
1014        let total_stale = stats.stale_thoughts.len() + stats.stale_questions.len();
1015        if total_stale == 0 {
1016            println!("{GREEN}✓{RESET} No stale pipeline entities.");
1017            return Ok(());
1018        }
1019
1020        println!("{BOLD}Stale Pipeline Entities{RESET}\n");
1021
1022        if !stats.stale_thoughts.is_empty() {
1023            println!("  {YELLOW}Thoughts (>{staleness_days} days):{RESET}");
1024            for entity in &stats.stale_thoughts {
1025                println!("    • {} {DIM}({}){RESET}", entity.name, entity.entity_type);
1026            }
1027        }
1028
1029        if !stats.stale_questions.is_empty() {
1030            println!("  {YELLOW}Questions (>{} days):{RESET}", staleness_days * 2);
1031            for entity in &stats.stale_questions {
1032                println!("    • {} {DIM}({}){RESET}", entity.name, entity.entity_type);
1033            }
1034        }
1035
1036        Ok(())
1037    })
1038    .await
1039}
1040
1041/// Read pipeline documents from a directory.
1042fn read_pipeline_docs(dir: &Path) -> Result<PipelineDocuments, RecallError> {
1043    let read_or_empty = |name: &str| -> String {
1044        let path = dir.join(name);
1045        std::fs::read_to_string(&path).unwrap_or_default()
1046    };
1047
1048    Ok(PipelineDocuments {
1049        learning: read_or_empty("LEARNING.md"),
1050        thoughts: read_or_empty("THOUGHTS.md"),
1051        curiosity: read_or_empty("CURIOSITY.md"),
1052        reflections: read_or_empty("REFLECTIONS.md"),
1053        praxis: read_or_empty("PRAXIS.md"),
1054    })
1055}
1056
1057/// Expand ~ to home directory in paths.
1058fn shellexpand(path: &str) -> String {
1059    if let Some(rest) = path.strip_prefix("~/") {
1060        if let Some(home) = dirs::home_dir() {
1061            return home.join(rest).to_string_lossy().to_string();
1062        }
1063    }
1064    path.to_string()
1065}
1066
1067/// Find the conversations directory — checks memory_dir/conversations/ then parent/conversations/.
1068pub(crate) fn find_conversations_dir(memory_dir: &Path) -> Result<PathBuf, RecallError> {
1069    let conv = memory_dir.join("conversations");
1070    if conv.exists() {
1071        return Ok(conv);
1072    }
1073    if let Some(parent) = memory_dir.parent() {
1074        let parent_conv = parent.join("conversations");
1075        if parent_conv.exists() {
1076            return Ok(parent_conv);
1077        }
1078    }
1079    Err(RecallError::NotInitialized(
1080        "conversations/ directory not found".into(),
1081    ))
1082}
1083
1084/// Thresholds and mode for one `graph gc` invocation.
1085///
1086/// A struct rather than eight positional arguments: every field is a knob the
1087/// CLI exposes, and callers should be able to take the defaults.
1088#[derive(Debug, Clone)]
1089pub struct GcOptions {
1090    /// Actually delete. The default is a dry run.
1091    pub execute: bool,
1092    pub stale_days: u64,
1093    pub stale_confidence: f64,
1094    pub dead_confidence: f64,
1095    pub dead_min_age_days: u64,
1096    /// Also sweep episodes.
1097    pub episodes: bool,
1098    pub episode_max_age_days: u64,
1099    /// Report health only, computing no deletion candidates.
1100    pub stats_only: bool,
1101}
1102
1103impl Default for GcOptions {
1104    fn default() -> Self {
1105        let defaults = crate::graph::gc::GcConfig::default();
1106        Self {
1107            execute: false,
1108            stale_days: defaults.stale_days,
1109            stale_confidence: defaults.stale_confidence,
1110            dead_confidence: defaults.dead_confidence,
1111            dead_min_age_days: defaults.dead_min_age_days,
1112            episodes: false,
1113            episode_max_age_days: defaults.episode_max_age_days,
1114            stats_only: false,
1115        }
1116    }
1117}
1118
1119impl GcOptions {
1120    fn to_config(&self) -> crate::graph::gc::GcConfig {
1121        crate::graph::gc::GcConfig {
1122            stale_days: self.stale_days,
1123            stale_confidence: self.stale_confidence,
1124            dead_confidence: self.dead_confidence,
1125            dead_min_age_days: self.dead_min_age_days,
1126            collect_episodes: self.episodes,
1127            episode_max_age_days: self.episode_max_age_days,
1128            dry_run: !self.execute,
1129            protect_pipeline: true,
1130        }
1131    }
1132}
1133
1134/// Run garbage collection on the graph.
1135pub async fn gc(memory_dir: &Path, options: &GcOptions) -> Result<(), RecallError> {
1136    use crate::graph::gc::GcActionKind;
1137
1138    let stats_only = options.stats_only;
1139    let config = options.to_config();
1140
1141    let graph_dir = memory_dir.join("graph");
1142    if !graph_dir.exists() {
1143        return Err(RecallError::NotInitialized(
1144            "Graph store not initialized. Run `recall-echo graph init` first.".into(),
1145        ));
1146    }
1147
1148    serve_client::exclusive(memory_dir, |gm| async move {
1149        if stats_only {
1150            let stats = gm.gc_stats().await?;
1151            println!("{BOLD}Graph Health{RESET}");
1152            println!("  Entities:              {}", stats.total_entities);
1153            println!("  Relationships:         {}", stats.total_relationships);
1154            println!(
1155                "  Pipeline entities:     {} {DIM}(protected){RESET}",
1156                stats.pipeline_entities
1157            );
1158            println!("  Zero-access entities:  {}", stats.zero_access_entities);
1159            println!(
1160                "  Low confidence rels:   {} {DIM}(< 0.5){RESET}",
1161                stats.low_confidence_rels
1162            );
1163            println!(
1164                "  Very low conf. rels:   {} {DIM}(< 0.2){RESET}",
1165                stats.very_low_confidence_rels
1166            );
1167            println!("  Superseded rels:       {}", stats.superseded_rels);
1168            return Ok(());
1169        }
1170
1171        let report = gm.run_gc(&config).await?;
1172
1173        // Header
1174        if report.dry_run {
1175            println!(
1176                "{BOLD}{YELLOW}GC Dry Run{RESET} {DIM}(pass --execute to actually delete){RESET}"
1177            );
1178        } else {
1179            println!("{BOLD}{GREEN}GC Executed{RESET}");
1180        }
1181
1182        println!("\n{BOLD}Scan{RESET}");
1183        println!("  Entities scanned:      {}", report.entities_scanned);
1184        println!("  Relationships scanned: {}", report.relationships_scanned);
1185        if config.collect_episodes {
1186            println!("  Episodes scanned:      {}", report.episodes_scanned);
1187        }
1188
1189        println!("\n{BOLD}Results{RESET}");
1190        println!("  Stale relationships:   {}", report.stale_relationships);
1191        println!("  Dead relationships:    {}", report.dead_relationships);
1192        println!("  Orphaned entities:     {}", report.orphaned_entities);
1193        if config.collect_episodes {
1194            println!("  Spent episodes:        {}", report.spent_episodes);
1195        }
1196
1197        let verb = if report.dry_run {
1198            "would remove"
1199        } else {
1200            "removed"
1201        };
1202        println!("  Total {verb}:         {}", report.total_removed);
1203
1204        // Details
1205        if !report.actions.is_empty() {
1206            println!("\n{BOLD}Actions{RESET}");
1207            for action in &report.actions {
1208                let icon = match action.kind {
1209                    GcActionKind::StaleRelationship => format!("{YELLOW}⚠{RESET}"),
1210                    GcActionKind::DeadRelationship => format!("{YELLOW}✗{RESET}"),
1211                    GcActionKind::OrphanedEntity => format!("{CYAN}○{RESET}"),
1212                    GcActionKind::SpentEpisode => format!("{CYAN}◌{RESET}"),
1213                };
1214                println!(
1215                    "  {icon} [{kind}] {name}",
1216                    kind = action.kind,
1217                    name = action.target_name,
1218                );
1219                println!("    {DIM}{reason}{RESET}", reason = action.reason);
1220            }
1221        }
1222
1223        if !report.errors.is_empty() {
1224            println!("\n{BOLD}Errors{RESET}");
1225            for err in &report.errors {
1226                println!("  \x1b[31m✗\x1b[0m {err}");
1227            }
1228        }
1229
1230        Ok(())
1231    })
1232    .await
1233}
1234
1235/// Apply an outcome to every entity a session touched.
1236///
1237/// A hot operation: it goes through the daemon like search and ingest, so it
1238/// can be run while a session is still using the store.
1239pub async fn feedback(
1240    memory_dir: &Path,
1241    session_id: &str,
1242    outcome: &str,
1243) -> Result<(), RecallError> {
1244    use crate::graph::utility::OutcomeKind;
1245    use crate::serve::FeedbackArgs;
1246
1247    let outcome: OutcomeKind = outcome.parse().map_err(RecallError::Other)?;
1248
1249    let request = Request::Feedback(FeedbackArgs {
1250        session_id: session_id.to_string(),
1251        outcome,
1252    });
1253    let report: crate::graph::utility::FeedbackReport =
1254        serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
1255
1256    if report.entities_updated == 0 && report.utilities.is_empty() {
1257        println!(
1258            "{YELLOW}No entities recorded for session {session_id}.{RESET} \
1259             {DIM}Nothing to apply the outcome to.{RESET}"
1260        );
1261        return Ok(());
1262    }
1263
1264    println!(
1265        "{GREEN}✓{RESET} Session {BOLD}{session_id}{RESET} recorded as {BOLD}{outcome}{RESET} \
1266         — {} entities updated",
1267        report.entities_updated
1268    );
1269
1270    for entity in &report.utilities {
1271        println!(
1272            "  {DIM}{}{RESET} utility {CYAN}{:.3}{RESET}",
1273            entity.entity_id, entity.utility_score
1274        );
1275    }
1276
1277    if !report.errors.is_empty() {
1278        println!("\n{YELLOW}Warnings:{RESET}");
1279        for err in &report.errors {
1280            println!("  {DIM}{err}{RESET}");
1281        }
1282    }
1283
1284    Ok(())
1285}
1286
1287// ── Correction ───────────────────────────────────────────────────────────
1288
1289/// One `graph correct` invocation, as the flags were given.
1290///
1291/// A struct rather than seven positional arguments, and unvalidated on purpose:
1292/// turning it into a [`CorrectTarget`] and a [`Correction`] is where the
1293/// contradictory combinations are named and refused.
1294#[derive(Debug, Clone)]
1295pub struct CorrectOptions {
1296    /// Entity name, or the source entity of a relationship.
1297    pub subject: String,
1298    /// Relationship type — only for a relationship target.
1299    pub rel_type: Option<String>,
1300    /// Target entity — only for a relationship target.
1301    pub object: Option<String>,
1302    /// Record contradicting evidence at user authority.
1303    pub wrong: bool,
1304    /// Remove outright.
1305    pub forget: bool,
1306    /// Contradict every relationship of an entity rather than being asked which.
1307    pub all_edges: bool,
1308    /// Skip the confirmation a removal otherwise requires.
1309    pub yes: bool,
1310}
1311
1312impl CorrectOptions {
1313    /// What the correction is aimed at.
1314    fn target(&self) -> Result<CorrectTarget, RecallError> {
1315        match (&self.rel_type, &self.object) {
1316            (None, None) => Ok(CorrectTarget::Entity {
1317                name: self.subject.clone(),
1318            }),
1319            (Some(rel_type), Some(object)) => Ok(CorrectTarget::Edge {
1320                from: self.subject.clone(),
1321                rel_type: rel_type.clone(),
1322                to: object.clone(),
1323            }),
1324            _ => Err(RecallError::Other(
1325                "a relationship needs all three names: \
1326                 `graph correct <from> <REL> <to> --wrong`"
1327                    .into(),
1328            )),
1329        }
1330    }
1331
1332    /// What to do to it.
1333    fn correction(&self) -> Result<Correction, RecallError> {
1334        match (self.wrong, self.forget) {
1335            (true, false) => Ok(Correction::Wrong {
1336                all_edges: self.all_edges,
1337            }),
1338            (false, true) => Ok(Correction::Forget { confirmed: false }),
1339            (true, true) => Err(RecallError::Other(
1340                "--wrong and --forget are different corrections; pass one".into(),
1341            )),
1342            (false, false) => Err(RecallError::Other(
1343                "say what to do: --wrong records that it is mistaken (confidence falls with \
1344                 evidence), --forget removes it outright"
1345                    .into(),
1346            )),
1347        }
1348    }
1349}
1350
1351/// Tell memory that something it learned is wrong.
1352///
1353/// A hot operation: it goes through the daemon like search and ingest, so a
1354/// correction lands while a session is still using the store.
1355pub async fn correct(memory_dir: &Path, options: &CorrectOptions) -> Result<(), RecallError> {
1356    let target = options.target()?;
1357    let report = send_correction(memory_dir, &target, options.correction()?).await?;
1358
1359    match report {
1360        // A removal is planned before it is applied, so the human sees what
1361        // goes before anything does.
1362        CorrectionReport::Planned { removal } => {
1363            print_removal_plan(&removal);
1364            if !options.yes && !confirm_removal(&removal)? {
1365                println!("{YELLOW}Nothing removed.{RESET}");
1366                return Ok(());
1367            }
1368            let applied =
1369                send_correction(memory_dir, &target, Correction::Forget { confirmed: true })
1370                    .await?;
1371            // Still through `refusal`: the graph can have moved between the
1372            // plan and the confirmation, and a removal that found nothing to
1373            // remove must not exit as though it had.
1374            print_correction(&applied);
1375            refusal(&applied)
1376        }
1377        other => {
1378            print_correction(&other);
1379            refusal(&other)
1380        }
1381    }
1382}
1383
1384async fn send_correction(
1385    memory_dir: &Path,
1386    target: &CorrectTarget,
1387    correction: Correction,
1388) -> Result<CorrectionReport, RecallError> {
1389    let request = Request::Correct(CorrectArgs {
1390        target: target.clone(),
1391        correction,
1392    });
1393    Ok(serde_json::from_value(
1394        serve_client::execute(memory_dir, &request).await?,
1395    )?)
1396}
1397
1398/// A correction that changed nothing exits non-zero: the user asked for a
1399/// change and did not get one, and a script must be able to tell.
1400fn refusal(report: &CorrectionReport) -> Result<(), RecallError> {
1401    match report {
1402        CorrectionReport::UnknownEntity { query, .. } => Err(RecallError::Other(format!(
1403            "no entity named \"{query}\" — nothing was changed"
1404        ))),
1405        CorrectionReport::NoSuchEdge {
1406            from, rel_type, to, ..
1407        } => Err(RecallError::Other(format!(
1408            "no {rel_type} relationship between \"{from}\" and \"{to}\" — nothing was changed"
1409        ))),
1410        CorrectionReport::Ambiguous { entity, .. } => Err(RecallError::Other(format!(
1411            "\"{entity}\" takes part in several relationships — name the one that is wrong"
1412        ))),
1413        CorrectionReport::NothingToCorrect { entity } => Err(RecallError::Other(format!(
1414            "\"{entity}\" has no relationships to contradict"
1415        ))),
1416        _ => Ok(()),
1417    }
1418}
1419
1420fn print_correction(report: &CorrectionReport) {
1421    match report {
1422        CorrectionReport::UnknownEntity { query, candidates } => {
1423            println!("{YELLOW}No entity named{RESET} {BOLD}{query}{RESET}.");
1424            if candidates.is_empty() {
1425                println!("  {DIM}Nothing stored is close to that name.{RESET}");
1426            } else {
1427                println!("\n  {DIM}Closest names in memory:{RESET}");
1428                for candidate in candidates {
1429                    println!(
1430                        "    {BOLD}{}{RESET} {DIM}({}){RESET}",
1431                        candidate.name, candidate.entity_type
1432                    );
1433                }
1434            }
1435        }
1436        CorrectionReport::NoSuchEdge {
1437            from,
1438            rel_type,
1439            to,
1440            existing,
1441        } => {
1442            println!(
1443                "{YELLOW}Memory holds no{RESET} {BOLD}{rel_type}{RESET} \
1444                 {YELLOW}relationship between{RESET} {BOLD}{from}{RESET} \
1445                 {YELLOW}and{RESET} {BOLD}{to}{RESET}."
1446            );
1447            if existing.is_empty() {
1448                println!("  {DIM}They are not connected at all.{RESET}");
1449            } else {
1450                println!("\n  {DIM}What does connect them:{RESET}");
1451                print_edges(existing);
1452            }
1453        }
1454        CorrectionReport::Ambiguous { entity, edges } => {
1455            println!(
1456                "{BOLD}{entity}{RESET} takes part in {} relationships. Which one is wrong?\n",
1457                edges.len()
1458            );
1459            print_edges(edges);
1460            println!("\n  {DIM}Name it:{RESET}");
1461            if let Some(edge) = edges.first() {
1462                println!(
1463                    "    {DIM}recall-echo graph correct \"{}\" \"{}\" \"{}\" --wrong{RESET}",
1464                    edge.from, edge.rel_type, edge.to
1465                );
1466            }
1467            println!("  {DIM}Or contradict every one of them:{RESET}");
1468            println!("    {DIM}recall-echo graph correct \"{entity}\" --wrong --all-edges{RESET}");
1469        }
1470        CorrectionReport::NothingToCorrect { entity } => {
1471            println!(
1472                "{BOLD}{entity}{RESET} is in memory but takes part in no relationships, \
1473                 so there is no claim to contradict."
1474            );
1475            println!(
1476                "  {DIM}To remove the entity itself: \
1477                 recall-echo graph correct \"{entity}\" --forget{RESET}"
1478            );
1479        }
1480        CorrectionReport::Contradicted { edges } => print_contradictions(edges),
1481        CorrectionReport::Planned { removal } => print_removal_plan(removal),
1482        CorrectionReport::Removed { removal } => {
1483            let entity = removal
1484                .entity
1485                .as_ref()
1486                .map(|entity| format!("{BOLD}{}{RESET} and ", entity.name))
1487                .unwrap_or_default();
1488            println!(
1489                "{GREEN}✓{RESET} Removed {entity}{} {}.",
1490                removal.edges.len(),
1491                plural(removal.edges.len(), "relationship", "relationships")
1492            );
1493        }
1494    }
1495}
1496
1497fn print_contradictions(edges: &[EdgeCorrection]) {
1498    println!(
1499        "{GREEN}✓{RESET} Recorded your correction on {} {}.\n",
1500        edges.len(),
1501        plural(edges.len(), "relationship", "relationships")
1502    );
1503    for correction in edges {
1504        let edge = &correction.edge;
1505        println!(
1506            "  {} {CYAN}—[{}]→{RESET} {}",
1507            edge.from, edge.rel_type, edge.to
1508        );
1509        println!(
1510            "    confidence {YELLOW}{:.2} → {:.2}{RESET}   {DIM}evidence {:.1} → {:.1}{RESET}",
1511            correction.confidence_before,
1512            edge.confidence,
1513            correction.evidence_before,
1514            edge.evidence,
1515        );
1516    }
1517    println!(
1518        "\n  {DIM}Your correction is evidence, not a decree: confidence falls by the weight of \
1519         one observation you authored. Say it again if memory is still wrong.{RESET}"
1520    );
1521}
1522
1523fn print_removal_plan(removal: &Removal) {
1524    println!("{BOLD}{YELLOW}This would remove:{RESET}\n");
1525    if let Some(entity) = &removal.entity {
1526        println!(
1527            "  {BOLD}{}{RESET} {DIM}({}){RESET}",
1528            entity.name, entity.entity_type
1529        );
1530    }
1531    if removal.edges.is_empty() {
1532        println!("  {DIM}and no relationships.{RESET}");
1533    } else {
1534        println!(
1535            "  {} {}:",
1536            removal.edges.len(),
1537            plural(removal.edges.len(), "relationship", "relationships")
1538        );
1539        print_edges(&removal.edges);
1540    }
1541    println!(
1542        "\n  {DIM}Removal is not evidence — it leaves no trace and cannot be re-weighed. \
1543         Prefer --wrong unless the memory should never have existed.{RESET}"
1544    );
1545}
1546
1547fn print_edges(edges: &[EdgeView]) {
1548    for edge in edges {
1549        let superseded = if edge.superseded {
1550            format!(" {DIM}[superseded]{RESET}")
1551        } else {
1552            String::new()
1553        };
1554        let coherence = if edge.self_reinforcements > 0 {
1555            format!(" {YELLOW}self×{}{RESET}", edge.self_reinforcements)
1556        } else {
1557            String::new()
1558        };
1559        println!(
1560            "    {} {CYAN}—[{}]→{RESET} {}  {:.0}%{coherence}{superseded}",
1561            edge.from,
1562            edge.rel_type,
1563            edge.to,
1564            edge.confidence * 100.0,
1565        );
1566    }
1567}
1568
1569/// Ask before destroying anything.
1570///
1571/// A non-interactive stdin is never taken as consent: a script that pipes into
1572/// this command must say `--yes` in the script, where a reader can see it.
1573///
1574/// The read blocks the runtime, which is what we want — there is nothing else
1575/// in flight, and the daemon connection was closed with the planning request.
1576fn confirm_removal(removal: &Removal) -> Result<bool, RecallError> {
1577    use std::io::{IsTerminal, Write};
1578
1579    if !std::io::stdin().is_terminal() {
1580        return Err(RecallError::Other(
1581            "refusing to remove memory without confirmation — re-run with --yes".into(),
1582        ));
1583    }
1584
1585    let what = removal
1586        .entity
1587        .as_ref()
1588        .map_or_else(|| "these relationships".to_string(), |e| e.name.clone());
1589    print!("{BOLD}Remove {what}?{RESET} [y/N] ");
1590    std::io::stdout().flush()?;
1591
1592    let mut answer = String::new();
1593    std::io::stdin().read_line(&mut answer)?;
1594    Ok(matches!(answer.trim().to_lowercase().as_str(), "y" | "yes"))
1595}
1596
1597fn plural(count: usize, one: &'static str, many: &'static str) -> &'static str {
1598    if count == 1 {
1599        one
1600    } else {
1601        many
1602    }
1603}
1604
1605/// Show relationship decay report — lists all relationships with their stored vs effective confidence.
1606pub async fn decay_report(
1607    memory_dir: &Path,
1608    entity_name: Option<&str>,
1609    show_all: bool,
1610) -> Result<(), RecallError> {
1611    use crate::graph::confidence;
1612    use crate::graph::types::Direction;
1613
1614    let graph_dir = memory_dir.join("graph");
1615    if !graph_dir.exists() {
1616        return Err(RecallError::NotInitialized(
1617            "Graph store not initialized. Run `recall-echo graph init` first.".into(),
1618        ));
1619    }
1620
1621    serve_client::exclusive(memory_dir, |gm| async move {
1622        let now = chrono::Utc::now();
1623
1624        let rels = if let Some(name) = entity_name {
1625            gm.get_relationships(name, Direction::Both).await?
1626        } else {
1627            crate::graph::crud::list_all_relationships(gm.db()).await?
1628        };
1629
1630        if rels.is_empty() {
1631            println!("{YELLOW}No relationships found.{RESET}");
1632            return Ok(());
1633        }
1634
1635        println!(
1636            "{BOLD}Decay Report{RESET} ({} relationships, half-life: {} days)\n",
1637            rels.len(),
1638            confidence::DEFAULT_HALF_LIFE_DAYS
1639        );
1640
1641        let mut decayed_count = 0u32;
1642        let mut total_decay = 0.0_f64;
1643
1644        for rel in &rels {
1645            let effective = confidence::effective_confidence(
1646                rel.confidence,
1647                rel.last_reinforced.as_ref(),
1648                &rel.valid_from,
1649                &now,
1650            );
1651
1652            let decay_amount = rel.confidence - effective;
1653            if decay_amount > 0.001 {
1654                decayed_count += 1;
1655            }
1656            total_decay += decay_amount;
1657
1658            if !show_all && decay_amount < 0.001 {
1659                continue;
1660            }
1661
1662            let from_short = match &rel.from_id {
1663                serde_json::Value::String(s) => s.split(':').next_back().unwrap_or(s).to_string(),
1664                other => other.to_string(),
1665            };
1666            let to_short = match &rel.to_id {
1667                serde_json::Value::String(s) => s.split(':').next_back().unwrap_or(s).to_string(),
1668                other => other.to_string(),
1669            };
1670
1671            let reinforced_tag = match &rel.last_reinforced {
1672                Some(serde_json::Value::String(s)) => format!(" {DIM}(reinforced: {s}){RESET}"),
1673                _ => String::new(),
1674            };
1675
1676            let decay_indicator = if decay_amount > 0.2 {
1677                format!("\x1b[31m↓{:.0}%\x1b[0m", decay_amount * 100.0)
1678            } else if decay_amount > 0.05 {
1679                format!("{YELLOW}↓{:.0}%{RESET}", decay_amount * 100.0)
1680            } else {
1681                format!("{DIM}≈{RESET}")
1682            };
1683
1684            // Evidence behind the score: how much corroboration it rests on,
1685            // and how much of that was the agent re-asserting itself.
1686            let edge_evidence = rel.edge_evidence();
1687            let coherence = edge_evidence.self_reinforcements();
1688            let evidence = rel.evidence();
1689            let evidence_tag = format!(
1690                " {DIM}[n={:.1} ±{:.2}{}]{RESET}",
1691                evidence.concentration(),
1692                evidence.variance().sqrt(),
1693                if coherence > 0 {
1694                    format!(", self×{coherence}")
1695                } else {
1696                    String::new()
1697                }
1698            );
1699
1700            println!(
1701                "  {from_short} {CYAN}—[{}]→{RESET} {to_short}  stored:{:.2} effective:{:.2} {decay_indicator}{evidence_tag}{reinforced_tag}",
1702                rel.rel_type, rel.confidence, effective,
1703            );
1704        }
1705
1706        println!(
1707            "\n{BOLD}Summary{RESET}: {decayed_count}/{} relationships decayed, avg decay: {:.3}",
1708            rels.len(),
1709            if rels.is_empty() {
1710                0.0
1711            } else {
1712                total_decay / rels.len() as f64
1713            }
1714        );
1715
1716        Ok(())
1717    })
1718    .await
1719}
1720
1721/// Find the archive file for a given log number.
1722#[cfg(feature = "llm")]
1723pub(crate) fn find_archive_file(
1724    conversations_dir: &Path,
1725    log_number: u32,
1726) -> Result<PathBuf, RecallError> {
1727    // Try both naming conventions
1728    let patterns = [
1729        format!("conversation-{log_number:03}.md"),
1730        format!("conversation-{log_number}.md"),
1731        format!("archive-log-{log_number:03}.md"),
1732        format!("archive-log-{log_number}.md"),
1733    ];
1734
1735    for name in &patterns {
1736        let path = conversations_dir.join(name);
1737        if path.exists() {
1738            return Ok(path);
1739        }
1740    }
1741
1742    Err(RecallError::Other(format!(
1743        "no archive file for log {log_number:03}",
1744    )))
1745}
1746
1747#[cfg(all(test, feature = "llm"))]
1748mod tests {
1749    use super::*;
1750
1751    /// The line a user reads must not claim a measurement it does not have.
1752    #[test]
1753    fn a_bill_says_which_of_its_numbers_were_measured() {
1754        assert_eq!(format_token_bill(13_663, 0), "14K measured");
1755        assert_eq!(format_token_bill(0, 2_500), "~2K estimated");
1756        assert_eq!(
1757            format_token_bill(13_663, 2_500),
1758            "14K measured + ~2K estimated"
1759        );
1760    }
1761
1762    /// A run that made no model calls has no bill to qualify.
1763    #[test]
1764    fn a_run_that_spent_nothing_says_zero() {
1765        assert_eq!(format_token_bill(0, 0), "0");
1766    }
1767}