Skip to main content

recall_echo/mcp/
render.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//! Turning retrieval results into text an LLM can use.
6//!
7//! The daemon answers in JSON built for programs: record ids, distances,
8//! nested traversal nodes. Handing that to a model wastes context on syntax
9//! and buries the parts that matter. Everything here renders the same data as
10//! compact prose-with-structure, keeps the numbers a reader would act on
11//! (retrieval score, similarity, edge confidence, utility) and drops the ones
12//! nobody reads.
13//!
14//! Every renderer is total: an empty result set produces guidance about what
15//! to try next, not an empty string.
16
17use std::fmt::Write as _;
18
19use serde_json::Value;
20
21use crate::graph::edge_view::EdgeView;
22use crate::graph::inspect::{MemoryOverview, DOUBTFUL_CONFIDENCE, STRONG_CONFIDENCE};
23use crate::graph::traverse::format_traversal;
24use crate::graph::types::{
25    EntityDetail, EpisodeSearchResult, GraphStats, MatchSource, QueryResult, ScoredEntity,
26    TraversalNode,
27};
28
29/// Longest abstract kept verbatim.
30const MAX_ABSTRACT_CHARS: usize = 300;
31/// Longest overview kept verbatim. Overviews are the L1 tier — worth showing,
32/// not worth showing whole.
33const MAX_OVERVIEW_CHARS: usize = 400;
34/// Longest verbatim excerpt of one episode's original text.
35const MAX_EPISODE_CHARS: usize = 1_200;
36/// Ceiling on a whole tool result. A memory lookup that eats the context
37/// window defeats its own purpose.
38const MAX_RESULT_CHARS: usize = 24_000;
39/// The neutral utility score an entity carries until outcome feedback moves
40/// it. Reporting it would be reporting the absence of information.
41const NEUTRAL_UTILITY: f64 = 0.5;
42
43/// Entity search results.
44#[must_use]
45pub fn entities(query: &str, results: &[ScoredEntity]) -> String {
46    if results.is_empty() {
47        return format!(
48            "No entities in memory match \"{query}\".\n\
49             Entities are distilled knowledge; the raw conversations may still hold it — try \
50             recall_episodes. If recall_status shows an empty graph, nothing has been ingested \
51             yet."
52        );
53    }
54
55    let mut out = format!(
56        "{} {} in memory for \"{query}\":\n",
57        results.len(),
58        plural(results.len(), "entity", "entities")
59    );
60    for (index, result) in results.iter().enumerate() {
61        write_entity(&mut out, index + 1, result);
62    }
63    budget(out)
64}
65
66/// Hybrid query results: entities, then the episodes behind them.
67#[must_use]
68pub fn query_result(query: &str, result: &QueryResult) -> String {
69    if result.entities.is_empty() && result.episodes.is_empty() {
70        return format!(
71            "Memory holds nothing about \"{query}\".\n\
72             Either it was never discussed, or it has not been ingested yet — recall_status \
73             says which."
74        );
75    }
76
77    let mut out = format!("Memory for \"{query}\":\n");
78
79    if result.entities.is_empty() {
80        out.push_str("\nNo distilled entities matched, but these conversations did.\n");
81    } else {
82        let _ = writeln!(
83            out,
84            "\n{} {}:",
85            result.entities.len(),
86            plural(result.entities.len(), "entity", "entities")
87        );
88        for (index, entity) in result.entities.iter().enumerate() {
89            write_entity(&mut out, index + 1, entity);
90        }
91    }
92
93    if !result.episodes.is_empty() {
94        let _ = writeln!(
95            out,
96            "\n{} conversation {}:",
97            result.episodes.len(),
98            plural(result.episodes.len(), "fragment", "fragments")
99        );
100        for (index, episode) in result.episodes.iter().enumerate() {
101            write_episode(&mut out, index + 1, episode);
102        }
103    }
104
105    budget(out)
106}
107
108/// Episode search results.
109#[must_use]
110pub fn episodes(query: &str, results: &[EpisodeSearchResult]) -> String {
111    if results.is_empty() {
112        return format!(
113            "No past conversation in memory matches \"{query}\".\n\
114             If recall_status shows episodes exist, the topic is genuinely absent; if it shows \
115             none, no sessions have been archived into the graph yet."
116        );
117    }
118
119    let mut out = format!(
120        "{} conversation {} for \"{query}\":\n",
121        results.len(),
122        plural(results.len(), "fragment", "fragments")
123    );
124    for (index, result) in results.iter().enumerate() {
125        write_episode(&mut out, index + 1, result);
126    }
127    budget(out)
128}
129
130/// A traversal tree rooted at one entity.
131#[must_use]
132pub fn traversal(entity: &str, depth: u32, node: &TraversalNode) -> String {
133    if node.edges.is_empty() {
134        return format!(
135            "\"{}\" ({}) exists in memory but has no relationships recorded within {depth} \
136             {}.\nIts own description: {}",
137            node.entity.name,
138            node.entity.entity_type,
139            plural(depth as usize, "hop", "hops"),
140            clip(&node.entity.abstract_text, MAX_ABSTRACT_CHARS)
141        );
142    }
143
144    let tree = format_traversal(node, 0);
145    let mut out = format!(
146        "Relationships from \"{entity}\", up to {depth} {}:\n\n{tree}",
147        plural(depth as usize, "hop", "hops")
148    );
149    if tree.contains('%') || tree.contains("[superseded]") {
150        out.push_str(
151            "\nA percentage is the edge's accumulated confidence (absent means fully \
152             corroborated); [superseded] marks a relationship that was true once and no longer \
153             is.\n",
154        );
155    }
156    budget(out)
157}
158
159/// Graph counts.
160#[must_use]
161pub fn status(stats: &GraphStats) -> String {
162    let mut out = format!(
163        "Memory graph: {} {}, {} {}, {} conversation {}.\n",
164        stats.entity_count,
165        plural(stats.entity_count as usize, "entity", "entities"),
166        stats.relationship_count,
167        plural(
168            stats.relationship_count as usize,
169            "relationship",
170            "relationships"
171        ),
172        stats.episode_count,
173        plural(stats.episode_count as usize, "episode", "episodes"),
174    );
175
176    if !stats.entity_type_counts.is_empty() {
177        let mut types: Vec<_> = stats.entity_type_counts.iter().collect();
178        types.sort_by(|left, right| right.1.cmp(left.1).then_with(|| left.0.cmp(right.0)));
179        let listed: Vec<String> = types
180            .iter()
181            .map(|(name, count)| format!("{name} {count}"))
182            .collect();
183        let _ = writeln!(out, "By type: {}.", listed.join(", "));
184    }
185
186    if stats.entity_count == 0 && stats.episode_count == 0 {
187        out.push_str(
188            "The graph is empty: no sessions have been ingested, so recall tools will find \
189             nothing.\n",
190        );
191    } else if stats.entity_count == 0 {
192        out.push_str(
193            "Conversations have been ingested but never distilled into entities, so \
194             recall_search and recall_query will be thin — recall_episodes still works.\n",
195        );
196    }
197
198    budget(out)
199}
200
201/// What memory holds, unprompted.
202///
203/// The uncertainty is the point. An agent reading this should be able to tell
204/// a settled fact from one the graph is holding onto out of habit, and say so
205/// to the user instead of presenting both the same way.
206#[must_use]
207pub fn overview(overview: &MemoryOverview) -> String {
208    let stats = &overview.stats;
209    if stats.entity_count == 0 && stats.episode_count == 0 {
210        return "Memory is empty: no sessions have been ingested, so there is nothing known \
211                about this user or their work yet.\n"
212            .to_string();
213    }
214
215    let mut out = format!(
216        "Memory holds {} {}, {} {} and {} conversation {}.\n",
217        stats.entity_count,
218        plural(stats.entity_count as usize, "entity", "entities"),
219        stats.relationship_count,
220        plural(
221            stats.relationship_count as usize,
222            "relationship",
223            "relationships"
224        ),
225        stats.episode_count,
226        plural(stats.episode_count as usize, "episode", "episodes"),
227    );
228
229    if stats.entity_count == 0 {
230        out.push_str(
231            "Nothing has been distilled from those conversations yet, so only recall_episodes \
232             will find anything.\n",
233        );
234        return budget(out);
235    }
236
237    for group in &overview.groups {
238        let _ = writeln!(out, "\n{} ({}):", group.entity_type, group.count);
239        for entity in &group.top {
240            let _ = writeln!(
241                out,
242                "- {} — {}",
243                entity.name,
244                clip(&entity.abstract_text, MAX_ABSTRACT_CHARS)
245            );
246        }
247        let listed = group.top.len() as u64;
248        if group.count > listed {
249            let _ = writeln!(out, "- … and {} more", group.count - listed);
250        }
251    }
252
253    let confidence = &overview.confidence;
254    if confidence.total() == 0 {
255        out.push_str(
256            "\nNo relationships are recorded: these entities are known but unconnected.\n",
257        );
258    } else {
259        let _ = writeln!(
260            out,
261            "\nConfidence: {} of {} relationships firmly held (at or above {:.0}%), {} uncertain, \
262             {} doubtful (below {:.0}%).",
263            confidence.strong,
264            confidence.total(),
265            STRONG_CONFIDENCE * 100.0,
266            confidence.uncertain,
267            confidence.doubtful,
268            DOUBTFUL_CONFIDENCE * 100.0,
269        );
270    }
271
272    write_edge_list(
273        &mut out,
274        "Least certain",
275        &overview.uncertain,
276        "Treat these as open questions rather than facts.",
277    );
278    write_edge_list(
279        &mut out,
280        "Resting on repetition",
281        &overview.self_reinforced,
282        "self×N counts corroborations the agent authored itself. That tally is deliberately \
283         kept out of the confidence above, so a high one means the belief rests on being \
284         restated rather than on independent evidence — say so rather than asserting it.",
285    );
286
287    budget(out)
288}
289
290fn write_edge_list(out: &mut String, heading: &str, edges: &[EdgeView], note: &str) {
291    if edges.is_empty() {
292        return;
293    }
294    let _ = writeln!(out, "\n{heading}:");
295    for edge in edges {
296        let coherence = if edge.self_reinforcements > 0 {
297            format!(", self×{}", edge.self_reinforcements)
298        } else {
299            String::new()
300        };
301        let _ = writeln!(
302            out,
303            "- {} ({:.0}%{coherence})",
304            edge.arrow(),
305            edge.confidence * 100.0
306        );
307    }
308    let _ = writeln!(out, "{note}");
309}
310
311// ── Pieces ───────────────────────────────────────────────────────────────
312
313fn write_entity(out: &mut String, position: usize, result: &ScoredEntity) {
314    let entity = &result.entity;
315    let _ = writeln!(
316        out,
317        "\n{position}. {} [{}] — score {:.2}, {}",
318        entity.name,
319        entity.entity_type,
320        result.score,
321        match_source(&result.source)
322    );
323    let _ = writeln!(
324        out,
325        "   {}",
326        clip(&entity.abstract_text, MAX_ABSTRACT_CHARS)
327    );
328    if adds_detail(entity) {
329        let _ = writeln!(out, "   {}", clip(&entity.overview, MAX_OVERVIEW_CHARS));
330    }
331    if let Some(provenance) = entity_provenance(entity) {
332        let _ = writeln!(out, "   {provenance}");
333    }
334}
335
336/// The overview is worth its tokens only when it says more than the abstract
337/// already did.
338fn adds_detail(entity: &EntityDetail) -> bool {
339    let overview = entity.overview.trim();
340    !overview.is_empty() && overview != entity.abstract_text.trim()
341}
342
343/// The line that says how much to trust this entity and where it came from.
344fn entity_provenance(entity: &EntityDetail) -> Option<String> {
345    let mut parts = Vec::new();
346    let updated = short_time(&entity.updated_at);
347    if !updated.is_empty() {
348        parts.push(format!("updated {updated}"));
349    }
350    if let Some(source) = entity.source.as_deref().filter(|s| !s.trim().is_empty()) {
351        parts.push(format!("from {source}"));
352    }
353    if (entity.utility_score - NEUTRAL_UTILITY).abs() > 0.005 {
354        parts.push(format!("usefulness {:.2}", entity.utility_score));
355    }
356    (!parts.is_empty()).then(|| parts.join(" · "))
357}
358
359fn match_source(source: &MatchSource) -> String {
360    match source {
361        MatchSource::Semantic => "matched directly".to_string(),
362        MatchSource::Keyword => "matched by keyword".to_string(),
363        MatchSource::Graph { parent, rel_type } => {
364            format!("reached from \"{parent}\" via {rel_type}")
365        }
366    }
367}
368
369fn write_episode(out: &mut String, position: usize, result: &EpisodeSearchResult) {
370    let episode = &result.episode;
371    let mut header = format!("\n{position}. session {}", episode.session_id);
372    if let Some(log) = episode.log_number {
373        let _ = write!(header, ", archive log #{log}");
374    }
375    let timestamp = short_time(&episode.timestamp);
376    if !timestamp.is_empty() {
377        let _ = write!(header, ", {timestamp}");
378    }
379    let _ = writeln!(
380        out,
381        "{header} — score {:.2}, similarity {:.2}",
382        result.score,
383        1.0 - result.distance
384    );
385    let _ = writeln!(
386        out,
387        "   {}",
388        clip(&episode.abstract_text, MAX_ABSTRACT_CHARS)
389    );
390
391    // The chunk itself is the reason to call this tool at all: the abstract is
392    // a label, the content is what was said.
393    if let Some(content) = episode.content.as_deref().filter(|c| !c.trim().is_empty()) {
394        let excerpt = clip(content, MAX_EPISODE_CHARS);
395        if excerpt != episode.abstract_text.trim() {
396            let _ = writeln!(out, "   ---\n{}", indent(&excerpt, "   "));
397        }
398    }
399}
400
401// ── Text utilities ───────────────────────────────────────────────────────
402
403/// Trim to `max` characters on a character boundary, marking the cut.
404fn clip(text: &str, max: usize) -> String {
405    let text = text.trim();
406    if text.chars().count() <= max {
407        return text.to_string();
408    }
409    let mut clipped: String = text.chars().take(max).collect();
410    clipped.push_str(" […]");
411    clipped
412}
413
414fn indent(text: &str, prefix: &str) -> String {
415    text.lines()
416        .map(|line| format!("{prefix}{line}"))
417        .collect::<Vec<_>>()
418        .join("\n")
419}
420
421/// Timestamps arrive as JSON scalars. Keep them to seconds — sub-second
422/// precision on a memory from last March is noise.
423fn short_time(value: &Value) -> String {
424    let raw = match value {
425        Value::Null => return String::new(),
426        Value::String(text) => text.clone(),
427        other => other.to_string(),
428    };
429    match raw.find('.') {
430        Some(dot) if raw.contains('T') => raw[..dot].to_string(),
431        _ => raw,
432    }
433}
434
435fn plural(count: usize, one: &'static str, many: &'static str) -> &'static str {
436    if count == 1 {
437        one
438    } else {
439        many
440    }
441}
442
443/// Hold a rendered result inside [`MAX_RESULT_CHARS`], saying so when it cuts.
444fn budget(text: String) -> String {
445    if text.chars().count() <= MAX_RESULT_CHARS {
446        return text;
447    }
448    let mut clipped: String = text.chars().take(MAX_RESULT_CHARS).collect();
449    clipped.push_str("\n\n[result truncated — ask a narrower question or lower `limit`]");
450    clipped
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456    use crate::graph::types::{EntitySummary, EntityType, Episode, TraversalEdge};
457    use serde_json::json;
458
459    fn entity(name: &str) -> EntityDetail {
460        EntityDetail {
461            id: json!(format!("entity:{name}")),
462            name: name.to_string(),
463            entity_type: EntityType::Tool,
464            abstract_text: format!("{name} is a thing."),
465            overview: format!("{name} does something in more words than the abstract."),
466            attributes: None,
467            access_count: 3,
468            utility_score: NEUTRAL_UTILITY,
469            updated_at: json!("2026-05-01T09:15:30.123456Z"),
470            source: Some("archive-log-042".into()),
471        }
472    }
473
474    fn scored(name: &str, score: f64, source: MatchSource) -> ScoredEntity {
475        ScoredEntity {
476            entity: entity(name),
477            score,
478            // Fixtures only need a self-consistent value; the render layer
479            // shows similarity for episodes, not entities.
480            similarity: score,
481            source,
482        }
483    }
484
485    fn episode(session: &str, content: &str) -> EpisodeSearchResult {
486        EpisodeSearchResult {
487            episode: Episode {
488                id: json!("episode:1"),
489                session_id: session.to_string(),
490                timestamp: json!("2026-04-02T18:00:00Z"),
491                abstract_text: "A chat about deploys.".into(),
492                overview: None,
493                content: Some(content.to_string()),
494                embedding: None,
495                log_number: Some(42),
496                provenance: Some("human".into()),
497                access_count: 0,
498            },
499            score: 0.71,
500            distance: 0.32,
501        }
502    }
503
504    #[test]
505    fn empty_entity_search_points_at_the_next_move() {
506        let text = entities("deploys", &[]);
507        assert!(text.contains("No entities"));
508        assert!(text.contains("recall_episodes"));
509        assert!(text.contains("recall_status"));
510    }
511
512    #[test]
513    fn entities_carry_name_type_score_and_provenance() {
514        let text = entities("rust", &[scored("Rust", 0.812, MatchSource::Semantic)]);
515        assert!(
516            text.contains("1. Rust [tool] — score 0.81, matched directly"),
517            "{text}"
518        );
519        assert!(text.contains("Rust is a thing."), "{text}");
520        assert!(text.contains("updated 2026-05-01T09:15:30"), "{text}");
521        assert!(text.contains("from archive-log-042"), "{text}");
522        // Neutral usefulness is the absence of feedback, not a fact.
523        assert!(!text.contains("usefulness"), "{text}");
524    }
525
526    #[test]
527    fn graph_reached_entities_say_how_they_were_reached() {
528        let text = entities(
529            "rust",
530            &[scored(
531                "Cargo",
532                0.4,
533                MatchSource::Graph {
534                    parent: "Rust".into(),
535                    rel_type: "USES".into(),
536                },
537            )],
538        );
539        assert!(text.contains("reached from \"Rust\" via USES"), "{text}");
540    }
541
542    #[test]
543    fn moved_usefulness_is_reported() {
544        let mut result = scored("Rust", 0.5, MatchSource::Semantic);
545        result.entity.utility_score = 0.82;
546        let text = entities("rust", &[result]);
547        assert!(text.contains("usefulness 0.82"), "{text}");
548    }
549
550    #[test]
551    fn identical_overview_is_not_repeated() {
552        let mut result = scored("Rust", 0.5, MatchSource::Semantic);
553        result.entity.overview = result.entity.abstract_text.clone();
554        let text = entities("rust", &[result]);
555        assert_eq!(text.matches("Rust is a thing.").count(), 1, "{text}");
556    }
557
558    #[test]
559    fn episodes_report_similarity_and_the_original_text() {
560        let text = episodes(
561            "deploys",
562            &[episode("abc123", "We ran cargo dist and it broke.")],
563        );
564        assert!(text.contains("session abc123"), "{text}");
565        assert!(text.contains("archive log #42"), "{text}");
566        assert!(text.contains("similarity 0.68"), "{text}");
567        assert!(text.contains("We ran cargo dist and it broke."), "{text}");
568    }
569
570    #[test]
571    fn long_episode_content_is_clipped() {
572        let long = "x".repeat(MAX_EPISODE_CHARS * 2);
573        let text = episodes("deploys", &[episode("abc123", &long)]);
574        assert!(text.contains("[…]"), "{text}");
575        assert!(text.chars().count() < long.chars().count());
576    }
577
578    #[test]
579    fn query_result_separates_entities_from_fragments() {
580        let result = QueryResult {
581            entities: vec![scored("Rust", 0.9, MatchSource::Semantic)],
582            episodes: vec![episode("abc123", "some talk")],
583        };
584        let text = query_result("rust", &result);
585        assert!(text.contains("1 entity:"), "{text}");
586        assert!(text.contains("1 conversation fragment:"), "{text}");
587    }
588
589    #[test]
590    fn empty_query_result_explains_the_two_possibilities() {
591        let result = QueryResult {
592            entities: Vec::new(),
593            episodes: Vec::new(),
594        };
595        let text = query_result("nothing", &result);
596        assert!(text.contains("recall_status"), "{text}");
597    }
598
599    fn leaf(name: &str) -> TraversalNode {
600        TraversalNode {
601            entity: EntitySummary {
602                id: json!(format!("entity:{name}")),
603                name: name.to_string(),
604                entity_type: EntityType::Tool,
605                abstract_text: format!("{name} is a thing."),
606            },
607            edges: Vec::new(),
608        }
609    }
610
611    #[test]
612    fn a_lone_entity_says_so_instead_of_printing_an_empty_tree() {
613        let text = traversal("Rust", 2, &leaf("Rust"));
614        assert!(text.contains("no relationships recorded"), "{text}");
615        assert!(text.contains("Rust is a thing."), "{text}");
616    }
617
618    #[test]
619    fn uncertain_edges_get_a_legend() {
620        let mut root = leaf("Rust");
621        root.edges.push(TraversalEdge {
622            rel_type: "USES".into(),
623            direction: "->".into(),
624            target: leaf("Cargo"),
625            valid_from: json!("2026-01-01T00:00:00Z"),
626            valid_until: None,
627            confidence: 0.62,
628        });
629        let text = traversal("Rust", 1, &root);
630        assert!(text.contains("[62%]"), "{text}");
631        assert!(text.contains("accumulated confidence"), "{text}");
632    }
633
634    #[test]
635    fn certain_edges_get_no_legend() {
636        let mut root = leaf("Rust");
637        root.edges.push(TraversalEdge {
638            rel_type: "USES".into(),
639            direction: "->".into(),
640            target: leaf("Cargo"),
641            valid_from: json!("2026-01-01T00:00:00Z"),
642            valid_until: None,
643            confidence: 1.0,
644        });
645        let text = traversal("Rust", 1, &root);
646        assert!(!text.contains("accumulated confidence"), "{text}");
647    }
648
649    #[test]
650    fn status_reports_counts_and_flags_an_empty_graph() {
651        let empty = GraphStats {
652            entity_count: 0,
653            relationship_count: 0,
654            episode_count: 0,
655            entity_type_counts: Default::default(),
656        };
657        let text = status(&empty);
658        assert!(text.contains("0 entities"), "{text}");
659        assert!(text.contains("The graph is empty"), "{text}");
660    }
661
662    #[test]
663    fn status_flags_episodes_without_entities() {
664        let stats = GraphStats {
665            entity_count: 0,
666            relationship_count: 0,
667            episode_count: 120,
668            entity_type_counts: Default::default(),
669        };
670        let text = status(&stats);
671        assert!(text.contains("never distilled"), "{text}");
672        assert!(text.contains("recall_episodes"), "{text}");
673    }
674
675    #[test]
676    fn status_lists_types_by_descending_count() {
677        let mut counts = std::collections::HashMap::new();
678        counts.insert("tool".to_string(), 3);
679        counts.insert("project".to_string(), 9);
680        let stats = GraphStats {
681            entity_count: 12,
682            relationship_count: 4,
683            episode_count: 1,
684            entity_type_counts: counts,
685        };
686        let text = status(&stats);
687        assert!(text.contains("By type: project 9, tool 3."), "{text}");
688        assert!(text.contains("1 conversation episode."), "{text}");
689    }
690
691    #[test]
692    fn results_stay_inside_the_character_budget() {
693        let long = "y".repeat(MAX_RESULT_CHARS * 2);
694        let clipped = budget(long);
695        assert!(clipped.contains("result truncated"));
696        assert!(clipped.chars().count() < MAX_RESULT_CHARS + 100);
697    }
698
699    #[test]
700    fn clip_respects_character_boundaries() {
701        let text = "é".repeat(10);
702        assert_eq!(clip(&text, 3), "ééé […]");
703        assert_eq!(clip(&text, 50), text);
704    }
705}