subcog/hooks/pre_compact/
mod.rs1mod 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;
22pub 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
37pub const MIN_SECTION_LENGTH: usize = 20;
48pub const FINGERPRINT_LENGTH: usize = 50;
50pub const MIN_COMMON_CHARS_FOR_DUPLICATE: usize = 30;
52
53pub struct PreCompactHandler {
76 orchestrator: CaptureOrchestrator,
78 llm: Option<Arc<dyn LlmProvider>>,
80 use_llm_analysis: bool,
82}
83
84#[derive(Debug, Clone, Deserialize, Default)]
86pub struct PreCompactInput {
87 #[serde(default)]
89 pub context: String,
90 #[serde(default)]
92 pub sections: Vec<ConversationSection>,
93}
94
95#[derive(Debug, Clone, Deserialize)]
97pub struct ConversationSection {
98 pub content: String,
100 #[serde(default = "default_role")]
102 pub role: String,
103}
104
105fn default_role() -> String {
106 "assistant".to_string()
107}
108
109impl PreCompactHandler {
110 #[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 #[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 #[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 #[must_use]
149 pub fn with_llm(mut self, llm: Arc<dyn LlmProvider>) -> Self {
150 self.llm = Some(llm);
151 self
152 }
153
154 #[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 fn analyze_content(&self, input: &PreCompactInput) -> Vec<CaptureCandidate> {
165 let mut candidates = Vec::new();
166
167 if !input.context.is_empty() {
169 candidates.extend(self.extract_from_text(&input.context));
170 }
171
172 for section in &input.sections {
174 if section.role == "assistant" {
175 candidates.extend(self.extract_from_text(§ion.content));
176 }
177 }
178
179 analyzer::deduplicate_candidates(candidates)
181 }
182
183 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 fn extract_from_text(&self, text: &str) -> Vec<CaptureCandidate> {
247 let mut candidates = Vec::new();
248
249 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 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 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 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 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 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 else if self.use_llm_analysis {
303 candidates.extend(self.classify_with_llm(section));
304 }
305 }
306
307 candidates
308 }
309
310 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 let parsed: PreCompactInput =
368 serde_json::from_str(input).unwrap_or_else(|_| PreCompactInput {
369 context: input.to_string(),
370 ..Default::default()
371 });
372
373 let candidates = self.analyze_content(&parsed);
375
376 let (captured, skipped) = self.orchestrator.capture_candidates(candidates);
378 let capture_count = captured.len();
379 let skip_count = skipped.len();
380
381 tracing::Span::current().record("captures", capture_count);
383
384 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 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 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 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}