Skip to main content

recall_echo/
conversation.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//! Core conversation types and processing.
6//!
7//! Defines recall-echo's own conversation types — these are the universal
8//! internal format. All input adapters (JSONL transcripts, pulse-null Messages)
9//! produce these types, which then flow into the archive pipeline.
10
11use std::collections::HashMap;
12use std::fmt::Write as _;
13
14// ---------------------------------------------------------------------------
15// Core types
16// ---------------------------------------------------------------------------
17
18/// A parsed conversation entry — the universal internal format.
19/// All input adapters produce these.
20#[derive(Debug, Clone)]
21pub enum ConversationEntry {
22    UserMessage(String),
23    AssistantText(String),
24    ToolUse { name: String, input_summary: String },
25    ToolResult { content: String, is_error: bool },
26}
27
28/// A parsed conversation — metadata + entries.
29/// Produced by input adapters (JSONL, pulse-null), consumed by archive pipeline.
30#[derive(Debug, Clone)]
31pub struct Conversation {
32    pub session_id: String,
33    pub first_timestamp: Option<String>,
34    pub last_timestamp: Option<String>,
35    pub user_message_count: u32,
36    pub assistant_message_count: u32,
37    pub entries: Vec<ConversationEntry>,
38}
39
40impl Conversation {
41    /// Create a new empty conversation with the given session ID.
42    #[must_use]
43    pub fn new(session_id: &str) -> Self {
44        Self {
45            session_id: session_id.to_string(),
46            first_timestamp: None,
47            last_timestamp: None,
48            user_message_count: 0,
49            assistant_message_count: 0,
50            entries: Vec::new(),
51        }
52    }
53
54    /// Total message count (user + assistant).
55    #[must_use]
56    pub fn total_messages(&self) -> u32 {
57        self.user_message_count + self.assistant_message_count
58    }
59}
60
61// ---------------------------------------------------------------------------
62// Markdown conversion
63// ---------------------------------------------------------------------------
64
65/// Convert conversation entries into a markdown document for archival.
66#[must_use]
67pub fn conversation_to_markdown(conv: &Conversation, log_num: u32) -> String {
68    let mut md = format!("# Conversation {log_num:03}\n\n");
69    let mut last_role: Option<&str> = None;
70
71    for entry in &conv.entries {
72        match entry {
73            ConversationEntry::UserMessage(text) => {
74                if last_role != Some("user") {
75                    md.push_str("---\n\n### User\n\n");
76                }
77                md.push_str(text);
78                md.push_str("\n\n");
79                last_role = Some("user");
80            }
81            ConversationEntry::AssistantText(text) => {
82                if last_role != Some("assistant") {
83                    md.push_str("---\n\n### Assistant\n\n");
84                }
85                md.push_str(text);
86                md.push_str("\n\n");
87                last_role = Some("assistant");
88            }
89            ConversationEntry::ToolUse {
90                name,
91                input_summary,
92            } => {
93                let _ = write!(md, "> **{name}**: `{input_summary}`\n\n");
94            }
95            ConversationEntry::ToolResult { content, is_error } => {
96                let label = if *is_error { "Error" } else { "Result" };
97                let truncated = truncate(content, 2000);
98                let _ = write!(
99                    md,
100                    "<details><summary>{label}</summary>\n\n```\n{truncated}\n```\n\n</details>\n\n"
101                );
102            }
103        }
104    }
105
106    md
107}
108
109// ---------------------------------------------------------------------------
110// Topic extraction
111// ---------------------------------------------------------------------------
112
113const STOP_WORDS: &[&str] = &[
114    "the", "a", "an", "is", "are", "was", "were", "be", "been", "being", "have", "has", "had",
115    "do", "does", "did", "will", "would", "could", "should", "may", "might", "can", "shall", "to",
116    "of", "in", "for", "on", "with", "at", "by", "from", "as", "into", "about", "like", "through",
117    "after", "over", "between", "out", "up", "down", "off", "then", "than", "too", "very", "just",
118    "also", "not", "no", "but", "or", "and", "if", "so", "yet", "both", "this", "that", "these",
119    "those", "it", "its", "i", "you", "we", "they", "he", "she", "me", "my", "your", "our",
120    "their", "him", "her", "us", "them", "what", "which", "who", "when", "where", "how", "why",
121    "all", "each", "every", "some", "any", "most", "other", "new", "old", "first", "last", "next",
122    "now", "here", "there", "only", "one", "two", "get", "got", "make", "made", "let", "let's",
123    "use", "need", "want", "know", "think", "see", "look", "find", "give", "tell", "say", "said",
124    "go", "going", "come", "take", "thing", "things", "way", "work", "right", "good", "yeah",
125    "yes", "okay", "ok", "sure", "well", "don't", "doesn't", "didn't", "can't", "won't", "isn't",
126    "aren't", "wasn't", "file", "code", "run", "set", "add", "put", "try",
127];
128
129/// Algorithmic topic extraction from conversation entries.
130/// Uses keyword frequency with stop-word filtering and tool-target boosting.
131#[must_use]
132pub fn extract_topics(conv: &Conversation, max: usize) -> Vec<String> {
133    let mut freq: HashMap<String, u32> = HashMap::new();
134
135    // Count words from first 5 user messages
136    let mut user_msg_count = 0;
137    for entry in &conv.entries {
138        if let ConversationEntry::UserMessage(text) = entry {
139            let cleaned = strip_channel_prefix(text);
140            for word in cleaned.split_whitespace() {
141                let clean: String = word
142                    .to_lowercase()
143                    .chars()
144                    .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
145                    .collect();
146                if clean.len() >= 3 && !STOP_WORDS.contains(&clean.as_str()) {
147                    *freq.entry(clean).or_default() += 1;
148                }
149            }
150            user_msg_count += 1;
151            if user_msg_count >= 5 {
152                break;
153            }
154        }
155    }
156
157    // Boost tool targets (file paths, commands)
158    for entry in &conv.entries {
159        if let ConversationEntry::ToolUse {
160            input_summary,
161            name,
162            ..
163        } = entry
164        {
165            let target = input_summary
166                .rsplit('/')
167                .next()
168                .unwrap_or(input_summary)
169                .trim_matches('`')
170                .to_lowercase();
171            // A tool whose arguments are a JSON object or a snippet of code has
172            // no "target" to boost — taking one anyway makes the whole blob a
173            // topic. Paths and bare commands do; those are what this boosts.
174            if target.len() >= 3 && !target.contains(['(', '{', '}', '"']) {
175                let stem = target.split('.').next().unwrap_or(&target);
176                if !stem.is_empty() {
177                    *freq.entry(stem.to_string()).or_default() += 2;
178                }
179            }
180            let tool_lower = name.to_lowercase();
181            if !STOP_WORDS.contains(&tool_lower.as_str()) {
182                *freq.entry(tool_lower).or_default() += 1;
183            }
184        }
185    }
186
187    let mut sorted: Vec<(String, u32)> = freq.into_iter().collect();
188    sorted.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
189    sorted.into_iter().take(max).map(|(k, _)| k).collect()
190}
191
192// ---------------------------------------------------------------------------
193// Summary extraction
194// ---------------------------------------------------------------------------
195
196/// Algorithmic summary extraction — first user message, truncated.
197#[must_use]
198pub fn extract_summary(conv: &Conversation) -> String {
199    for entry in &conv.entries {
200        if let ConversationEntry::UserMessage(text) = entry {
201            let cleaned = strip_channel_prefix(text);
202            if cleaned.is_empty() {
203                continue;
204            }
205            let truncated: String = cleaned.chars().take(200).collect();
206            if truncated.len() < cleaned.len() {
207                return format!("{truncated}...");
208            }
209            return truncated;
210        }
211    }
212    "Empty session".to_string()
213}
214
215// ---------------------------------------------------------------------------
216// Timestamp / duration helpers
217// ---------------------------------------------------------------------------
218
219/// Get current UTC timestamp in ISO 8601 format.
220#[must_use]
221pub fn utc_now() -> String {
222    chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
223}
224
225/// Extract just the date portion from an ISO timestamp.
226#[must_use]
227pub fn date_from_timestamp(ts: &str) -> String {
228    ts.split('T').next().unwrap_or(ts).to_string()
229}
230
231/// Calculate duration string from two ISO timestamps.
232#[must_use]
233pub fn calculate_duration(start: &str, end: &str) -> String {
234    fn parse_timestamp(ts: &str) -> Option<u64> {
235        let t_pos = ts.find('T')?;
236        let date_part = &ts[..t_pos];
237        let time_part = ts[t_pos + 1..]
238            .trim_end_matches('Z')
239            .trim_end_matches("+00:00");
240
241        let date_parts: Vec<&str> = date_part.split('-').collect();
242        if date_parts.len() != 3 {
243            return None;
244        }
245        let year: u64 = date_parts[0].parse().ok()?;
246        let month: u64 = date_parts[1].parse().ok()?;
247        let day: u64 = date_parts[2].parse().ok()?;
248
249        let time_clean = time_part.split('.').next()?;
250        let time_parts: Vec<&str> = time_clean.split(':').collect();
251        if time_parts.len() != 3 {
252            return None;
253        }
254        let hour: u64 = time_parts[0].parse().ok()?;
255        let min: u64 = time_parts[1].parse().ok()?;
256        let sec: u64 = time_parts[2].parse().ok()?;
257
258        Some(((year * 365 + month * 30 + day) * 86400) + hour * 3600 + min * 60 + sec)
259    }
260
261    match (parse_timestamp(start), parse_timestamp(end)) {
262        (Some(a), Some(b)) => {
263            let diff = b.abs_diff(a);
264            format_duration(diff)
265        }
266        _ => "unknown".to_string(),
267    }
268}
269
270fn format_duration(seconds: u64) -> String {
271    if seconds < 60 {
272        "< 1m".to_string()
273    } else if seconds < 3600 {
274        format!("{}m", seconds / 60)
275    } else {
276        let h = seconds / 3600;
277        let m = (seconds % 3600) / 60;
278        if m == 0 {
279            format!("{h}h")
280        } else {
281            format!("{h}h{m:02}m")
282        }
283    }
284}
285
286// ---------------------------------------------------------------------------
287// Helpers
288// ---------------------------------------------------------------------------
289
290/// Strip [Channel: ...] and "User message:" prefixes from text.
291#[must_use]
292pub fn strip_channel_prefix(text: &str) -> String {
293    let mut s = text.trim().to_string();
294
295    if s.starts_with('[') {
296        if let Some(end) = s.find("]\n") {
297            s = s[end + 2..].trim().to_string();
298        } else if let Some(end) = s.find("] ") {
299            s = s[end + 2..].trim().to_string();
300        }
301    }
302
303    if let Some(rest) = s.strip_prefix("User message: ") {
304        s = rest.to_string();
305    }
306    if let Some(rest) = s.strip_prefix("User message:") {
307        s = rest.trim().to_string();
308    }
309
310    s
311}
312
313/// Truncate a string, appending a notice if it was cut.
314#[must_use]
315pub fn truncate(s: &str, max: usize) -> String {
316    if s.len() <= max {
317        s.to_string()
318    } else {
319        let total = s.len();
320        // Find a valid UTF-8 char boundary at or before `max`
321        let mut end = max;
322        while end > 0 && !s.is_char_boundary(end) {
323            end -= 1;
324        }
325        format!("{}...\n\n[truncated, {total} chars total]", &s[..end])
326    }
327}
328
329/// Condense a conversation into a text block suitable for LLM summarization.
330/// Keeps it short to minimize token usage.
331#[must_use]
332pub fn condense_for_summary(conv: &Conversation) -> String {
333    let mut condensed = String::new();
334
335    for entry in &conv.entries {
336        match entry {
337            ConversationEntry::UserMessage(text) => {
338                condensed.push_str("User: ");
339                let t: String = text.chars().take(300).collect();
340                condensed.push_str(&t);
341                if t.len() < text.len() {
342                    condensed.push('\u{2026}');
343                }
344                condensed.push('\n');
345            }
346            ConversationEntry::AssistantText(text) => {
347                condensed.push_str("Assistant: ");
348                let t: String = text.chars().take(300).collect();
349                condensed.push_str(&t);
350                if t.len() < text.len() {
351                    condensed.push('\u{2026}');
352                }
353                condensed.push('\n');
354            }
355            ConversationEntry::ToolUse {
356                name,
357                input_summary,
358            } => {
359                let _ = writeln!(condensed, "[Tool: {name} \u{2192} {input_summary}]");
360            }
361            ConversationEntry::ToolResult { .. } => {}
362        }
363    }
364
365    if condensed.len() > 4000 {
366        condensed.truncate(4000);
367        condensed.push_str("\n\u{2026} (conversation truncated)");
368    }
369
370    condensed
371}
372
373#[cfg(test)]
374mod tests {
375    use super::*;
376
377    fn make_conv(entries: Vec<ConversationEntry>) -> Conversation {
378        let mut user_count = 0u32;
379        let mut asst_count = 0u32;
380        for e in &entries {
381            match e {
382                ConversationEntry::UserMessage(_) => user_count += 1,
383                ConversationEntry::AssistantText(_) => asst_count += 1,
384                _ => {}
385            }
386        }
387        Conversation {
388            session_id: "test".to_string(),
389            first_timestamp: None,
390            last_timestamp: None,
391            user_message_count: user_count,
392            assistant_message_count: asst_count,
393            entries,
394        }
395    }
396
397    #[test]
398    fn conversation_to_markdown_basic() {
399        let conv = make_conv(vec![
400            ConversationEntry::UserMessage("What is Rust?".to_string()),
401            ConversationEntry::AssistantText("Rust is a systems programming language.".to_string()),
402        ]);
403        let md = conversation_to_markdown(&conv, 1);
404        assert!(md.contains("# Conversation 001"));
405        assert!(md.contains("### User"));
406        assert!(md.contains("### Assistant"));
407        assert!(md.contains("What is Rust?"));
408    }
409
410    #[test]
411    fn topic_extraction() {
412        let conv = make_conv(vec![
413            ConversationEntry::UserMessage(
414                "Let's work on the authentication module for the API".to_string(),
415            ),
416            ConversationEntry::UserMessage(
417                "The authentication needs JWT tokens and rate limiting".to_string(),
418            ),
419            ConversationEntry::ToolUse {
420                name: "Read".to_string(),
421                input_summary: "/src/auth.rs".to_string(),
422            },
423        ]);
424        let topics = extract_topics(&conv, 5);
425        assert!(!topics.is_empty());
426        assert!(topics.iter().any(|t| t.contains("auth")));
427    }
428
429    /// Codex and Grok record tool arguments as JSON. Boosting that as a topic
430    /// would file the conversation under `{"command":"ls -la"}`.
431    #[test]
432    fn structured_tool_arguments_are_not_topics() {
433        let conv = make_conv(vec![
434            ConversationEntry::UserMessage("check the socket permissions".to_string()),
435            ConversationEntry::ToolUse {
436                name: "run_terminal_command".to_string(),
437                input_summary: r#"{"command":"ls -la","description":"List files"}"#.to_string(),
438            },
439        ]);
440        let topics = extract_topics(&conv, 5);
441        assert!(
442            topics.iter().all(|topic| !topic.contains('{')),
443            "{topics:?}"
444        );
445        assert!(topics.contains(&"socket".to_string()), "{topics:?}");
446    }
447
448    #[test]
449    fn summary_extraction() {
450        let conv = make_conv(vec![
451            ConversationEntry::UserMessage("Fix the login bug in the auth module".to_string()),
452            ConversationEntry::AssistantText("Let me take a look at the auth module.".to_string()),
453        ]);
454        let summary = extract_summary(&conv);
455        assert!(summary.contains("Fix the login bug"));
456    }
457
458    #[test]
459    fn summary_strips_channel_prefix() {
460        let conv = make_conv(vec![ConversationEntry::UserMessage(
461            "[Channel: discord | Trust: VERIFIED]\nFix the login bug".to_string(),
462        )]);
463        let summary = extract_summary(&conv);
464        assert!(summary.starts_with("Fix the login bug"));
465    }
466
467    #[test]
468    fn summary_empty_session() {
469        let conv = make_conv(vec![]);
470        assert_eq!(extract_summary(&conv), "Empty session");
471    }
472
473    #[test]
474    fn duration_calculation() {
475        assert_eq!(
476            calculate_duration("2026-03-06T10:00:00Z", "2026-03-06T10:45:00Z"),
477            "45m"
478        );
479        assert_eq!(
480            calculate_duration("2026-03-06T10:00:00Z", "2026-03-06T12:30:00Z"),
481            "2h30m"
482        );
483    }
484
485    #[test]
486    fn duration_short() {
487        assert_eq!(
488            calculate_duration("2026-03-05T14:30:00.000Z", "2026-03-05T14:30:30.000Z"),
489            "< 1m"
490        );
491    }
492
493    #[test]
494    fn duration_invalid() {
495        assert_eq!(calculate_duration("garbage", "nonsense"), "unknown");
496    }
497
498    #[test]
499    fn utc_now_format() {
500        let ts = utc_now();
501        assert!(ts.contains('T'));
502        assert!(ts.ends_with('Z'));
503        assert!(ts.len() >= 19);
504    }
505
506    #[test]
507    fn empty_messages_produce_empty_topics() {
508        let conv = make_conv(vec![]);
509        let topics = extract_topics(&conv, 5);
510        assert!(topics.is_empty());
511    }
512
513    #[test]
514    fn truncate_short() {
515        assert_eq!(truncate("hello", 100), "hello");
516    }
517
518    #[test]
519    fn truncate_long() {
520        let long = "x".repeat(3000);
521        let result = truncate(&long, 2000);
522        assert!(result.len() < 3000);
523        assert!(result.contains("[truncated, 3000 chars total]"));
524    }
525
526    #[test]
527    fn condense_truncates_long_messages() {
528        let conv = make_conv(vec![ConversationEntry::UserMessage("x".repeat(500))]);
529        let condensed = condense_for_summary(&conv);
530        assert!(condensed.len() < 400);
531        assert!(condensed.contains('\u{2026}'));
532    }
533}