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