1use super::HookHandler;
4use crate::Result;
5use crate::current_timestamp;
6use crate::observability::current_request_id;
7use crate::services::SyncService;
8use std::time::{Duration, Instant};
9use tracing::instrument;
10
11const DEFAULT_TIMEOUT_MS: u64 = 30_000;
13
14pub struct StopHandler {
19 sync: Option<SyncService>,
21 auto_sync: bool,
23 timeout_ms: u64,
25}
26
27impl StopHandler {
28 #[must_use]
30 pub const fn new() -> Self {
31 Self {
32 sync: None,
33 auto_sync: true,
34 timeout_ms: DEFAULT_TIMEOUT_MS,
35 }
36 }
37
38 #[must_use]
42 pub const fn with_timeout_ms(mut self, timeout_ms: u64) -> Self {
43 self.timeout_ms = timeout_ms;
44 self
45 }
46
47 #[must_use]
49 pub fn with_sync(mut self, sync: SyncService) -> Self {
50 self.sync = Some(sync);
51 self
52 }
53
54 #[must_use]
56 pub const fn with_auto_sync(mut self, enabled: bool) -> Self {
57 self.auto_sync = enabled;
58 self
59 }
60
61 #[allow(clippy::cast_possible_truncation)]
63 fn generate_summary(&self, input: &serde_json::Value) -> SessionSummary {
64 let session_id = input
66 .get("session_id")
67 .and_then(|v| v.as_str())
68 .unwrap_or("unknown")
69 .to_string();
70
71 let start_time = input
72 .get("start_time")
73 .and_then(serde_json::Value::as_u64)
74 .unwrap_or(0);
75
76 let end_time = current_timestamp();
77 let duration_seconds = end_time.saturating_sub(start_time);
78
79 let interaction_count = input
82 .get("interaction_count")
83 .and_then(serde_json::Value::as_u64)
84 .unwrap_or(0) as usize;
85
86 let memories_captured = input
89 .get("memories_captured")
90 .and_then(serde_json::Value::as_u64)
91 .unwrap_or(0) as usize;
92
93 let tools_used = input
95 .get("tools_used")
96 .and_then(|v| v.as_array())
97 .map_or(0, std::vec::Vec::len);
98
99 let namespace_counts = Self::extract_namespace_counts(input);
101
102 let tags_used = Self::extract_tags_used(input);
104
105 let query_patterns = Self::extract_query_patterns(input);
107
108 let resources_read = Self::extract_resources_read(input);
110
111 SessionSummary {
112 session_id,
113 duration_seconds,
114 interaction_count,
115 memories_captured,
116 tools_used,
117 namespace_counts,
118 tags_used,
119 query_patterns,
120 resources_read,
121 }
122 }
123
124 #[allow(clippy::cast_possible_truncation)]
126 fn extract_namespace_counts(
127 input: &serde_json::Value,
128 ) -> std::collections::HashMap<String, NamespaceStats> {
129 let Some(ns_stats) = input.get("namespace_stats").and_then(|v| v.as_object()) else {
130 return std::collections::HashMap::new();
131 };
132
133 ns_stats
134 .iter()
135 .filter_map(|(ns, stats)| {
136 let captures = stats
137 .get("captures")
138 .and_then(serde_json::Value::as_u64)
139 .unwrap_or(0) as usize;
140 let recalls = stats
141 .get("recalls")
142 .and_then(serde_json::Value::as_u64)
143 .unwrap_or(0) as usize;
144
145 if captures > 0 || recalls > 0 {
146 Some((ns.clone(), NamespaceStats { captures, recalls }))
147 } else {
148 None
149 }
150 })
151 .collect()
152 }
153
154 #[allow(clippy::cast_possible_truncation)]
156 fn extract_tags_used(input: &serde_json::Value) -> Vec<(String, usize)> {
157 let Some(tag_array) = input.get("tags_used").and_then(|v| v.as_array()) else {
158 return Vec::new();
159 };
160
161 let mut tags: Vec<(String, usize)> = tag_array
162 .iter()
163 .filter_map(|entry| {
164 let obj = entry.as_object()?;
165 let tag = obj.get("tag").and_then(|v| v.as_str())?;
166 let count = obj.get("count").and_then(serde_json::Value::as_u64)? as usize;
167 Some((tag.to_string(), count))
168 })
169 .collect();
170
171 tags.sort_by(|a, b| b.1.cmp(&a.1));
173 tags.truncate(10);
174 tags
175 }
176
177 fn extract_query_patterns(input: &serde_json::Value) -> Vec<String> {
179 input
180 .get("query_patterns")
181 .and_then(|v| v.as_array())
182 .map(|arr| {
183 arr.iter()
184 .filter_map(|v| v.as_str().map(String::from))
185 .collect()
186 })
187 .unwrap_or_default()
188 }
189
190 fn extract_resources_read(input: &serde_json::Value) -> Vec<String> {
192 input
193 .get("resources_read")
194 .and_then(|v| v.as_array())
195 .map(|arr| {
196 arr.iter()
197 .filter_map(|v| v.as_str().map(String::from))
198 .collect()
199 })
200 .unwrap_or_default()
201 }
202
203 fn perform_sync(&self) -> Option<SyncResult> {
205 if !self.auto_sync {
206 return None;
207 }
208
209 let sync = self.sync.as_ref()?;
210
211 match sync.sync() {
212 Ok(stats) => Some(SyncResult {
213 success: true,
214 pushed: stats.pushed,
215 pulled: stats.pulled,
216 error: None,
217 }),
218 Err(e) => Some(SyncResult {
219 success: false,
220 pushed: 0,
221 pulled: 0,
222 error: Some(e.to_string()),
223 }),
224 }
225 }
226
227 fn build_metadata(
229 summary: &SessionSummary,
230 sync_result: Option<&SyncResult>,
231 ) -> serde_json::Value {
232 let mut metadata = serde_json::json!({
233 "session_id": summary.session_id,
234 "duration_seconds": summary.duration_seconds,
235 "interaction_count": summary.interaction_count,
236 "memories_captured": summary.memories_captured,
237 "tools_used": summary.tools_used,
238 });
239
240 if !summary.namespace_counts.is_empty() {
242 let ns_json: serde_json::Map<String, serde_json::Value> = summary
243 .namespace_counts
244 .iter()
245 .map(|(ns, stats)| {
246 (
247 ns.clone(),
248 serde_json::json!({
249 "captures": stats.captures,
250 "recalls": stats.recalls
251 }),
252 )
253 })
254 .collect();
255 metadata["namespace_stats"] = serde_json::Value::Object(ns_json);
256 }
257
258 if !summary.tags_used.is_empty() {
260 metadata["tags_used"] = serde_json::json!(summary.tags_used);
261 }
262
263 if !summary.query_patterns.is_empty() {
265 metadata["query_patterns"] = serde_json::json!(summary.query_patterns);
266 }
267
268 if !summary.resources_read.is_empty() {
270 metadata["resources_read"] = serde_json::json!(summary.resources_read);
271 }
272
273 if let Some(sync) = sync_result {
275 metadata["sync"] = serde_json::json!({
276 "performed": true,
277 "success": sync.success,
278 "pushed": sync.pushed,
279 "pulled": sync.pulled,
280 "error": sync.error
281 });
282 } else {
283 metadata["sync"] = serde_json::json!({ "performed": false });
284 }
285
286 if summary.memories_captured == 0 && summary.interaction_count > 5 {
288 metadata["hints"] = serde_json::json!([
289 "Consider capturing key decisions made during this session",
290 "Use 'mcp__plugin_subcog_subcog__subcog_capture' to save important learnings"
291 ]);
292 }
293
294 metadata
295 }
296
297 fn build_context_lines(summary: &SessionSummary, sync_result: Option<&SyncResult>) -> String {
299 let mut lines = vec![
300 "**Subcog Session Summary**\n".to_string(),
301 format!("Session: `{}`", summary.session_id),
302 format!("Duration: {} seconds", summary.duration_seconds),
303 format!("Interactions: {}", summary.interaction_count),
304 format!("Memories captured: {}", summary.memories_captured),
305 format!("Tools used: {}", summary.tools_used),
306 ];
307
308 if !summary.namespace_counts.is_empty() {
310 lines.push("\n**Namespace Breakdown**:".to_string());
311 lines.push("| Namespace | Captures | Recalls |".to_string());
312 lines.push("|-----------|----------|---------|".to_string());
313 let mut sorted_ns: Vec<_> = summary.namespace_counts.iter().collect();
314 sorted_ns.sort_by_key(|(ns, _)| *ns);
315 for (ns, stats) in sorted_ns {
316 lines.push(format!(
317 "| {} | {} | {} |",
318 ns, stats.captures, stats.recalls
319 ));
320 }
321 }
322
323 if !summary.tags_used.is_empty() {
325 let tags_str: Vec<String> = summary
326 .tags_used
327 .iter()
328 .take(5)
329 .map(|(tag, count)| format!("`{tag}` ({count})"))
330 .collect();
331 lines.push(format!("\n**Top Tags**: {}", tags_str.join(", ")));
332 }
333
334 if !summary.query_patterns.is_empty() {
336 let patterns_str: Vec<String> = summary
337 .query_patterns
338 .iter()
339 .take(5)
340 .map(|p| format!("`{p}`"))
341 .collect();
342 lines.push(format!("\n**Query Patterns**: {}", patterns_str.join(", ")));
343 }
344
345 if !summary.resources_read.is_empty() {
347 lines.push(format!(
348 "\n**Resources Read**: {} unique resources",
349 summary.resources_read.len()
350 ));
351 }
352
353 if let Some(sync) = sync_result {
355 if sync.success {
356 lines.push(format!(
357 "\n**Sync**: ✓ {} pushed, {} pulled",
358 sync.pushed, sync.pulled
359 ));
360 } else {
361 lines.push(format!(
362 "\n**Sync**: ✗ Failed - {}",
363 sync.error.as_deref().unwrap_or("Unknown error")
364 ));
365 }
366 }
367
368 if summary.memories_captured == 0 && summary.interaction_count > 5 {
370 lines.push("\n**Tip**: No memories were captured this session. Consider using `mcp__plugin_subcog_subcog__subcog_capture` to save important decisions and learnings.".to_string());
371 }
372
373 lines.join("\n")
374 }
375}
376
377impl Default for StopHandler {
378 fn default() -> Self {
379 Self::new()
380 }
381}
382
383impl HookHandler for StopHandler {
384 fn event_type(&self) -> &'static str {
385 "Stop"
386 }
387
388 #[instrument(
389 name = "subcog.hook.stop",
390 skip(self, input),
391 fields(
392 request_id = tracing::field::Empty,
393 component = "hooks",
394 operation = "stop",
395 hook = "Stop",
396 session_id = tracing::field::Empty,
397 sync_performed = tracing::field::Empty,
398 timed_out = tracing::field::Empty
399 )
400 )]
401 fn handle(&self, input: &str) -> Result<String> {
402 let start = Instant::now();
403 let deadline = Duration::from_millis(self.timeout_ms);
404 let mut timed_out = false;
405 if let Some(request_id) = current_request_id() {
406 tracing::Span::current().record("request_id", request_id.as_str());
407 }
408
409 tracing::info!(
410 hook = "Stop",
411 timeout_ms = self.timeout_ms,
412 "Processing stop hook"
413 );
414
415 let input_json: serde_json::Value =
417 serde_json::from_str(input).unwrap_or_else(|_| serde_json::json!({}));
418 let summary = self.generate_summary(&input_json);
419
420 let span = tracing::Span::current();
422 span.record("session_id", summary.session_id.as_str());
423
424 let sync_result = if start.elapsed() < deadline.saturating_sub(Duration::from_secs(1)) {
427 self.perform_sync()
428 } else {
429 tracing::warn!(
430 hook = "Stop",
431 elapsed_ms = start.elapsed().as_millis(),
432 deadline_ms = self.timeout_ms,
433 "Skipping sync due to timeout deadline"
434 );
435 timed_out = true;
436 None
437 };
438 span.record("sync_performed", sync_result.is_some());
439
440 if start.elapsed() >= deadline {
442 tracing::warn!(
443 hook = "Stop",
444 elapsed_ms = start.elapsed().as_millis(),
445 deadline_ms = self.timeout_ms,
446 "Stop hook exceeded timeout, returning minimal response"
447 );
448 metrics::counter!(
449 "hook_timeouts_total",
450 "hook_type" => "Stop"
451 )
452 .increment(1);
453
454 tracing::debug!(
458 session_id = %summary.session_id,
459 timed_out = true,
460 elapsed_ms = start.elapsed().as_millis(),
461 "Stop hook timed out, returning empty response"
462 );
463 span.record("timed_out", true);
464 return Ok("{}".to_string());
465 }
466
467 let mut metadata = Self::build_metadata(&summary, sync_result.as_ref());
469 let context = Self::build_context_lines(&summary, sync_result.as_ref());
470
471 if timed_out {
473 metadata["sync_skipped_timeout"] = serde_json::json!(true);
474 }
475 #[allow(clippy::cast_possible_truncation)]
476 let elapsed_ms = start.elapsed().as_millis() as u64; metadata["elapsed_ms"] = serde_json::json!(elapsed_ms);
478
479 tracing::info!(
482 session_id = %summary.session_id,
483 duration_seconds = summary.duration_seconds,
484 interaction_count = summary.interaction_count,
485 memories_captured = summary.memories_captured,
486 sync_performed = sync_result.is_some(),
487 "Session ended"
488 );
489 tracing::debug!(context = %context, metadata = ?metadata, "Stop hook context (not returned)");
490
491 span.record("timed_out", timed_out);
492
493 let result = Ok("{}".to_string());
495
496 let status = if result.is_ok() { "success" } else { "error" };
498 metrics::counter!("hook_executions_total", "hook_type" => "Stop", "status" => status)
499 .increment(1);
500 metrics::histogram!("hook_duration_ms", "hook_type" => "Stop")
501 .record(start.elapsed().as_secs_f64() * 1000.0);
502
503 result
504 }
505}
506
507#[derive(Debug, Clone)]
509struct SessionSummary {
510 session_id: String,
512 duration_seconds: u64,
514 interaction_count: usize,
516 memories_captured: usize,
518 tools_used: usize,
520 namespace_counts: std::collections::HashMap<String, NamespaceStats>,
522 tags_used: Vec<(String, usize)>,
524 query_patterns: Vec<String>,
526 resources_read: Vec<String>,
528}
529
530#[derive(Debug, Clone, Default)]
532struct NamespaceStats {
533 captures: usize,
535 recalls: usize,
537}
538
539#[derive(Debug, Clone)]
541struct SyncResult {
542 success: bool,
544 pushed: usize,
546 pulled: usize,
548 error: Option<String>,
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 #[test]
557 fn test_handler_creation() {
558 let handler = StopHandler::default();
559 assert_eq!(handler.event_type(), "Stop");
560 }
561
562 #[test]
563 fn test_generate_summary() {
564 let handler = StopHandler::default();
565
566 let now = current_timestamp();
567 let input = serde_json::json!({
568 "session_id": "test-session",
569 "start_time": now - 3600, "interaction_count": 10,
571 "memories_captured": 2,
572 "tools_used": ["Read", "Write", "Bash"]
573 });
574
575 let summary = handler.generate_summary(&input);
576
577 assert_eq!(summary.session_id, "test-session");
578 assert_eq!(summary.interaction_count, 10);
579 assert_eq!(summary.memories_captured, 2);
580 assert_eq!(summary.tools_used, 3);
581 assert!(summary.duration_seconds >= 3600);
582 }
583
584 #[test]
585 fn test_handle_basic() {
586 let handler = StopHandler::default();
587
588 let input = r#"{"session_id": "test-session", "interaction_count": 5}"#;
589
590 let result = handler.handle(input);
591 assert!(result.is_ok());
592
593 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
594 assert!(response.as_object().unwrap().is_empty());
597 }
598
599 #[test]
600 fn test_handle_with_hints() {
601 let handler = StopHandler::default();
602
603 let input =
604 r#"{"session_id": "test-session", "interaction_count": 10, "memories_captured": 0}"#;
605
606 let result = handler.handle(input);
607 assert!(result.is_ok());
608
609 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
610 assert!(response.as_object().unwrap().is_empty());
612 }
613
614 #[test]
615 fn test_auto_sync_disabled() {
616 let handler = StopHandler::default().with_auto_sync(false);
617
618 let sync_result = handler.perform_sync();
619 assert!(sync_result.is_none());
620 }
621
622 #[test]
623 fn test_configuration() {
624 let handler = StopHandler::default().with_auto_sync(true);
625
626 assert!(handler.auto_sync);
627 }
628
629 #[test]
630 fn test_empty_input() {
631 let handler = StopHandler::default();
632
633 let input = "{}";
634
635 let result = handler.handle(input);
636 assert!(result.is_ok());
637
638 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
639 assert!(response.as_object().unwrap().is_empty());
641 }
642
643 #[test]
644 fn test_namespace_breakdown() {
645 let handler = StopHandler::default();
646
647 let input = serde_json::json!({
648 "session_id": "test-session",
649 "namespace_stats": {
650 "decisions": {"captures": 3, "recalls": 5},
651 "learnings": {"captures": 2, "recalls": 1}
652 }
653 });
654
655 let result = handler.handle(&input.to_string());
656 assert!(result.is_ok());
657
658 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
659 assert!(response.as_object().unwrap().is_empty());
661 }
662
663 #[test]
664 fn test_tags_analysis() {
665 let handler = StopHandler::default();
666
667 let input = serde_json::json!({
668 "session_id": "test-session",
669 "tags_used": [
670 {"tag": "rust", "count": 5},
671 {"tag": "architecture", "count": 3},
672 {"tag": "testing", "count": 2}
673 ]
674 });
675
676 let result = handler.handle(&input.to_string());
677 assert!(result.is_ok());
678
679 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
680 assert!(response.as_object().unwrap().is_empty());
682 }
683
684 #[test]
685 fn test_query_patterns() {
686 let handler = StopHandler::default();
687
688 let input = serde_json::json!({
689 "session_id": "test-session",
690 "query_patterns": ["how to implement", "where is the config"]
691 });
692
693 let result = handler.handle(&input.to_string());
694 assert!(result.is_ok());
695
696 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
697 assert!(response.as_object().unwrap().is_empty());
699 }
700
701 #[test]
702 fn test_resources_tracking() {
703 let handler = StopHandler::default();
704
705 let input = serde_json::json!({
706 "session_id": "test-session",
707 "resources_read": [
708 "subcog://decisions/mem-1",
709 "subcog://learnings/mem-2"
710 ]
711 });
712
713 let result = handler.handle(&input.to_string());
714 assert!(result.is_ok());
715
716 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
717 assert!(response.as_object().unwrap().is_empty());
719 }
720
721 #[test]
722 fn test_extract_namespace_counts() {
723 let input = serde_json::json!({
724 "namespace_stats": {
725 "decisions": {"captures": 3, "recalls": 5},
726 "patterns": {"captures": 1, "recalls": 0}
727 }
728 });
729
730 let counts = StopHandler::extract_namespace_counts(&input);
731
732 assert_eq!(counts.len(), 2);
733 assert_eq!(counts.get("decisions").map(|s| s.captures), Some(3));
734 assert_eq!(counts.get("decisions").map(|s| s.recalls), Some(5));
735 assert_eq!(counts.get("patterns").map(|s| s.captures), Some(1));
736 }
737
738 #[test]
739 fn test_extract_tags_used() {
740 let input = serde_json::json!({
741 "tags_used": [
742 {"tag": "rust", "count": 10},
743 {"tag": "testing", "count": 5},
744 {"tag": "docs", "count": 3}
745 ]
746 });
747
748 let tags = StopHandler::extract_tags_used(&input);
749
750 assert_eq!(tags.len(), 3);
751 assert_eq!(tags[0], ("rust".to_string(), 10)); assert_eq!(tags[1], ("testing".to_string(), 5));
753 }
754
755 #[test]
756 fn test_default_timeout() {
757 let handler = StopHandler::new();
758 assert_eq!(handler.timeout_ms, DEFAULT_TIMEOUT_MS);
759 assert_eq!(handler.timeout_ms, 30_000);
760 }
761
762 #[test]
763 fn test_with_timeout_ms() {
764 let handler = StopHandler::new().with_timeout_ms(5_000);
765 assert_eq!(handler.timeout_ms, 5_000);
766 }
767
768 #[test]
769 fn test_returns_empty_json() {
770 let handler = StopHandler::new();
771 let input = r#"{"session_id": "test-session"}"#;
772
773 let result = handler.handle(input);
774 assert!(result.is_ok());
775
776 let response: serde_json::Value = serde_json::from_str(&result.unwrap()).unwrap();
777 assert!(response.as_object().unwrap().is_empty());
779 }
780
781 #[test]
782 fn test_builder_chaining() {
783 let handler = StopHandler::new()
784 .with_timeout_ms(10_000)
785 .with_auto_sync(false);
786
787 assert_eq!(handler.timeout_ms, 10_000);
788 assert!(!handler.auto_sync);
789 }
790}