Skip to main content

recall_echo/
conversation.rs

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