Skip to main content

subcog/hooks/pre_compact/
formatter.rs

1//! Response formatting for pre-compact hook.
2//!
3//! This module handles building human-readable and structured JSON responses
4//! for the pre-compact hook output.
5
6use serde::{Deserialize, Serialize};
7
8/// A memory that was auto-captured.
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct CapturedMemory {
11    /// Memory ID.
12    pub memory_id: String,
13    /// Namespace.
14    pub namespace: String,
15    /// Confidence score.
16    pub confidence: f32,
17}
18
19/// A candidate that was skipped due to duplication.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct SkippedDuplicate {
22    /// The reason it was skipped.
23    pub reason: String,
24    /// URN of the existing memory it matched.
25    pub matched_urn: String,
26    /// Similarity score (for semantic matches).
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub similarity_score: Option<f32>,
29    /// Namespace of the candidate.
30    pub namespace: String,
31}
32
33/// Formats hook responses for Claude Code.
34pub struct ResponseFormatter;
35
36impl ResponseFormatter {
37    /// Builds the human-readable context message for the hook response.
38    #[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()); // blank line
67            }
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    /// Builds the Claude Code hook response JSON.
84    ///
85    /// Note: `PreCompact` hooks don't support `hookSpecificOutput` per Claude Code
86    /// hook specification. The context message is logged for debugging but not
87    /// returned in the response. Returns empty JSON `{}`.
88    #[must_use]
89    pub fn build_hook_response(
90        captured: &[CapturedMemory],
91        skipped: &[SkippedDuplicate],
92    ) -> serde_json::Value {
93        // Build metadata for logging/debugging purposes
94        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        // Log the context for debugging (PreCompact hooks cannot inject context)
111        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        // PreCompact hooks don't support hookSpecificOutput - return empty
121        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        // PreCompact hooks don't support hookSpecificOutput - always empty
197        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        // PreCompact hooks don't support hookSpecificOutput per Claude Code spec
211        // Context is logged but not returned
212        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        // PreCompact hooks return empty JSON - context is logged only
226        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        // PreCompact hooks return empty JSON - context is logged only
245        assert!(response.as_object().unwrap().is_empty());
246
247        // Verify context message generation still works (for logging)
248        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}