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