1use serde::{Deserialize, Serialize};
9
10pub 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
43pub const MIN_DEBRIEF_MESSAGES: usize = 8;
45
46pub const MAX_DEBRIEF_ITEMS: usize = 5;
48
49pub const DEBRIEF_SOURCE: &str = "zeroclaw_debrief";
51
52#[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#[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#[derive(Debug, Clone)]
80pub struct Message {
81 pub role: String,
82 pub content: String,
83}
84
85pub 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
151fn strip_code_fences(s: &str) -> String {
153 let mut result = s.to_string();
154 if result.starts_with("```") {
155 if let Some(pos) = result.find('\n') {
157 result = result[pos + 1..].to_string();
158 }
159 if result.ends_with("```") {
161 result = result[..result.len() - 3].trim_end().to_string();
162 }
163 }
164 result
165}
166
167pub 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
186pub 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}