Skip to main content

subcog/hooks/pre_compact/
mod.rs

1//! Pre-compact hook handler.
2//!
3//! Analyzes content being compacted and auto-captures important memories.
4//! Integrates with `DeduplicationService` to avoid capturing duplicate memories.
5//!
6//! # Module Structure
7//!
8//! - [`analyzer`]: Content analysis and namespace classification
9//! - [`orchestrator`]: Capture coordination with deduplication
10//! - [`formatter`]: Response formatting for hook output
11
12mod analyzer;
13mod formatter;
14mod orchestrator;
15
16pub use analyzer::{
17    CaptureCandidate, calculate_section_confidence, contains_blocker_language,
18    contains_context_language, contains_decision_language, contains_learning_language,
19    contains_pattern_language,
20};
21pub use formatter::ResponseFormatter;
22// CapturedMemory and SkippedDuplicate are internal types used by orchestrator and formatter
23pub use orchestrator::CaptureOrchestrator;
24
25use crate::Result;
26use crate::hooks::HookHandler;
27use crate::llm::LlmProvider;
28use crate::models::Namespace;
29use crate::observability::current_request_id;
30use crate::services::CaptureService;
31use crate::services::deduplication::Deduplicator;
32use serde::Deserialize;
33use std::sync::Arc;
34use std::time::Instant;
35use tracing::instrument;
36
37// Content analysis thresholds
38//
39// These constants are intentionally kept in the pre_compact module rather than
40// centralized in config for the following reasons:
41// 1. They are implementation details specific to the pre-compact hook algorithm
42// 2. They are not user-configurable and don't need environment variable support
43// 3. Moving them to config would increase coupling between modules
44// 4. They are compile-time constants that benefit from inlining
45
46/// Minimum section length to consider for capture.
47pub const MIN_SECTION_LENGTH: usize = 20;
48/// Length of content fingerprint for deduplication.
49pub const FINGERPRINT_LENGTH: usize = 50;
50/// Minimum common characters to consider a duplicate.
51pub const MIN_COMMON_CHARS_FOR_DUPLICATE: usize = 30;
52
53/// Handler for the `PreCompact` hook event.
54///
55/// Analyzes context being compacted and auto-captures valuable memories.
56/// Integrates with `DeduplicationService` to check for duplicates before capture.
57///
58/// # Deduplication
59///
60/// When a deduplication service is configured, each candidate is checked against:
61/// 1. **Exact match**: SHA256 hash comparison
62/// 2. **Semantic similarity**: Embedding cosine similarity (if embeddings available)
63/// 3. **Recent capture**: LRU cache with TTL
64///
65/// Duplicates are skipped and reported in the hook output.
66///
67/// # LLM Analysis Mode
68///
69/// When an LLM provider is configured and `use_llm_analysis` is enabled, content
70/// that doesn't match keyword-based detection patterns will be analyzed by the
71/// LLM for classification. This provides more accurate namespace assignment at
72/// the cost of increased latency.
73///
74/// Configure via environment variable: `SUBCOG_AUTO_CAPTURE_USE_LLM=true`
75pub struct PreCompactHandler {
76    /// Capture orchestrator for coordinating captures with deduplication.
77    orchestrator: CaptureOrchestrator,
78    /// Optional LLM provider for content classification.
79    llm: Option<Arc<dyn LlmProvider>>,
80    /// Whether to use LLM for classifying ambiguous content.
81    use_llm_analysis: bool,
82}
83
84/// Input for the `PreCompact` hook.
85#[derive(Debug, Clone, Deserialize, Default)]
86pub struct PreCompactInput {
87    /// Conversation context being compacted.
88    #[serde(default)]
89    pub context: String,
90    /// Sections of the conversation.
91    #[serde(default)]
92    pub sections: Vec<ConversationSection>,
93}
94
95/// A section of conversation.
96#[derive(Debug, Clone, Deserialize)]
97pub struct ConversationSection {
98    /// Section content.
99    pub content: String,
100    /// Type of content (user, assistant, `tool_result`).
101    #[serde(default = "default_role")]
102    pub role: String,
103}
104
105fn default_role() -> String {
106    "assistant".to_string()
107}
108
109impl PreCompactHandler {
110    /// Creates a new `PreCompact` handler.
111    ///
112    /// The `use_llm_analysis` setting is loaded from the environment variable
113    /// `SUBCOG_AUTO_CAPTURE_USE_LLM` (default: false).
114    #[must_use]
115    pub fn new() -> Self {
116        let use_llm_analysis = std::env::var("SUBCOG_AUTO_CAPTURE_USE_LLM")
117            .map(|v| v.to_lowercase() == "true" || v == "1")
118            .unwrap_or(false);
119
120        Self {
121            orchestrator: CaptureOrchestrator::new(),
122            llm: None,
123            use_llm_analysis,
124        }
125    }
126
127    /// Sets the capture service.
128    #[must_use]
129    pub fn with_capture(mut self, capture: CaptureService) -> Self {
130        self.orchestrator = self.orchestrator.with_capture(capture);
131        self
132    }
133
134    /// Sets the deduplication service.
135    ///
136    /// When set, candidates are checked for duplicates before capture.
137    /// Duplicates are skipped and reported in the hook output.
138    #[must_use]
139    pub fn with_deduplication(mut self, dedup: Arc<dyn Deduplicator>) -> Self {
140        self.orchestrator = self.orchestrator.with_deduplication(dedup);
141        self
142    }
143
144    /// Sets the LLM provider for content classification.
145    ///
146    /// When set (and `SUBCOG_AUTO_CAPTURE_USE_LLM=true`), content that doesn't
147    /// match keyword-based detection will be analyzed by the LLM.
148    #[must_use]
149    pub fn with_llm(mut self, llm: Arc<dyn LlmProvider>) -> Self {
150        self.llm = Some(llm);
151        self
152    }
153
154    /// Enables or disables LLM analysis mode.
155    ///
156    /// This overrides the `SUBCOG_AUTO_CAPTURE_USE_LLM` environment variable.
157    #[must_use]
158    pub const fn with_llm_analysis(mut self, enabled: bool) -> Self {
159        self.use_llm_analysis = enabled;
160        self
161    }
162
163    /// Analyzes content and extracts capture candidates.
164    fn analyze_content(&self, input: &PreCompactInput) -> Vec<CaptureCandidate> {
165        let mut candidates = Vec::new();
166
167        // Analyze the full context
168        if !input.context.is_empty() {
169            candidates.extend(self.extract_from_text(&input.context));
170        }
171
172        // Analyze individual sections
173        for section in &input.sections {
174            if section.role == "assistant" {
175                candidates.extend(self.extract_from_text(&section.content));
176            }
177        }
178
179        // Deduplicate similar candidates
180        analyzer::deduplicate_candidates(candidates)
181    }
182
183    /// Uses LLM to classify content that didn't match keyword detection.
184    ///
185    /// Returns `Some(CaptureCandidate)` if the LLM suggests capturing,
186    /// `None` if LLM is unavailable or suggests not capturing.
187    fn classify_with_llm(&self, section: &str) -> Option<CaptureCandidate> {
188        let llm = self.llm.as_ref()?;
189
190        match llm.analyze_for_capture(section) {
191            Ok(analysis) if analysis.should_capture && analysis.confidence > 0.6 => {
192                let namespace = analysis
193                    .suggested_namespace
194                    .as_ref()
195                    .and_then(|ns| Namespace::parse(ns))
196                    .unwrap_or(Namespace::Context);
197
198                tracing::debug!(
199                    namespace = %namespace.as_str(),
200                    confidence = analysis.confidence,
201                    reasoning = %analysis.reasoning,
202                    "LLM classified content for capture"
203                );
204
205                metrics::counter!(
206                    "hook_llm_classifications_total",
207                    "hook_type" => "PreCompact",
208                    "namespace" => namespace.as_str().to_string(),
209                    "result" => "capture"
210                )
211                .increment(1);
212
213                Some(CaptureCandidate {
214                    content: section.to_string(),
215                    namespace,
216                    confidence: analysis.confidence,
217                })
218            },
219            Ok(analysis) => {
220                tracing::debug!(
221                    confidence = analysis.confidence,
222                    should_capture = analysis.should_capture,
223                    "LLM analysis did not suggest capture"
224                );
225
226                metrics::counter!(
227                    "hook_llm_classifications_total",
228                    "hook_type" => "PreCompact",
229                    "result" => "skip"
230                )
231                .increment(1);
232
233                None
234            },
235            Err(e) => {
236                tracing::warn!(error = %e, "LLM classification failed, skipping content");
237                None
238            },
239        }
240    }
241
242    /// Extracts potential memories from text.
243    ///
244    /// Uses keyword-based detection first, then optionally falls back to LLM
245    /// classification if `use_llm_analysis` is enabled.
246    fn extract_from_text(&self, text: &str) -> Vec<CaptureCandidate> {
247        let mut candidates = Vec::new();
248
249        // Split into paragraphs or logical sections
250        let sections: Vec<&str> = text
251            .split("\n\n")
252            .filter(|s| !s.trim().is_empty())
253            .collect();
254
255        for section in sections {
256            let section = section.trim();
257            if section.len() < MIN_SECTION_LENGTH {
258                continue;
259            }
260
261            // Check for decision-related language
262            if contains_decision_language(section) {
263                candidates.push(CaptureCandidate {
264                    content: section.to_string(),
265                    namespace: Namespace::Decisions,
266                    confidence: calculate_section_confidence(section),
267                });
268            }
269            // Check for learning-related language
270            else if contains_learning_language(section) {
271                candidates.push(CaptureCandidate {
272                    content: section.to_string(),
273                    namespace: Namespace::Learnings,
274                    confidence: calculate_section_confidence(section),
275                });
276            }
277            // Check for blocker/issue resolution language
278            else if contains_blocker_language(section) {
279                candidates.push(CaptureCandidate {
280                    content: section.to_string(),
281                    namespace: Namespace::Blockers,
282                    confidence: calculate_section_confidence(section),
283                });
284            }
285            // Check for pattern-related language
286            else if contains_pattern_language(section) {
287                candidates.push(CaptureCandidate {
288                    content: section.to_string(),
289                    namespace: Namespace::Patterns,
290                    confidence: calculate_section_confidence(section),
291                });
292            }
293            // Check for context-related language (explains "why" behind decisions)
294            else if contains_context_language(section) {
295                candidates.push(CaptureCandidate {
296                    content: section.to_string(),
297                    namespace: Namespace::Context,
298                    confidence: calculate_section_confidence(section),
299                });
300            }
301            // No keyword match - try LLM classification if enabled
302            else if self.use_llm_analysis {
303                candidates.extend(self.classify_with_llm(section));
304            }
305        }
306
307        candidates
308    }
309
310    /// Records metrics for the hook execution.
311    fn record_metrics(status: &str, duration_ms: f64, capture_count: usize, skip_count: usize) {
312        metrics::counter!(
313            "hook_executions_total",
314            "hook_type" => "PreCompact",
315            "status" => status.to_string()
316        )
317        .increment(1);
318        metrics::histogram!("hook_duration_ms", "hook_type" => "PreCompact").record(duration_ms);
319        if capture_count > 0 {
320            metrics::counter!(
321                "hook_auto_capture_total",
322                "hook_type" => "PreCompact",
323                "namespace" => "mixed"
324            )
325            .increment(capture_count as u64);
326        }
327        if skip_count > 0 {
328            metrics::counter!(
329                "hook_deduplication_skipped_total",
330                "hook_type" => "PreCompact",
331                "reason" => "aggregate"
332            )
333            .increment(skip_count as u64);
334        }
335    }
336}
337
338impl Default for PreCompactHandler {
339    fn default() -> Self {
340        Self::new()
341    }
342}
343
344impl HookHandler for PreCompactHandler {
345    fn event_type(&self) -> &'static str {
346        "PreCompact"
347    }
348
349    #[instrument(
350        name = "subcog.hook.pre_compact",
351        skip(self, input),
352        fields(
353            request_id = tracing::field::Empty,
354            component = "hooks",
355            operation = "pre_compact",
356            hook = "PreCompact",
357            captures = tracing::field::Empty
358        )
359    )]
360    fn handle(&self, input: &str) -> Result<String> {
361        let start = Instant::now();
362        if let Some(request_id) = current_request_id() {
363            tracing::Span::current().record("request_id", request_id.as_str());
364        }
365
366        // Parse input
367        let parsed: PreCompactInput =
368            serde_json::from_str(input).unwrap_or_else(|_| PreCompactInput {
369                context: input.to_string(),
370                ..Default::default()
371            });
372
373        // Analyze content for capture candidates
374        let candidates = self.analyze_content(&parsed);
375
376        // Capture the candidates (with deduplication if configured)
377        let (captured, skipped) = self.orchestrator.capture_candidates(candidates);
378        let capture_count = captured.len();
379        let skip_count = skipped.len();
380
381        // Record captures in tracing span
382        tracing::Span::current().record("captures", capture_count);
383
384        // Build response
385        let response = ResponseFormatter::build_hook_response(&captured, &skipped);
386        let result = serde_json::to_string(&response).map_err(|e| crate::Error::OperationFailed {
387            operation: "serialize_output".to_string(),
388            cause: e.to_string(),
389        });
390
391        // Record metrics
392        let status = if result.is_ok() { "success" } else { "error" };
393        Self::record_metrics(
394            status,
395            start.elapsed().as_secs_f64() * 1000.0,
396            capture_count,
397            skip_count,
398        );
399
400        result
401    }
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407    use crate::services::deduplication::{Deduplicator, DuplicateCheckResult, DuplicateReason};
408
409    #[test]
410    fn test_handler_creation() {
411        let handler = PreCompactHandler::default();
412        assert_eq!(handler.event_type(), "PreCompact");
413    }
414
415    #[test]
416    fn test_handle_empty_input() {
417        let handler = PreCompactHandler::default();
418        let result = handler.handle("{}");
419
420        assert!(result.is_ok());
421        let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
422        // Claude Code hook format - empty response when nothing captured
423        assert!(response.as_object().unwrap().is_empty());
424    }
425
426    #[test]
427    fn test_analyze_content() {
428        let handler = PreCompactHandler::default();
429        let input = PreCompactInput {
430            context: "We decided to use PostgreSQL for the database. This was a key architectural decision.\n\nTIL that connection pooling is important for performance.".to_string(),
431            sections: vec![],
432        };
433
434        let candidates = handler.analyze_content(&input);
435        assert!(!candidates.is_empty());
436    }
437
438    #[test]
439    fn test_with_deduplication_builder() {
440        // Mock deduplicator that always returns not duplicate
441        struct MockDedup;
442        impl Deduplicator for MockDedup {
443            fn check_duplicate(
444                &self,
445                _content: &str,
446                _namespace: Namespace,
447            ) -> crate::Result<DuplicateCheckResult> {
448                Ok(DuplicateCheckResult::not_duplicate(0))
449            }
450            fn record_capture(&self, _hash: &str, _memory_id: &crate::models::MemoryId) {}
451        }
452
453        let dedup = Arc::new(MockDedup);
454        let handler = PreCompactHandler::new().with_deduplication(dedup);
455        assert!(handler.orchestrator.has_deduplication());
456    }
457
458    #[test]
459    fn test_reason_to_str() {
460        assert_eq!(
461            orchestrator::reason_to_str(Some(DuplicateReason::ExactMatch)),
462            "exact_match"
463        );
464        assert_eq!(
465            orchestrator::reason_to_str(Some(DuplicateReason::SemanticSimilar)),
466            "semantic_similar"
467        );
468        assert_eq!(
469            orchestrator::reason_to_str(Some(DuplicateReason::RecentCapture)),
470            "recent_capture"
471        );
472        assert_eq!(orchestrator::reason_to_str(None), "unknown");
473    }
474}