1#[allow(clippy::wildcard_imports)]
6use super::*;
7
8pub fn handle_observe() {
16 if is_disabled() {
17 return;
18 }
19 let Some(input) = read_stdin_with_timeout(HOOK_STDIN_TIMEOUT) else {
20 return;
21 };
22 emit_dedicated_session_context(&input);
28
29 super::edit_health::maybe_emit(&input);
33
34 let Some(event) = parse_observe_event(&input) else {
35 return;
36 };
37 append_radar_event(&event);
38
39 if event.event_type == "agent_response"
43 && let Some(text) = event.content.as_deref()
44 {
45 crate::core::output_echo::analyze_and_record(text);
46 }
47}
48
49fn emit_dedicated_session_context(input: &str) {
50 let Ok(v) = serde_json::from_str::<serde_json::Value>(input) else {
51 return;
52 };
53 if !session_start_honours_additional_context(&v) {
54 return;
55 }
56 let cfg = crate::core::config::Config::load();
57 if !cfg.dedicated_session_context_active() {
58 return;
59 }
60 let summary = crate::core::rules_canonical::render(
64 cfg.shadow_mode,
65 crate::core::rules_canonical::Wrapper::Bare,
66 crate::core::config::CompressionLevel::Off,
67 );
68 emit_session_start_additional_context(&summary);
69}
70
71fn session_start_honours_additional_context(v: &serde_json::Value) -> bool {
80 let is_session_start =
81 v.get("hook_event_name").and_then(|e| e.as_str()) == Some("SessionStart");
82 let is_cursor = v
83 .get("conversation_id")
84 .and_then(|c| c.as_str())
85 .is_some_and(|c| !c.is_empty());
86 is_session_start && !is_cursor
87}
88
89#[derive(serde::Serialize)]
90struct ObserveEvent {
91 ts: u64,
92 event_type: &'static str,
93 tokens: usize,
94 #[serde(skip_serializing_if = "Option::is_none")]
95 tool_name: Option<String>,
96 #[serde(skip_serializing_if = "Option::is_none")]
97 detail: Option<String>,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 content: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
101 model: Option<String>,
102 #[serde(skip_serializing_if = "Option::is_none")]
103 conversation_id: Option<String>,
104}
105
106const MAX_CONTENT_CHARS: usize = 50_000;
107
108fn parse_observe_event(input: &str) -> Option<ObserveEvent> {
109 let v: serde_json::Value = serde_json::from_str(input).ok()?;
110
111 let ts = std::time::SystemTime::now()
112 .duration_since(std::time::UNIX_EPOCH)
113 .unwrap_or_default()
114 .as_secs();
115
116 let model = v
117 .get("model")
118 .and_then(|m| m.as_str())
119 .filter(|m| !m.is_empty())
120 .map(String::from);
121 let conversation_id = v
122 .get("conversation_id")
123 .and_then(|c| c.as_str())
124 .filter(|c| !c.is_empty())
125 .map(String::from);
126
127 let transcript_path = v
128 .get("transcript_path")
129 .and_then(|t| t.as_str())
130 .filter(|t| !t.is_empty())
131 .map(String::from);
132
133 if let Some(ref m) = model {
134 persist_detected_model(m);
135 }
136 if let Some(ref tp) = transcript_path {
137 persist_transcript_path(tp, conversation_id.as_deref());
138 }
139
140 let mut event = detect_event_type(&v, ts)?;
141 event.model = model;
142 event.conversation_id = conversation_id;
143 Some(event)
144}
145
146fn detect_event_type(v: &serde_json::Value, ts: u64) -> Option<ObserveEvent> {
147 if let Some(result) = v.get("toolResult") {
152 let tool = super::payload::resolve_tool_name(v).unwrap_or_else(|| "unknown".to_string());
153 let args = super::payload::resolve_tool_args(v);
154 let command = args
155 .as_ref()
156 .and_then(|a| a.get("command"))
157 .and_then(|c| c.as_str());
158 let result_text = result
159 .get("textResultForLlm")
160 .and_then(|t| t.as_str())
161 .map_or_else(|| result.to_string(), String::from);
162 let tokens = result_text.len() / 4;
163 let is_lctx = tool.starts_with("ctx_") || tool.starts_with("mcp__lean-ctx__");
164 let event_type = if is_lctx {
165 "mcp_call"
166 } else if command.is_some() {
167 "shell"
168 } else {
169 "native_tool"
170 };
171 let content = match command {
172 Some(cmd) => format!("$ {cmd}\n{result_text}"),
173 None => result_text,
174 };
175 return Some(ObserveEvent {
176 ts,
177 event_type,
178 tokens,
179 tool_name: Some(tool),
180 detail: command.map(|c| truncate_str(c, 80)),
181 content: Some(cap_content(&content)),
182 model: None,
183 conversation_id: None,
184 });
185 }
186
187 if let Some(result) = v
188 .get("result_json")
189 .or_else(|| v.get("result"))
190 .or_else(|| v.get("tool_response"))
191 .or_else(|| v.get("tool_output"))
192 {
193 let tool = v
194 .get("tool_name")
195 .and_then(|t| t.as_str())
196 .unwrap_or("unknown");
197 let tokens = estimate_tokens_json(result);
198 let content_str = match result {
199 serde_json::Value::String(s) => s.clone(),
200 other => other.to_string(),
201 };
202 return Some(ObserveEvent {
203 ts,
204 event_type: "mcp_call",
205 tokens,
206 tool_name: Some(tool.to_string()),
207 detail: v
208 .get("server_name")
209 .and_then(|s| s.as_str())
210 .map(String::from),
211 content: Some(cap_content(&content_str)),
212 model: None,
213 conversation_id: None,
214 });
215 }
216
217 if let Some(output) = v.get("output") {
218 let cmd = v
219 .get("command")
220 .and_then(|c| c.as_str())
221 .unwrap_or("")
222 .to_string();
223 let tokens = estimate_tokens_value(output);
224 let out_str = match output {
225 serde_json::Value::String(s) => s.clone(),
226 other => other.to_string(),
227 };
228 return Some(ObserveEvent {
229 ts,
230 event_type: "shell",
231 tokens,
232 tool_name: None,
233 detail: Some(truncate_str(&cmd, 80)),
234 content: Some(cap_content(&format!("$ {cmd}\n{out_str}"))),
235 model: None,
236 conversation_id: None,
237 });
238 }
239
240 if v.get("content").is_some() && v.get("file_path").is_some() {
241 let path = v
242 .get("file_path")
243 .and_then(|p| p.as_str())
244 .unwrap_or("")
245 .to_string();
246 let file_content = v.get("content").and_then(|c| c.as_str()).unwrap_or("");
247 let tokens = file_content.len() / 4;
248 return Some(ObserveEvent {
249 ts,
250 event_type: "file_read",
251 tokens,
252 tool_name: None,
253 detail: Some(truncate_str(&path, 120)),
254 content: Some(cap_content(file_content)),
255 model: None,
256 conversation_id: None,
257 });
258 }
259
260 if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
261 let has_duration = v.get("duration_ms").is_some();
262 let event_type = if has_duration {
263 "thinking"
264 } else {
265 "agent_response"
266 };
267 let tokens = text.len() / 4;
268 return Some(ObserveEvent {
269 ts,
270 event_type,
271 tokens,
272 tool_name: None,
273 detail: None,
274 content: Some(cap_content(text)),
275 model: None,
276 conversation_id: None,
277 });
278 }
279
280 if let Some(prompt) = v.get("prompt").and_then(|p| p.as_str()) {
281 let tokens = prompt.len() / 4;
282 let mut full = prompt.to_string();
283 if let Some(attachments) = v.get("attachments").and_then(|a| a.as_array())
284 && !attachments.is_empty()
285 {
286 full.push_str(&format!("\n\n[{} attachments]", attachments.len()));
287 for att in attachments {
288 if let Some(name) = att.get("name").and_then(|n| n.as_str()) {
289 full.push_str(&format!("\n - {name}"));
290 }
291 }
292 }
293 return Some(ObserveEvent {
294 ts,
295 event_type: "user_message",
296 tokens,
297 tool_name: None,
298 detail: v
299 .get("attachments")
300 .and_then(|a| a.as_array())
301 .map(|a| format!("{} attachments", a.len())),
302 content: Some(cap_content(&full)),
303 model: None,
304 conversation_id: None,
305 });
306 }
307
308 if v.get("tool_name").is_some() || v.get("tool_input").is_some() {
309 let tool = v
310 .get("tool_name")
311 .and_then(|t| t.as_str())
312 .unwrap_or("unknown")
313 .to_string();
314 let is_lctx = tool.starts_with("ctx_") || tool.starts_with("mcp__lean-ctx__");
315 let tokens = v.get("tool_input").map_or(0, estimate_tokens_json);
316 let input_str = v
317 .get("tool_input")
318 .map(std::string::ToString::to_string)
319 .unwrap_or_default();
320 return Some(ObserveEvent {
321 ts,
322 event_type: if is_lctx { "mcp_call" } else { "native_tool" },
323 tokens,
324 tool_name: Some(tool),
325 detail: None,
326 content: if input_str.is_empty() {
327 None
328 } else {
329 Some(cap_content(&input_str))
330 },
331 model: None,
332 conversation_id: None,
333 });
334 }
335
336 let is_compaction = v.get("compaction").is_some()
346 || v.get("messages_count").is_some()
347 || v.get("hook_event_name")
348 .and_then(|e| e.as_str())
349 .is_some_and(|e| e == "PreCompact")
350 || v.get("event")
351 .and_then(|e| e.as_str())
352 .is_some_and(|e| e == "compaction" || e == "compact");
353 if !is_compaction && v.get("session_id").is_some() {
354 return Some(ObserveEvent {
355 ts,
356 event_type: "session",
357 tokens: 0,
358 tool_name: None,
359 detail: v
360 .get("session_id")
361 .and_then(|s| s.as_str())
362 .map(String::from),
363 content: None,
364 model: None,
365 conversation_id: None,
366 });
367 }
368
369 if is_compaction {
370 return Some(ObserveEvent {
371 ts,
372 event_type: "compaction",
373 tokens: 0,
374 tool_name: None,
375 detail: None,
376 content: None,
377 model: None,
378 conversation_id: None,
379 });
380 }
381
382 None
383}
384
385fn estimate_tokens_json(v: &serde_json::Value) -> usize {
386 match v {
387 serde_json::Value::String(s) => s.len() / 4,
388 _ => v.to_string().len() / 4,
389 }
390}
391
392fn estimate_tokens_value(v: &serde_json::Value) -> usize {
393 match v {
394 serde_json::Value::String(s) => s.len() / 4,
395 _ => v.to_string().len() / 4,
396 }
397}
398
399fn persist_detected_model(model: &str) {
400 let m = model.to_lowercase();
401 let is_bg_model = m.contains("flash")
402 || m.contains("mini")
403 || m.contains("haiku")
404 || m.contains("fast")
405 || m.contains("nano")
406 || m.contains("small");
407 if is_bg_model {
408 return;
409 }
410
411 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
412 return;
413 };
414 let path = data_dir.join("detected_model.json");
415 let ts = std::time::SystemTime::now()
416 .duration_since(std::time::UNIX_EPOCH)
417 .unwrap_or_default()
418 .as_secs();
419 let window = model_context_window(model);
420 let payload = serde_json::json!({
421 "model": model,
422 "window_size": window,
423 "detected_at": ts,
424 });
425 if let Ok(json) = serde_json::to_string_pretty(&payload) {
426 let tmp = path.with_extension("tmp");
427 if std::fs::write(&tmp, &json).is_ok() {
428 let _ = std::fs::rename(&tmp, &path);
429 }
430 }
431}
432
433pub fn model_context_window(model: &str) -> usize {
434 crate::core::model_registry::context_window_for_model(model)
435}
436
437pub fn load_detected_model() -> Option<(String, usize)> {
438 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
439 let path = data_dir.join("detected_model.json");
440 let content = std::fs::read_to_string(&path).ok()?;
441 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
442 let model = v.get("model")?.as_str()?.to_string();
443 let window = v.get("window_size")?.as_u64()? as usize;
444 let detected_at = v.get("detected_at")?.as_u64()?;
445 let now = std::time::SystemTime::now()
446 .duration_since(std::time::UNIX_EPOCH)
447 .unwrap_or_default()
448 .as_secs();
449 if now.saturating_sub(detected_at) > 7200 {
450 return None;
451 }
452 Some((model, window))
453}
454
455fn persist_transcript_path(path: &str, conversation_id: Option<&str>) {
456 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
457 return;
458 };
459 let meta_path = data_dir.join("active_transcript.json");
460 let ts = std::time::SystemTime::now()
461 .duration_since(std::time::UNIX_EPOCH)
462 .unwrap_or_default()
463 .as_secs();
464 let payload = serde_json::json!({
465 "transcript_path": path,
466 "conversation_id": conversation_id,
467 "updated_at": ts,
468 });
469 if let Ok(json) = serde_json::to_string_pretty(&payload) {
470 let tmp = meta_path.with_extension("tmp");
471 if std::fs::write(&tmp, &json).is_ok() {
472 let _ = std::fs::rename(&tmp, &meta_path);
473 }
474 }
475}
476
477pub fn load_active_transcript() -> Option<(String, Option<String>)> {
478 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
479 let path = data_dir.join("active_transcript.json");
480 let content = std::fs::read_to_string(&path).ok()?;
481 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
482 let tp = v.get("transcript_path")?.as_str()?.to_string();
483 let conv = v
484 .get("conversation_id")
485 .and_then(|c| c.as_str())
486 .map(String::from);
487 let updated = v.get("updated_at")?.as_u64()?;
488 let now = std::time::SystemTime::now()
489 .duration_since(std::time::UNIX_EPOCH)
490 .unwrap_or_default()
491 .as_secs();
492 if now.saturating_sub(updated) > 7200 {
493 return None;
494 }
495 Some((tp, conv))
496}
497
498fn cap_content(s: &str) -> String {
499 if s.len() <= MAX_CONTENT_CHARS {
500 s.to_string()
501 } else {
502 let truncated = safe_truncate(s, MAX_CONTENT_CHARS);
503 format!("{}…\n\n[truncated: {} total chars]", truncated, s.len())
504 }
505}
506
507fn truncate_str(s: &str, max: usize) -> String {
508 if s.len() <= max {
509 s.to_string()
510 } else {
511 format!("{}...", safe_truncate(s, max))
512 }
513}
514
515fn safe_truncate(s: &str, max: usize) -> &str {
517 if max >= s.len() {
518 return s;
519 }
520 let mut end = max;
521 while end > 0 && !s.is_char_boundary(end) {
522 end -= 1;
523 }
524 &s[..end]
525}
526
527fn append_radar_event(event: &ObserveEvent) {
528 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
529 return;
530 };
531 let radar_path = data_dir.join("context_radar.jsonl");
532
533 if event.event_type == "session"
534 && let Ok(meta) = std::fs::metadata(&radar_path)
535 {
536 const MAX_RADAR_SIZE: u64 = 10 * 1024 * 1024; if meta.len() > MAX_RADAR_SIZE {
538 let prev = data_dir.join("context_radar.prev.jsonl");
539 let _ = std::fs::rename(&radar_path, &prev);
540 }
541 }
542
543 let Ok(line) = serde_json::to_string(event) else {
544 return;
545 };
546
547 use std::fs::OpenOptions;
548 use std::io::Write;
549 if let Ok(mut f) = OpenOptions::new()
550 .create(true)
551 .append(true)
552 .open(&radar_path)
553 {
554 let _ = writeln!(f, "{line}");
555 }
556}
557
558#[must_use]
566pub fn radar_event_count() -> usize {
567 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
568 return 0;
569 };
570 let Ok(file) = std::fs::File::open(data_dir.join("context_radar.jsonl")) else {
571 return 0;
572 };
573 use std::io::{BufRead, BufReader};
574 BufReader::new(file)
575 .lines()
576 .map_while(Result::ok)
577 .filter(|l| !l.trim().is_empty())
578 .count()
579}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn detect_event_type_tool_response_is_mcp_call() {
587 let v = serde_json::json!({
588 "tool_name": "ctx_read",
589 "tool_response": "file contents here"
590 });
591 let event = detect_event_type(&v, 1000).unwrap();
592 assert_eq!(event.event_type, "mcp_call");
593 }
594
595 #[test]
596 fn detect_event_type_tool_output_is_mcp_call() {
597 let v = serde_json::json!({
598 "tool_name": "ctx_search",
599 "tool_output": "search results"
600 });
601 let event = detect_event_type(&v, 1000).unwrap();
602 assert_eq!(event.event_type, "mcp_call");
603 }
604
605 #[test]
606 fn detect_event_type_ctx_prefix_is_mcp_call() {
607 let v = serde_json::json!({
608 "tool_name": "ctx_read",
609 "tool_input": {"path": "src/main.rs"}
610 });
611 let event = detect_event_type(&v, 1000).unwrap();
612 assert_eq!(event.event_type, "mcp_call");
613 }
614
615 #[test]
616 fn detect_event_type_mcp_prefix_is_mcp_call() {
617 let v = serde_json::json!({
618 "tool_name": "mcp__lean-ctx__ctx_read",
619 "tool_input": {"path": "src/main.rs"}
620 });
621 let event = detect_event_type(&v, 1000).unwrap();
622 assert_eq!(event.event_type, "mcp_call");
623 }
624
625 #[test]
626 fn detect_event_type_native_read_is_native_tool() {
627 let v = serde_json::json!({
628 "tool_name": "Read",
629 "tool_input": {"path": "src/main.rs"}
630 });
631 let event = detect_event_type(&v, 1000).unwrap();
632 assert_eq!(event.event_type, "native_tool");
633 }
634
635 #[test]
636 fn detect_event_type_copilot_bash_posttooluse_is_shell() {
637 let v = serde_json::json!({
640 "toolName": "bash",
641 "toolArgs": "{\"command\":\"npm test\"}",
642 "toolResult": {
643 "resultType": "success",
644 "textResultForLlm": "All tests passed (15/15)"
645 }
646 });
647 let event = detect_event_type(&v, 1000).unwrap();
648 assert_eq!(event.event_type, "shell");
649 assert_eq!(event.tool_name.as_deref(), Some("bash"));
650 assert_eq!(event.detail.as_deref(), Some("npm test"));
651 assert!(event.content.unwrap().contains("All tests passed"));
652 }
653
654 #[test]
655 fn detect_event_type_copilot_ctx_tool_is_mcp_call() {
656 let v = serde_json::json!({
657 "toolName": "ctx_read",
658 "toolArgs": "{\"path\":\"src/main.rs\"}",
659 "toolResult": { "textResultForLlm": "file contents" }
660 });
661 let event = detect_event_type(&v, 1000).unwrap();
662 assert_eq!(event.event_type, "mcp_call");
663 assert_eq!(event.tool_name.as_deref(), Some("ctx_read"));
664 }
665
666 #[test]
667 fn detect_event_type_result_json_is_mcp_call() {
668 let v = serde_json::json!({
669 "tool_name": "ctx_read",
670 "result_json": {"content": "..."}
671 });
672 let event = detect_event_type(&v, 1000).unwrap();
673 assert_eq!(event.event_type, "mcp_call");
674 }
675
676 #[test]
680 fn detect_event_type_claude_precompact_is_compaction() {
681 let v = serde_json::json!({
682 "session_id": "abc123",
683 "transcript_path": "/Users/u/.claude/projects/x/abc123.jsonl",
684 "cwd": "/Users/u/project",
685 "hook_event_name": "PreCompact",
686 "trigger": "auto",
687 "custom_instructions": ""
688 });
689 let event = detect_event_type(&v, 1000).unwrap();
690 assert_eq!(event.event_type, "compaction");
691 }
692
693 #[test]
694 fn detect_event_type_plain_session_event_still_session() {
695 let v = serde_json::json!({
696 "session_id": "abc123",
697 "hook_event_name": "SessionStart"
698 });
699 let event = detect_event_type(&v, 1000).unwrap();
700 assert_eq!(event.event_type, "session");
701 }
702
703 #[test]
704 fn session_start_honoured_for_claude_payload() {
705 let v = serde_json::json!({
707 "hook_event_name": "SessionStart",
708 "session_id": "abc123",
709 "source": "startup"
710 });
711 assert!(session_start_honours_additional_context(&v));
712 }
713
714 #[test]
715 fn session_start_skipped_for_cursor_payload() {
716 let v = serde_json::json!({
719 "hook_event_name": "SessionStart",
720 "conversation_id": "0e1f4ed8-d858-4557-9fc5-6cbf5298eb8b",
721 "model": "claude-opus"
722 });
723 assert!(!session_start_honours_additional_context(&v));
724 }
725
726 #[test]
727 fn session_start_skipped_for_non_session_event() {
728 let v = serde_json::json!({ "hook_event_name": "PreToolUse", "session_id": "x" });
729 assert!(!session_start_honours_additional_context(&v));
730 }
731}