Skip to main content

lore_cli/summarize/
prompt.rs

1//! Prompt construction for LLM-powered session summaries.
2//!
3//! Provides the system prompt and message formatting logic needed to
4//! generate concise summaries of AI-assisted development sessions.
5
6use crate::storage::models::{Message, MessageRole};
7
8/// Returns the system prompt that instructs the LLM how to summarize a session.
9///
10/// The prompt directs the model to produce a one-sentence overview followed by
11/// 2-5 bullet points covering the key technical work, staying under 300 words
12/// total. Output is plain text with no markdown headers.
13pub fn system_prompt() -> &'static str {
14    "You are summarizing an AI-assisted coding session. \
15     Produce a one-sentence overview of what the session accomplished, \
16     followed by 2-5 bullet points covering the key technical work.\n\n\
17     Rules:\n\
18     - Keep the total summary under 300 words.\n\
19     - Focus on what was done and why, not how.\n\
20     - Do not mention tool calls or internal mechanics.\n\
21     - Use plain text only. No markdown headers, no special formatting.\n\
22     - Start bullet points with a dash (-)."
23}
24
25/// Formats session messages into a conversation transcript for the LLM.
26///
27/// Each message is rendered with a role tag and, for user messages, a UTC
28/// timestamp. Tool calls and thinking blocks are excluded via
29/// `MessageContent::text()`.
30///
31/// If the formatted output exceeds `max_chars`, the middle portion of the
32/// conversation is replaced with an omission marker. The first 20% and
33/// last 30% of messages are kept to preserve context from both the beginning
34/// and end of the session.
35///
36/// Returns an empty string when the message slice is empty.
37pub fn prepare_conversation(messages: &[Message], max_chars: usize) -> String {
38    if messages.is_empty() {
39        return String::new();
40    }
41
42    let formatted = format_messages(messages);
43
44    if max_chars == 0 || formatted.len() <= max_chars {
45        return formatted;
46    }
47
48    // Truncation: keep first 20% and last 30% of messages
49    truncate_conversation(messages, max_chars)
50}
51
52/// Formats a slice of messages into the conversation transcript.
53fn format_messages(messages: &[Message]) -> String {
54    let mut parts = Vec::with_capacity(messages.len());
55
56    for msg in messages {
57        let text = msg.content.text();
58        let formatted = format_single_message(msg, &text);
59        if !formatted.is_empty() {
60            parts.push(formatted);
61        }
62    }
63
64    parts.join("\n\n")
65}
66
67/// Formats a single message with its role header and content.
68///
69/// User and system messages include a UTC timestamp. Assistant messages
70/// show only the role tag. Returns an empty string if the message text
71/// is empty (e.g., messages containing only tool blocks).
72fn format_single_message(msg: &Message, text: &str) -> String {
73    if text.is_empty() {
74        return String::new();
75    }
76
77    let header = match msg.role {
78        MessageRole::User => {
79            let ts = msg.timestamp.format("%Y-%m-%d %H:%M UTC");
80            format!("[User] ({ts})")
81        }
82        MessageRole::Assistant => "[Assistant]".to_string(),
83        MessageRole::System => {
84            let ts = msg.timestamp.format("%Y-%m-%d %H:%M UTC");
85            format!("[System] ({ts})")
86        }
87    };
88
89    format!("{header}\n{text}")
90}
91
92/// Applies the head+tail truncation strategy when the full transcript is too long.
93///
94/// Keeps the first 20% of messages and the last 30%, replacing the middle
95/// with an omission marker indicating how many messages were skipped.
96fn truncate_conversation(messages: &[Message], max_chars: usize) -> String {
97    let count = messages.len();
98
99    // Calculate how many messages to keep from head and tail
100    let head_count = ((count as f64) * 0.2).ceil() as usize;
101    let tail_count = ((count as f64) * 0.3).ceil() as usize;
102
103    // Ensure we don't overlap (when message count is very small)
104    let (head_count, tail_count) = if head_count + tail_count >= count {
105        // Not enough messages to truncate; just format all of them
106        return format_messages(messages);
107    } else {
108        (head_count, tail_count)
109    };
110
111    let omitted = count - head_count - tail_count;
112    let head_msgs = &messages[..head_count];
113    let tail_msgs = &messages[count - tail_count..];
114
115    let head_text = format_messages(head_msgs);
116    let marker = format!("[... {omitted} messages omitted ...]");
117    let tail_text = format_messages(tail_msgs);
118
119    let result = format!("{head_text}\n\n{marker}\n\n{tail_text}");
120
121    // If still over the limit after truncation, hard-truncate the result
122    if result.len() > max_chars {
123        let truncated: String = result.chars().take(max_chars).collect();
124        truncated
125    } else {
126        result
127    }
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use crate::storage::models::{ContentBlock, MessageContent};
134    use chrono::{TimeZone, Utc};
135    use uuid::Uuid;
136
137    /// Helper to create a test message with the given role and text content.
138    fn make_message(role: MessageRole, text: &str, index: i32) -> Message {
139        Message {
140            id: Uuid::new_v4(),
141            session_id: Uuid::new_v4(),
142            parent_id: None,
143            index,
144            timestamp: Utc.with_ymd_and_hms(2024, 1, 15, 14, 30, 0).unwrap(),
145            role,
146            content: MessageContent::Text(text.to_string()),
147            model: None,
148            git_branch: None,
149            cwd: None,
150        }
151    }
152
153    /// Helper to create a message with only tool-use blocks (no text).
154    fn make_tool_only_message(index: i32) -> Message {
155        Message {
156            id: Uuid::new_v4(),
157            session_id: Uuid::new_v4(),
158            parent_id: None,
159            index,
160            timestamp: Utc.with_ymd_and_hms(2024, 1, 15, 14, 30, 0).unwrap(),
161            role: MessageRole::Assistant,
162            content: MessageContent::Blocks(vec![ContentBlock::ToolUse {
163                id: "tool_1".to_string(),
164                name: "Read".to_string(),
165                input: serde_json::json!({"file_path": "/tmp/test.rs"}),
166            }]),
167            model: None,
168            git_branch: None,
169            cwd: None,
170        }
171    }
172
173    #[test]
174    fn test_system_prompt_is_non_empty() {
175        let prompt = system_prompt();
176        assert!(!prompt.is_empty());
177        assert!(prompt.contains("summariz"));
178        assert!(prompt.contains("300 words"));
179    }
180
181    #[test]
182    fn test_basic_user_and_assistant_formatting() {
183        let messages = vec![
184            make_message(MessageRole::User, "Fix the login bug", 0),
185            make_message(MessageRole::Assistant, "I found the issue in auth.rs", 1),
186        ];
187
188        let result = prepare_conversation(&messages, 10_000);
189
190        assert!(result.contains("[User] (2024-01-15 14:30 UTC)"));
191        assert!(result.contains("Fix the login bug"));
192        assert!(result.contains("[Assistant]"));
193        assert!(result.contains("I found the issue in auth.rs"));
194        // Assistant messages should not have a timestamp in the header
195        assert!(!result.contains("[Assistant] ("));
196    }
197
198    #[test]
199    fn test_system_message_formatting() {
200        let messages = vec![make_message(
201            MessageRole::System,
202            "You are a coding assistant",
203            0,
204        )];
205
206        let result = prepare_conversation(&messages, 10_000);
207
208        assert!(result.contains("[System] (2024-01-15 14:30 UTC)"));
209        assert!(result.contains("You are a coding assistant"));
210    }
211
212    #[test]
213    fn test_empty_messages_returns_empty_string() {
214        let messages: Vec<Message> = vec![];
215        let result = prepare_conversation(&messages, 10_000);
216        assert!(result.is_empty());
217    }
218
219    #[test]
220    fn test_single_message_formatting() {
221        let messages = vec![make_message(MessageRole::User, "Hello", 0)];
222
223        let result = prepare_conversation(&messages, 10_000);
224
225        assert_eq!(result, "[User] (2024-01-15 14:30 UTC)\nHello");
226    }
227
228    #[test]
229    fn test_tool_only_messages_handled_gracefully() {
230        let messages = vec![
231            make_message(MessageRole::User, "Read that file", 0),
232            make_tool_only_message(1),
233            make_message(MessageRole::Assistant, "Done reading", 2),
234        ];
235
236        let result = prepare_conversation(&messages, 10_000);
237
238        // The tool-only message should be skipped (empty text)
239        assert!(result.contains("Read that file"));
240        assert!(result.contains("Done reading"));
241        // Should not contain back-to-back blank sections from the skipped message
242        assert!(!result.contains("\n\n\n\n"));
243    }
244
245    #[test]
246    fn test_truncation_with_head_and_tail_strategy() {
247        // Create 20 messages so truncation math is clear:
248        // head = ceil(20 * 0.2) = 4, tail = ceil(20 * 0.3) = 6, omitted = 10
249        let mut messages = Vec::new();
250        for i in 0..20 {
251            let role = if i % 2 == 0 {
252                MessageRole::User
253            } else {
254                MessageRole::Assistant
255            };
256            messages.push(make_message(role, &format!("Message number {i}"), i));
257        }
258
259        // Use a limit smaller than the full output (~800 chars) but large enough
260        // to contain the truncated head+marker+tail (~450 chars).
261        let result = prepare_conversation(&messages, 500);
262
263        assert!(result.contains("Message number 0"));
264        assert!(result.contains("[... 10 messages omitted ...]"));
265        assert!(result.contains("Message number 19"));
266    }
267
268    #[test]
269    fn test_no_truncation_when_under_limit() {
270        let messages = vec![
271            make_message(MessageRole::User, "Short", 0),
272            make_message(MessageRole::Assistant, "Reply", 1),
273        ];
274
275        let result = prepare_conversation(&messages, 10_000);
276
277        assert!(!result.contains("omitted"));
278    }
279
280    #[test]
281    fn test_truncation_preserves_first_and_last_messages() {
282        // 10 messages: head = ceil(10*0.2)=2, tail = ceil(10*0.3)=3, omitted = 5
283        let mut messages = Vec::new();
284        for i in 0..10 {
285            messages.push(make_message(MessageRole::User, &format!("msg-{i}"), i));
286        }
287
288        // Full output is ~380 chars. Use 250 to trigger truncation but
289        // leave enough room for the head+marker+tail (~220 chars).
290        let result = prepare_conversation(&messages, 250);
291
292        // Head messages
293        assert!(result.contains("msg-0"));
294        assert!(result.contains("msg-1"));
295        // Tail messages
296        assert!(result.contains("msg-7"));
297        assert!(result.contains("msg-8"));
298        assert!(result.contains("msg-9"));
299        // Omission marker
300        assert!(result.contains("[... 5 messages omitted ...]"));
301    }
302
303    #[test]
304    fn test_all_tool_only_messages_produce_empty_output() {
305        let messages = vec![make_tool_only_message(0), make_tool_only_message(1)];
306
307        let result = prepare_conversation(&messages, 10_000);
308
309        assert!(result.is_empty());
310    }
311
312    #[test]
313    fn test_max_chars_zero_returns_full_conversation() {
314        let messages = vec![
315            make_message(MessageRole::User, "Hello", 0),
316            make_message(MessageRole::Assistant, "World", 1),
317        ];
318
319        let result = prepare_conversation(&messages, 0);
320
321        assert!(result.contains("Hello"));
322        assert!(result.contains("World"));
323    }
324
325    #[test]
326    fn test_few_messages_no_truncation_when_head_tail_covers_all() {
327        // With 2 messages: head = ceil(2*0.2)=1, tail = ceil(2*0.3)=1
328        // head + tail = 2 >= count => no truncation possible, returns full text
329        let two_messages = vec![
330            make_message(MessageRole::User, "Alpha", 0),
331            make_message(MessageRole::Assistant, "Beta", 1),
332        ];
333
334        // Even with a small limit, the truncation logic sees that
335        // head+tail covers all messages and returns the full output.
336        let full = prepare_conversation(&two_messages, 10_000);
337        let truncated = prepare_conversation(&two_messages, 1);
338
339        // The result is the full text (possibly hard-truncated), but
340        // no omission marker because there are too few messages to split.
341        assert!(!full.contains("omitted"));
342        // Hard truncation may apply, but the omission marker is not present
343        // in the underlying truncation output.
344        assert!(truncated.len() <= full.len());
345    }
346
347    #[test]
348    fn test_small_message_set_truncation() {
349        // Use 5 messages with long enough content so omitting the middle
350        // saves more space than the marker takes.
351        // head = ceil(5*0.2)=1, tail = ceil(5*0.3)=2, omitted = 2
352        let messages = vec![
353            make_message(
354                MessageRole::User,
355                "The very first user message in the session",
356                0,
357            ),
358            make_message(
359                MessageRole::Assistant,
360                "A long middle response that should be omitted from output",
361                1,
362            ),
363            make_message(
364                MessageRole::User,
365                "Another middle message that should be omitted from output",
366                2,
367            ),
368            make_message(
369                MessageRole::Assistant,
370                "The penultimate message in the tail section",
371                3,
372            ),
373            make_message(
374                MessageRole::User,
375                "The final message in the conversation",
376                4,
377            ),
378        ];
379
380        let full = prepare_conversation(&messages, 10_000);
381        // Truncated output replaces 2 messages (~140 chars) with a marker (~30 chars)
382        // so it should be shorter. Use a limit that triggers truncation.
383        let result = prepare_conversation(&messages, full.len() - 1);
384
385        assert!(result.contains("The very first user message"));
386        assert!(result.contains("penultimate message"));
387        assert!(result.contains("final message"));
388        assert!(result.contains("[... 2 messages omitted ...]"));
389        // Omitted messages should not appear
390        assert!(!result.contains("A long middle response"));
391        assert!(!result.contains("Another middle message"));
392    }
393}