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