1use 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
38const MAX_ABSTRACT_CHARS: usize = 140;
41
42pub 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#[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
188fn 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#[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#[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
301fn 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 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 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}