Skip to main content

recall_echo/
inspect_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//! `recall-echo what-do-you-know` — memory, read out loud.
6//!
7//! Everything else in the CLI answers a question the user already knew to ask.
8//! This answers the one they have before they know anything: *what do you think
9//! you know?* It is deliberately readable rather than complete — a person
10//! should be able to scan it in ten seconds and think "yes, that's right" or
11//! "no, that's wrong", and the second thought needs somewhere to go, so every
12//! rendering points at `graph correct`.
13//!
14//! Nothing here retrieves. The overview is a projection of the store and the
15//! `--about` form is the ordinary hybrid query; this module only decides how
16//! the answer reads. Rendering builds a string and printing emits it, so what
17//! a person sees is what a test can assert.
18
19use std::fmt::Write as _;
20use std::path::Path;
21
22use crate::error::RecallError;
23use crate::graph::edge_view::EdgeView;
24use crate::graph::inspect::{
25    ConfidenceSummary, MemoryOverview, TopicEntity, TopicReport, DOUBTFUL_CONFIDENCE,
26    STRONG_CONFIDENCE,
27};
28use crate::graph::types::MatchSource;
29use crate::serve::{AboutArgs, OverviewArgs, Request};
30use crate::serve_client;
31
32const CYAN: &str = "\x1b[36m";
33const YELLOW: &str = "\x1b[33m";
34const BOLD: &str = "\x1b[1m";
35const DIM: &str = "\x1b[2m";
36const RESET: &str = "\x1b[0m";
37
38/// Longest abstract shown whole. Anything longer is a paragraph, and this is a
39/// summary.
40const MAX_ABSTRACT_CHARS: usize = 140;
41
42/// Report what memory holds — everything, or one subject.
43pub async fn run(
44    memory_dir: &Path,
45    about: Option<&str>,
46    per_type: usize,
47) -> Result<(), RecallError> {
48    require_graph(memory_dir)?;
49
50    let text = match about {
51        Some(topic) => {
52            let request = Request::About(AboutArgs {
53                topic: topic.to_string(),
54                limit: per_type,
55            });
56            let report: TopicReport =
57                serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
58            render_topic(&report)
59        }
60        None => {
61            let request = Request::Overview(OverviewArgs { per_type });
62            let overview: MemoryOverview =
63                serde_json::from_value(serve_client::execute(memory_dir, &request).await?)?;
64            render_overview(&overview)
65        }
66    };
67
68    print!("{text}");
69    Ok(())
70}
71
72fn require_graph(memory_dir: &Path) -> Result<(), RecallError> {
73    if memory_dir.join("graph").exists() {
74        return Ok(());
75    }
76    Err(RecallError::NotInitialized(
77        "Graph store not initialized. Run `recall-echo graph init` first.".into(),
78    ))
79}
80
81// ── Overview ─────────────────────────────────────────────────────────────
82
83/// Everything memory holds, as a person reads it.
84#[must_use]
85pub fn render_overview(overview: &MemoryOverview) -> String {
86    let stats = &overview.stats;
87    let mut out = format!("{BOLD}What I know{RESET}\n");
88
89    if stats.entity_count == 0 && stats.episode_count == 0 {
90        let _ = writeln!(out, "\n  {YELLOW}Nothing yet.{RESET}");
91        let _ = writeln!(
92            out,
93            "  {DIM}Memory fills when sessions end. Once conversations are archived, \
94             `recall-echo graph extract --all` distils them into what you see here.{RESET}"
95        );
96        return out;
97    }
98
99    let _ = writeln!(
100        out,
101        "\n  {} entities · {} relationships · {} conversation fragments",
102        stats.entity_count, stats.relationship_count, stats.episode_count
103    );
104
105    if stats.entity_count == 0 {
106        let _ = writeln!(
107            out,
108            "\n  {YELLOW}Conversations are stored but nothing has been distilled from them \
109             yet.{RESET}"
110        );
111        let _ = writeln!(out, "  {DIM}recall-echo graph extract --all{RESET}");
112        return out;
113    }
114
115    write_groups(&mut out, overview);
116    write_confidence(&mut out, &overview.confidence);
117    write_edge_section(&mut out, "Least certain", &overview.uncertain, None);
118    write_edge_section(
119        &mut out,
120        "Believed partly because I kept saying it",
121        &overview.self_reinforced,
122        Some(
123            "self×N counts corroborations I produced myself. They are kept out of the \
124             confidence above — repetition is coherence, not evidence.",
125        ),
126    );
127
128    let _ = writeln!(
129        out,
130        "\n  {DIM}Wrong about something? recall-echo graph correct \"<name>\" --wrong{RESET}"
131    );
132    out
133}
134
135fn write_groups(out: &mut String, overview: &MemoryOverview) {
136    for group in &overview.groups {
137        let _ = writeln!(
138            out,
139            "\n  {BOLD}{}{RESET} {DIM}({}){RESET}",
140            group.entity_type, group.count
141        );
142        for entity in &group.top {
143            let _ = writeln!(
144                out,
145                "    {BOLD}{}{RESET} — {}",
146                entity.name,
147                clip(&entity.abstract_text)
148            );
149        }
150        let listed = group.top.len() as u64;
151        if group.count > listed {
152            let _ = writeln!(out, "    {DIM}… and {} more{RESET}", group.count - listed);
153        }
154    }
155}
156
157fn write_confidence(out: &mut String, summary: &ConfidenceSummary) {
158    if summary.total() == 0 {
159        let _ = writeln!(
160            out,
161            "\n  {YELLOW}No relationships yet{RESET} — I know these things but have not \
162             connected them."
163        );
164        return;
165    }
166
167    let _ = writeln!(out, "\n  {BOLD}How sure I am{RESET}");
168    let _ = writeln!(
169        out,
170        "    {} of {} relationships firmly held {DIM}(≥{:.0}%){RESET}, {} uncertain, \
171         {} doubtful {DIM}(<{:.0}%){RESET}",
172        summary.strong,
173        summary.total(),
174        STRONG_CONFIDENCE * 100.0,
175        summary.uncertain,
176        summary.doubtful,
177        DOUBTFUL_CONFIDENCE * 100.0,
178    );
179    if is_mostly_unsure(summary) {
180        let _ = writeln!(
181            out,
182            "    {DIM}Most of what I hold is unsettled — treat it as a starting point, not as \
183             fact.{RESET}"
184        );
185    }
186}
187
188/// True when the graph believes less than half of what it holds firmly.
189fn is_mostly_unsure(summary: &ConfidenceSummary) -> bool {
190    summary.total() > 0 && summary.strong * 2 < summary.total()
191}
192
193fn write_edge_section(out: &mut String, heading: &str, edges: &[EdgeView], note: Option<&str>) {
194    if edges.is_empty() {
195        return;
196    }
197    let _ = writeln!(out, "\n  {BOLD}{heading}{RESET}");
198    for edge in edges {
199        write_edge_line(out, edge);
200    }
201    if let Some(note) = note {
202        let _ = writeln!(out, "    {DIM}{note}{RESET}");
203    }
204}
205
206fn write_edge_line(out: &mut String, edge: &EdgeView) {
207    let _ = writeln!(
208        out,
209        "    {} {CYAN}—[{}]→{RESET} {}  {} {DIM}({:.0}%, evidence {:.1}){RESET}{}",
210        edge.from,
211        edge.rel_type,
212        edge.to,
213        certainty(edge.confidence),
214        edge.confidence * 100.0,
215        edge.evidence,
216        coherence_tag(edge),
217    );
218}
219
220// ── One subject ──────────────────────────────────────────────────────────
221
222/// What memory holds about one subject, as a person reads it.
223#[must_use]
224pub fn render_topic(report: &TopicReport) -> String {
225    let mut out = format!("{BOLD}What I know about \"{}\"{RESET}\n", report.topic);
226
227    if report.entities.is_empty() {
228        let _ = writeln!(out, "\n  {YELLOW}Nothing distilled about that.{RESET}");
229        let _ = writeln!(
230            out,
231            "  {DIM}The raw conversations may still hold it: \
232             recall-echo graph query \"{}\" --episodes{RESET}",
233            report.topic
234        );
235        return out;
236    }
237
238    for entity in &report.entities {
239        write_topic_entity(&mut out, entity);
240    }
241
242    let _ = writeln!(
243        out,
244        "\n  {DIM}Wrong about something? recall-echo graph correct \"<from>\" \"<REL>\" \
245         \"<to>\" --wrong{RESET}"
246    );
247    out
248}
249
250fn write_topic_entity(out: &mut String, entity: &TopicEntity) {
251    let _ = writeln!(
252        out,
253        "\n  {BOLD}{}{RESET} {DIM}— {} · {} (score {:.2}){RESET}",
254        entity.entity.name,
255        entity.entity.entity_type,
256        how_it_was_found(&entity.source),
257        entity.score,
258    );
259    let _ = writeln!(out, "    {}", clip(&entity.entity.abstract_text));
260
261    if entity.edges.is_empty() {
262        let _ = writeln!(out, "    {DIM}Nothing else is recorded about it.{RESET}");
263        return;
264    }
265    for edge in &entity.edges {
266        write_edge_line(out, edge);
267    }
268    if entity.edges_omitted > 0 {
269        let _ = writeln!(
270            out,
271            "    {DIM}… and {} more relationships{RESET}",
272            entity.edges_omitted
273        );
274    }
275}
276
277fn how_it_was_found(source: &MatchSource) -> String {
278    match source {
279        MatchSource::Semantic => "matched directly".to_string(),
280        MatchSource::Keyword => "matched by keyword".to_string(),
281        MatchSource::Graph { parent, rel_type } => format!("reached from {parent} via {rel_type}"),
282    }
283}
284
285// ── Wording ──────────────────────────────────────────────────────────────
286
287/// How a posterior mean reads out loud.
288///
289/// The bands are the ones [`crate::graph::inspect`] counts by, so the summary
290/// line and the per-edge wording can never disagree.
291#[must_use]
292pub fn certainty(confidence: f64) -> &'static str {
293    match confidence {
294        c if c >= 0.9 => "near-certain",
295        c if c >= STRONG_CONFIDENCE => "confident",
296        c if c >= DOUBTFUL_CONFIDENCE => "unsure",
297        _ => "doubtful",
298    }
299}
300
301/// The "some of this is me agreeing with myself" marker, when there is one.
302fn coherence_tag(edge: &EdgeView) -> String {
303    if edge.self_reinforcements > 0 {
304        format!(" {YELLOW}self×{}{RESET}", edge.self_reinforcements)
305    } else {
306        String::new()
307    }
308}
309
310fn clip(text: &str) -> String {
311    let text = text.trim();
312    if text.chars().count() <= MAX_ABSTRACT_CHARS {
313        return text.to_string();
314    }
315    let mut clipped: String = text.chars().take(MAX_ABSTRACT_CHARS).collect();
316    clipped.push_str(" […]");
317    clipped
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use crate::graph::inspect::{KnownEntity, TypeGroup};
324    use crate::graph::types::{EntityDetail, EntityType, GraphStats};
325
326    fn stats(entities: u64, relationships: u64, episodes: u64) -> GraphStats {
327        GraphStats {
328            entity_count: entities,
329            relationship_count: relationships,
330            episode_count: episodes,
331            entity_type_counts: Default::default(),
332        }
333    }
334
335    fn edge(confidence: f64, self_reinforcements: i64) -> EdgeView {
336        EdgeView {
337            id: "relates_to:1".into(),
338            from: "Echo".into(),
339            to: "NixOS".into(),
340            rel_type: "USES".into(),
341            description: None,
342            confidence,
343            evidence: 12.4,
344            self_reinforcements,
345            superseded: false,
346        }
347    }
348
349    fn overview_with(groups: Vec<TypeGroup>, stats: GraphStats) -> MemoryOverview {
350        MemoryOverview {
351            stats,
352            groups,
353            confidence: ConfidenceSummary::default(),
354            uncertain: Vec::new(),
355            self_reinforced: Vec::new(),
356        }
357    }
358
359    fn one_group() -> Vec<TypeGroup> {
360        vec![TypeGroup {
361            entity_type: "project".into(),
362            count: 4,
363            top: vec![KnownEntity {
364                id: "entity:recall-echo".into(),
365                name: "recall-echo".into(),
366                entity_type: "project".into(),
367                abstract_text: "Persistent memory with a knowledge graph.".into(),
368                access_count: 9,
369                utility_score: 0.8,
370            }],
371        }]
372    }
373
374    #[test]
375    fn an_empty_graph_says_so_and_says_what_fills_it() {
376        let text = render_overview(&overview_with(Vec::new(), stats(0, 0, 0)));
377        assert!(text.contains("Nothing yet."), "{text}");
378        assert!(text.contains("graph extract --all"), "{text}");
379        // Confidence over nothing is not a number worth printing.
380        assert!(!text.contains("firmly held"), "{text}");
381    }
382
383    #[test]
384    fn conversations_without_entities_point_at_extraction() {
385        let text = render_overview(&overview_with(Vec::new(), stats(0, 0, 120)));
386        assert!(text.contains("120 conversation fragments"), "{text}");
387        assert!(text.contains("nothing has been distilled"), "{text}");
388    }
389
390    #[test]
391    fn entities_without_relationships_say_they_are_unconnected() {
392        let text = render_overview(&overview_with(one_group(), stats(4, 0, 12)));
393        assert!(text.contains("recall-echo"), "{text}");
394        assert!(text.contains("… and 3 more"), "{text}");
395        assert!(text.contains("No relationships yet"), "{text}");
396        assert!(!text.contains("firmly held"), "{text}");
397    }
398
399    #[test]
400    fn a_self_reinforced_edge_shows_its_tally_and_what_it_means() {
401        let mut overview = overview_with(one_group(), stats(4, 3, 12));
402        overview.confidence = ConfidenceSummary {
403            strong: 3,
404            uncertain: 0,
405            doubtful: 0,
406        };
407        overview.self_reinforced = vec![edge(0.88, 23)];
408        let text = render_overview(&overview);
409
410        assert!(text.contains("self×23"), "{text}");
411        assert!(
412            text.contains("repetition is coherence, not evidence"),
413            "{text}"
414        );
415        assert!(text.contains("—[USES]→"), "{text}");
416        assert!(text.contains("NixOS"), "{text}");
417    }
418
419    #[test]
420    fn an_edge_nobody_repeated_carries_no_tally() {
421        let mut overview = overview_with(one_group(), stats(4, 1, 12));
422        overview.confidence = ConfidenceSummary {
423            strong: 0,
424            uncertain: 0,
425            doubtful: 1,
426        };
427        overview.uncertain = vec![edge(0.31, 0)];
428        let text = render_overview(&overview);
429
430        assert!(text.contains("Least certain"), "{text}");
431        assert!(text.contains("doubtful"), "{text}");
432        assert!(!text.contains("self×"), "{text}");
433        // Believing less than half of itself is worth admitting.
434        assert!(text.contains("Most of what I hold is unsettled"), "{text}");
435    }
436
437    #[test]
438    fn every_overview_offers_the_way_to_correct_it() {
439        let text = render_overview(&overview_with(one_group(), stats(4, 0, 1)));
440        assert!(text.contains("graph correct"), "{text}");
441    }
442
443    fn topic_entity(edges: Vec<EdgeView>, omitted: usize) -> TopicEntity {
444        TopicEntity {
445            entity: EntityDetail {
446                id: serde_json::json!("entity:nixos"),
447                name: "NixOS".into(),
448                entity_type: EntityType::Tool,
449                abstract_text: "Declarative Linux distribution.".into(),
450                overview: String::new(),
451                attributes: None,
452                access_count: 3,
453                utility_score: 0.5,
454                updated_at: serde_json::json!("2026-05-01T09:15:30Z"),
455                source: None,
456            },
457            score: 0.81,
458            source: MatchSource::Semantic,
459            edges,
460            edges_omitted: omitted,
461        }
462    }
463
464    #[test]
465    fn a_topic_reads_as_claims_with_certainty_attached() {
466        let report = TopicReport {
467            topic: "nixos".into(),
468            entities: vec![topic_entity(vec![edge(0.92, 4)], 3)],
469        };
470        let text = render_topic(&report);
471
472        assert!(text.contains("What I know about \"nixos\""), "{text}");
473        assert!(text.contains("NixOS"), "{text}");
474        assert!(text.contains("matched directly"), "{text}");
475        assert!(text.contains("near-certain"), "{text}");
476        assert!(text.contains("self×4"), "{text}");
477        assert!(text.contains("… and 3 more relationships"), "{text}");
478    }
479
480    #[test]
481    fn a_subject_with_no_entities_points_at_the_raw_conversations() {
482        let text = render_topic(&TopicReport {
483            topic: "docker".into(),
484            entities: Vec::new(),
485        });
486        assert!(text.contains("Nothing distilled"), "{text}");
487        assert!(text.contains("--episodes"), "{text}");
488    }
489
490    #[test]
491    fn an_entity_with_no_relationships_says_that_plainly() {
492        let text = render_topic(&TopicReport {
493            topic: "nixos".into(),
494            entities: vec![topic_entity(Vec::new(), 0)],
495        });
496        assert!(text.contains("Nothing else is recorded about it"), "{text}");
497    }
498
499    #[test]
500    fn certainty_matches_the_bands_the_summary_counts_by() {
501        assert_eq!(certainty(1.0), "near-certain");
502        assert_eq!(certainty(STRONG_CONFIDENCE), "confident");
503        assert_eq!(certainty(DOUBTFUL_CONFIDENCE), "unsure");
504        assert_eq!(certainty(0.1), "doubtful");
505    }
506
507    #[test]
508    fn a_graph_believing_less_than_half_of_itself_says_so() {
509        assert!(is_mostly_unsure(&ConfidenceSummary {
510            strong: 4,
511            uncertain: 5,
512            doubtful: 1,
513        }));
514        assert!(!is_mostly_unsure(&ConfidenceSummary {
515            strong: 6,
516            uncertain: 4,
517            doubtful: 0,
518        }));
519        assert!(!is_mostly_unsure(&ConfidenceSummary::default()));
520    }
521
522    #[test]
523    fn long_abstracts_are_clipped_on_a_character_boundary() {
524        let long = "é".repeat(MAX_ABSTRACT_CHARS * 2);
525        let clipped = clip(&long);
526        assert!(clipped.ends_with(" […]"));
527        assert!(clipped.chars().count() < long.chars().count());
528        assert_eq!(clip("  short  "), "short");
529    }
530
531    #[test]
532    fn only_a_non_zero_tally_is_shown() {
533        assert!(coherence_tag(&edge(0.88, 0)).is_empty());
534        assert!(coherence_tag(&edge(0.88, 23)).contains("self×23"));
535    }
536}