subcog/hooks/pre_compact/
formatter.rs1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CapturedMemory {
11 pub memory_id: String,
13 pub namespace: String,
15 pub confidence: f32,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SkippedDuplicate {
22 pub reason: String,
24 pub matched_urn: String,
26 #[serde(skip_serializing_if = "Option::is_none")]
28 pub similarity_score: Option<f32>,
29 pub namespace: String,
31}
32
33pub struct ResponseFormatter;
35
36impl ResponseFormatter {
37 #[must_use]
39 pub fn build_context_message(
40 captured: &[CapturedMemory],
41 skipped: &[SkippedDuplicate],
42 ) -> Option<String> {
43 if captured.is_empty() && skipped.is_empty() {
44 return None;
45 }
46
47 let mut lines = vec!["**Subcog Pre-Compact Auto-Capture**\n".to_string()];
48
49 if !captured.is_empty() {
50 lines.push(format!(
51 "Captured {} memories before context compaction:\n",
52 captured.len()
53 ));
54 for c in captured {
55 lines.push(format!(
56 "- `{}`: {} (confidence: {:.0}%)",
57 c.namespace,
58 c.memory_id,
59 c.confidence * 100.0
60 ));
61 }
62 }
63
64 if !skipped.is_empty() {
65 if !captured.is_empty() {
66 lines.push(String::new()); }
68 lines.push(format!("Skipped {} duplicates:\n", skipped.len()));
69 for s in skipped {
70 let score_str = s
71 .similarity_score
72 .map_or(String::new(), |sc| format!(" ({:.0}% similar)", sc * 100.0));
73 lines.push(format!(
74 "- `{}`: {} ({}{})",
75 s.namespace, s.matched_urn, s.reason, score_str
76 ));
77 }
78 }
79
80 Some(lines.join("\n"))
81 }
82
83 #[must_use]
89 pub fn build_hook_response(
90 captured: &[CapturedMemory],
91 skipped: &[SkippedDuplicate],
92 ) -> serde_json::Value {
93 let metadata = serde_json::json!({
95 "captured": !captured.is_empty(),
96 "captures": captured.iter().map(|c| serde_json::json!({
97 "memory_id": c.memory_id,
98 "namespace": c.namespace,
99 "confidence": c.confidence
100 })).collect::<Vec<_>>(),
101 "skipped_duplicates": skipped.len(),
102 "duplicates": skipped.iter().map(|s| serde_json::json!({
103 "reason": s.reason,
104 "matched_urn": s.matched_urn,
105 "namespace": s.namespace,
106 "similarity_score": s.similarity_score
107 })).collect::<Vec<_>>()
108 });
109
110 if let Some(ctx) = Self::build_context_message(captured, skipped) {
112 tracing::info!(
113 captures = captured.len(),
114 skipped = skipped.len(),
115 "PreCompact auto-capture completed"
116 );
117 tracing::debug!(context = %ctx, metadata = ?metadata, "PreCompact context (not returned)");
118 }
119
120 serde_json::json!({})
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 #[test]
130 fn test_build_context_message_empty() {
131 let captured: Vec<CapturedMemory> = vec![];
132 let skipped: Vec<SkippedDuplicate> = vec![];
133
134 let result = ResponseFormatter::build_context_message(&captured, &skipped);
135 assert!(result.is_none());
136 }
137
138 #[test]
139 fn test_build_context_message_with_captures() {
140 let captured = vec![CapturedMemory {
141 memory_id: "mem-123".to_string(),
142 namespace: "decisions".to_string(),
143 confidence: 0.85,
144 }];
145 let skipped: Vec<SkippedDuplicate> = vec![];
146
147 let result = ResponseFormatter::build_context_message(&captured, &skipped);
148 assert!(result.is_some());
149 let msg = result.unwrap();
150 assert!(msg.contains("Captured 1 memories"));
151 assert!(msg.contains("decisions"));
152 assert!(msg.contains("mem-123"));
153 assert!(msg.contains("85%"));
154 }
155
156 #[test]
157 fn test_build_context_message_with_skipped() {
158 let captured: Vec<CapturedMemory> = vec![];
159 let skipped = vec![SkippedDuplicate {
160 reason: "exact_match".to_string(),
161 matched_urn: "subcog://project/decisions/abc123".to_string(),
162 similarity_score: None,
163 namespace: "decisions".to_string(),
164 }];
165
166 let result = ResponseFormatter::build_context_message(&captured, &skipped);
167 assert!(result.is_some());
168 let msg = result.unwrap();
169 assert!(msg.contains("Skipped 1 duplicates"));
170 assert!(msg.contains("exact_match"));
171 assert!(msg.contains("subcog://project/decisions/abc123"));
172 }
173
174 #[test]
175 fn test_build_context_message_with_similarity_score() {
176 let captured: Vec<CapturedMemory> = vec![];
177 let skipped = vec![SkippedDuplicate {
178 reason: "semantic_similar".to_string(),
179 matched_urn: "subcog://project/patterns/def456".to_string(),
180 similarity_score: Some(0.92),
181 namespace: "patterns".to_string(),
182 }];
183
184 let result = ResponseFormatter::build_context_message(&captured, &skipped);
185 assert!(result.is_some());
186 let msg = result.unwrap();
187 assert!(msg.contains("92% similar"));
188 }
189
190 #[test]
191 fn test_build_hook_response_empty() {
192 let captured: Vec<CapturedMemory> = vec![];
193 let skipped: Vec<SkippedDuplicate> = vec![];
194
195 let response = ResponseFormatter::build_hook_response(&captured, &skipped);
196 assert!(response.as_object().unwrap().is_empty());
198 }
199
200 #[test]
201 fn test_build_hook_response_with_data() {
202 let captured = vec![CapturedMemory {
203 memory_id: "mem-789".to_string(),
204 namespace: "learnings".to_string(),
205 confidence: 0.9,
206 }];
207 let skipped: Vec<SkippedDuplicate> = vec![];
208
209 let response = ResponseFormatter::build_hook_response(&captured, &skipped);
210 assert!(response.as_object().unwrap().is_empty());
213 }
214
215 #[test]
216 fn test_build_hook_response_returns_empty_json() {
217 let captured = vec![CapturedMemory {
218 memory_id: "mem-abc".to_string(),
219 namespace: "blockers".to_string(),
220 confidence: 0.75,
221 }];
222 let skipped: Vec<SkippedDuplicate> = vec![];
223
224 let response = ResponseFormatter::build_hook_response(&captured, &skipped);
225 assert!(response.as_object().unwrap().is_empty());
227 }
228
229 #[test]
230 fn test_build_hook_response_mixed() {
231 let captured = vec![CapturedMemory {
232 memory_id: "new-mem".to_string(),
233 namespace: "context".to_string(),
234 confidence: 0.88,
235 }];
236 let skipped = vec![SkippedDuplicate {
237 reason: "recent_capture".to_string(),
238 matched_urn: "subcog://project/context/old-mem".to_string(),
239 similarity_score: None,
240 namespace: "context".to_string(),
241 }];
242
243 let response = ResponseFormatter::build_hook_response(&captured, &skipped);
244 assert!(response.as_object().unwrap().is_empty());
246
247 let context = ResponseFormatter::build_context_message(&captured, &skipped);
249 assert!(context.is_some());
250 let ctx = context.unwrap();
251 assert!(ctx.contains("Captured 1 memories"));
252 assert!(ctx.contains("Skipped 1 duplicates"));
253 assert!(ctx.contains("new-mem"));
254 assert!(ctx.contains("old-mem"));
255 }
256}