Skip to main content

totalreclaw_core/
debrief.rs

1//! Session debrief extraction for TotalReclaw.
2//!
3//! Captures broader context, outcomes, and relationships that turn-by-turn
4//! extraction misses. Called at session/consolidation end.
5//!
6//! Uses the canonical debrief prompt — identical across all clients.
7
8use serde::{Deserialize, Serialize};
9
10/// Canonical debrief system prompt.
11///
12/// This prompt MUST be identical across all TotalReclaw implementations
13/// (TypeScript, Python, Rust). Changes here must be mirrored everywhere.
14pub const DEBRIEF_SYSTEM_PROMPT: &str = r#"You are reviewing a conversation that just ended. The following facts were
15already extracted and stored during this conversation:
16
17{already_stored_facts}
18
19Your job is to capture what turn-by-turn extraction MISSED. Focus on:
20
211. **Broader context** — What was the conversation about overall? What project,
22   problem, or topic tied the discussion together?
232. **Outcomes & conclusions** — What was decided, agreed upon, or resolved?
243. **What was attempted** — What approaches were tried? What worked, what didn't, and why?
254. **Relationships** — How do topics discussed relate to each other or to things
26   from previous conversations?
275. **Open threads** — What was left unfinished or needs follow-up?
28
29Do NOT repeat facts already stored. Only add genuinely new information that provides
30broader context a future conversation would benefit from.
31
32Return a JSON array (no markdown, no code fences):
33[{"text": "...", "type": "summary|context", "importance": N}]
34
35- Use type "summary" for conclusions, outcomes, and decisions-of-the-session
36- Use type "context" for broader project context, open threads, and what-was-tried
37- Importance 7-8 for most debrief items (they are high-value by definition)
38- Maximum 5 items (debriefs should be concise, not exhaustive)
39- Each item should be 1-3 sentences, self-contained
40
41If the conversation was too short or trivial to warrant a debrief, return: []"#;
42
43/// Minimum number of messages to trigger a debrief (4 turns = 8 messages).
44pub const MIN_DEBRIEF_MESSAGES: usize = 8;
45
46/// Maximum number of debrief items.
47pub const MAX_DEBRIEF_ITEMS: usize = 5;
48
49/// Source tag for debrief items stored on-chain.
50pub const DEBRIEF_SOURCE: &str = "zeroclaw_debrief";
51
52/// A single debrief item.
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct DebriefItem {
55    pub text: String,
56    #[serde(rename = "type")]
57    pub item_type: DebriefType,
58    pub importance: u8,
59}
60
61/// Debrief item type.
62#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
63#[serde(rename_all = "lowercase")]
64pub enum DebriefType {
65    Summary,
66    Context,
67}
68
69impl std::fmt::Display for DebriefType {
70    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
71        match self {
72            DebriefType::Summary => write!(f, "summary"),
73            DebriefType::Context => write!(f, "context"),
74        }
75    }
76}
77
78/// A conversation message for debrief input.
79#[derive(Debug, Clone)]
80pub struct Message {
81    pub role: String,
82    pub content: String,
83}
84
85/// Parse a debrief LLM response into validated [`DebriefItem`]s.
86///
87/// - Strips markdown code fences
88/// - Validates type is summary|context (defaults to context)
89/// - Filters importance < 6
90/// - Caps at 5 items
91/// - Defaults importance to 7 if missing/invalid
92/// - Rejects text shorter than 5 characters
93/// - Truncates text to 512 characters
94pub fn parse_debrief_response(response: &str) -> Vec<DebriefItem> {
95    let cleaned = strip_code_fences(response.trim());
96
97    let parsed: Vec<serde_json::Value> = match serde_json::from_str(&cleaned) {
98        Ok(serde_json::Value::Array(arr)) => arr,
99        _ => return Vec::new(),
100    };
101
102    let mut items = Vec::new();
103
104    for entry in parsed {
105        let obj = match entry.as_object() {
106            Some(o) => o,
107            None => continue,
108        };
109
110        let text = match obj.get("text").and_then(|v| v.as_str()) {
111            Some(t) if t.trim().len() >= 5 => {
112                let trimmed = t.trim();
113                if trimmed.len() > 512 {
114                    trimmed[..512].to_string()
115                } else {
116                    trimmed.to_string()
117                }
118            }
119            _ => continue,
120        };
121
122        let item_type = match obj.get("type").and_then(|v| v.as_str()) {
123            Some("summary") => DebriefType::Summary,
124            _ => DebriefType::Context,
125        };
126
127        let importance = obj
128            .get("importance")
129            .and_then(|v| v.as_u64())
130            .map(|n| n.clamp(1, 10) as u8)
131            .unwrap_or(7);
132
133        if importance < 6 {
134            continue;
135        }
136
137        items.push(DebriefItem {
138            text,
139            item_type,
140            importance,
141        });
142
143        if items.len() >= MAX_DEBRIEF_ITEMS {
144            break;
145        }
146    }
147
148    items
149}
150
151/// Strip markdown code fences from a response.
152fn strip_code_fences(s: &str) -> String {
153    let mut result = s.to_string();
154    if result.starts_with("```") {
155        // Remove opening fence (```json or ```)
156        if let Some(pos) = result.find('\n') {
157            result = result[pos + 1..].to_string();
158        }
159        // Remove closing fence
160        if result.ends_with("```") {
161            result = result[..result.len() - 3].trim_end().to_string();
162        }
163    }
164    result
165}
166
167/// Format conversation messages for the debrief prompt.
168///
169/// Truncates to approximately `max_chars` characters.
170pub fn format_messages(messages: &[Message], max_chars: usize) -> String {
171    let mut lines = Vec::new();
172    let mut total = 0;
173
174    for msg in messages {
175        let line = format!("[{}]: {}", msg.role, msg.content);
176        if total + line.len() > max_chars {
177            break;
178        }
179        total += line.len();
180        lines.push(line);
181    }
182
183    lines.join("\n\n")
184}
185
186/// Build the debrief system prompt with already-stored facts context.
187pub fn build_debrief_prompt(stored_fact_texts: &[&str]) -> String {
188    let already_stored = if stored_fact_texts.is_empty() {
189        "(none)".to_string()
190    } else {
191        stored_fact_texts
192            .iter()
193            .map(|t| format!("- {}", t))
194            .collect::<Vec<_>>()
195            .join("\n")
196    };
197
198    DEBRIEF_SYSTEM_PROMPT.replace("{already_stored_facts}", &already_stored)
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204
205    #[test]
206    fn test_parse_valid_json() {
207        let input = r#"[
208            {"text": "Session was about refactoring the auth module", "type": "summary", "importance": 8},
209            {"text": "Migration to new API is still pending", "type": "context", "importance": 7}
210        ]"#;
211        let result = parse_debrief_response(input);
212        assert_eq!(result.len(), 2);
213        assert_eq!(result[0].item_type, DebriefType::Summary);
214        assert_eq!(result[0].importance, 8);
215        assert_eq!(result[1].item_type, DebriefType::Context);
216        assert_eq!(result[1].importance, 7);
217    }
218
219    #[test]
220    fn test_parse_empty_array() {
221        let result = parse_debrief_response("[]");
222        assert!(result.is_empty());
223    }
224
225    #[test]
226    fn test_strips_markdown_fences() {
227        let input = "```json\n[{\"text\": \"Session summary here with enough text\", \"type\": \"summary\", \"importance\": 8}]\n```";
228        let result = parse_debrief_response(input);
229        assert_eq!(result.len(), 1);
230        assert_eq!(result[0].item_type, DebriefType::Summary);
231    }
232
233    #[test]
234    fn test_strips_bare_markdown_fences() {
235        let input = "```\n[{\"text\": \"Session summary here with enough text\", \"type\": \"context\", \"importance\": 7}]\n```";
236        let result = parse_debrief_response(input);
237        assert_eq!(result.len(), 1);
238    }
239
240    #[test]
241    fn test_caps_at_5_items() {
242        let items: Vec<serde_json::Value> = (0..8)
243            .map(|i| {
244                serde_json::json!({
245                    "text": format!("Debrief item number {} with enough text", i + 1),
246                    "type": "summary",
247                    "importance": 7
248                })
249            })
250            .collect();
251        let input = serde_json::to_string(&items).unwrap();
252        let result = parse_debrief_response(&input);
253        assert_eq!(result.len(), 5);
254    }
255
256    #[test]
257    fn test_filters_importance_below_6() {
258        let input = r#"[
259            {"text": "Important finding from the session test", "type": "summary", "importance": 8},
260            {"text": "Trivial detail that should be filtered out", "type": "context", "importance": 3}
261        ]"#;
262        let result = parse_debrief_response(input);
263        assert_eq!(result.len(), 1);
264        assert_eq!(result[0].importance, 8);
265    }
266
267    #[test]
268    fn test_validates_type_defaults_to_context() {
269        let input = r#"[
270            {"text": "Valid summary item for the session here", "type": "summary", "importance": 7},
271            {"text": "This has an invalid type value set here", "type": "fact", "importance": 7}
272        ]"#;
273        let result = parse_debrief_response(input);
274        assert_eq!(result.len(), 2);
275        assert_eq!(result[0].item_type, DebriefType::Summary);
276        assert_eq!(result[1].item_type, DebriefType::Context);
277    }
278
279    #[test]
280    fn test_handles_invalid_json() {
281        let result = parse_debrief_response("not json at all");
282        assert!(result.is_empty());
283    }
284
285    #[test]
286    fn test_handles_non_array_json() {
287        let result = parse_debrief_response(r#"{"text": "not an array"}"#);
288        assert!(result.is_empty());
289    }
290
291    #[test]
292    fn test_handles_empty_string() {
293        let result = parse_debrief_response("");
294        assert!(result.is_empty());
295    }
296
297    #[test]
298    fn test_filters_short_text() {
299        let input = r#"[
300            {"text": "ok", "type": "summary", "importance": 8},
301            {"text": "This is a valid debrief item text here", "type": "summary", "importance": 8}
302        ]"#;
303        let result = parse_debrief_response(input);
304        assert_eq!(result.len(), 1);
305        assert_eq!(result[0].text, "This is a valid debrief item text here");
306    }
307
308    #[test]
309    fn test_filters_missing_text() {
310        let input = r#"[
311            {"type": "summary", "importance": 8},
312            {"text": "Valid debrief item with actual text content", "type": "summary", "importance": 8}
313        ]"#;
314        let result = parse_debrief_response(input);
315        assert_eq!(result.len(), 1);
316    }
317
318    #[test]
319    fn test_defaults_importance_to_7() {
320        let input = r#"[{"text": "A debrief item without importance score", "type": "summary"}]"#;
321        let result = parse_debrief_response(input);
322        assert_eq!(result.len(), 1);
323        assert_eq!(result[0].importance, 7);
324    }
325
326    #[test]
327    fn test_clamps_importance_to_10() {
328        let input =
329            r#"[{"text": "A debrief item with huge importance value", "type": "summary", "importance": 99}]"#;
330        let result = parse_debrief_response(input);
331        assert_eq!(result.len(), 1);
332        assert_eq!(result[0].importance, 10);
333    }
334
335    #[test]
336    fn test_truncates_text_to_512() {
337        let long_text = "x".repeat(600);
338        let input = format!(
339            r#"[{{"text": "{}", "type": "summary", "importance": 8}}]"#,
340            long_text
341        );
342        let result = parse_debrief_response(&input);
343        assert_eq!(result.len(), 1);
344        assert_eq!(result[0].text.len(), 512);
345    }
346
347    #[test]
348    fn test_trims_whitespace_in_text() {
349        let input =
350            r#"[{"text": "  Debrief item with whitespace around it  ", "type": "summary", "importance": 8}]"#;
351        let result = parse_debrief_response(input);
352        assert_eq!(result[0].text, "Debrief item with whitespace around it");
353    }
354
355    #[test]
356    fn test_skips_non_object_entries() {
357        let input = r#"["just a string", {"text": "Valid debrief item with content here", "type": "summary", "importance": 7}, 42]"#;
358        let result = parse_debrief_response(input);
359        assert_eq!(result.len(), 1);
360    }
361
362    #[test]
363    fn test_build_debrief_prompt_with_facts() {
364        let facts = vec!["User prefers dark mode", "User works at Acme"];
365        let prompt = build_debrief_prompt(&facts);
366        assert!(prompt.contains("- User prefers dark mode"));
367        assert!(prompt.contains("- User works at Acme"));
368        assert!(!prompt.contains("(none)"));
369    }
370
371    #[test]
372    fn test_build_debrief_prompt_no_facts() {
373        let prompt = build_debrief_prompt(&[]);
374        assert!(prompt.contains("(none)"));
375    }
376
377    #[test]
378    fn test_format_messages() {
379        let messages = vec![
380            Message {
381                role: "user".into(),
382                content: "Hello".into(),
383            },
384            Message {
385                role: "assistant".into(),
386                content: "Hi there".into(),
387            },
388        ];
389        let result = format_messages(&messages, 1000);
390        assert!(result.contains("[user]: Hello"));
391        assert!(result.contains("[assistant]: Hi there"));
392    }
393
394    #[test]
395    fn test_format_messages_truncates() {
396        let messages = vec![
397            Message {
398                role: "user".into(),
399                content: "x".repeat(100),
400            },
401            Message {
402                role: "assistant".into(),
403                content: "y".repeat(100),
404            },
405        ];
406        let result = format_messages(&messages, 50);
407        assert!(!result.contains("[assistant]"));
408    }
409
410    #[test]
411    fn test_format_messages_empty() {
412        let result = format_messages(&[], 1000);
413        assert!(result.is_empty());
414    }
415
416    #[test]
417    fn test_prompt_contains_key_sections() {
418        assert!(DEBRIEF_SYSTEM_PROMPT.contains("Broader context"));
419        assert!(DEBRIEF_SYSTEM_PROMPT.contains("Outcomes & conclusions"));
420        assert!(DEBRIEF_SYSTEM_PROMPT.contains("What was attempted"));
421        assert!(DEBRIEF_SYSTEM_PROMPT.contains("Relationships"));
422        assert!(DEBRIEF_SYSTEM_PROMPT.contains("Open threads"));
423        assert!(DEBRIEF_SYSTEM_PROMPT.contains("Maximum 5 items"));
424        assert!(DEBRIEF_SYSTEM_PROMPT.contains("{already_stored_facts}"));
425        assert!(DEBRIEF_SYSTEM_PROMPT.contains("summary|context"));
426    }
427
428    #[test]
429    fn test_prompt_matches_python_canonical() {
430        assert!(DEBRIEF_SYSTEM_PROMPT.starts_with("You are reviewing a conversation that just ended."));
431        assert!(DEBRIEF_SYSTEM_PROMPT.ends_with("return: []"));
432    }
433
434    #[test]
435    fn test_constants() {
436        assert_eq!(MIN_DEBRIEF_MESSAGES, 8);
437        assert_eq!(MAX_DEBRIEF_ITEMS, 5);
438        assert_eq!(DEBRIEF_SOURCE, "zeroclaw_debrief");
439    }
440
441    #[test]
442    fn test_debrief_type_display() {
443        assert_eq!(format!("{}", DebriefType::Summary), "summary");
444        assert_eq!(format!("{}", DebriefType::Context), "context");
445    }
446
447    #[test]
448    fn test_debrief_type_serde_roundtrip() {
449        let item = DebriefItem {
450            text: "Test item for serde roundtrip".to_string(),
451            item_type: DebriefType::Summary,
452            importance: 8,
453        };
454        let json = serde_json::to_string(&item).unwrap();
455        assert!(json.contains(r#""type":"summary""#));
456
457        let deserialized: DebriefItem = serde_json::from_str(&json).unwrap();
458        assert_eq!(deserialized.item_type, DebriefType::Summary);
459        assert_eq!(deserialized.importance, 8);
460    }
461
462    #[test]
463    fn test_importance_exactly_6_passes() {
464        let input =
465            r#"[{"text": "Borderline importance item at exactly six", "type": "summary", "importance": 6}]"#;
466        let result = parse_debrief_response(input);
467        assert_eq!(result.len(), 1);
468        assert_eq!(result[0].importance, 6);
469    }
470
471    #[test]
472    fn test_importance_exactly_5_filtered() {
473        let input =
474            r#"[{"text": "Below threshold importance item at five", "type": "summary", "importance": 5}]"#;
475        let result = parse_debrief_response(input);
476        assert!(result.is_empty());
477    }
478}