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