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