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 let session_id = v
155 .get("session_id")
156 .and_then(|s| s.as_str())
157 .filter(|s| !s.is_empty())
158 .map(String::from);
159
160 if let Some(ref m) = model {
161 persist_detected_model(m);
162 }
163 if let Some(ref tp) = transcript_path {
164 persist_transcript_path(tp, conversation_id.as_deref(), session_id.as_deref());
165 }
166
167 let mut event = detect_event_type(&v, ts)?;
168 event.model = model;
169 event.conversation_id = conversation_id;
170 Some(event)
171}
172
173fn detect_event_type(v: &serde_json::Value, ts: u64) -> Option<ObserveEvent> {
174 if let Some(result) = v.get("toolResult") {
179 let tool = super::payload::resolve_tool_name(v).unwrap_or_else(|| "unknown".to_string());
180 let args = super::payload::resolve_tool_args(v);
181 let command = args
182 .as_ref()
183 .and_then(|a| a.get("command"))
184 .and_then(|c| c.as_str());
185 let result_text = result
186 .get("textResultForLlm")
187 .and_then(|t| t.as_str())
188 .map_or_else(|| result.to_string(), String::from);
189 let tokens = result_text.len() / 4;
190 let is_lctx = tool.starts_with("ctx_") || tool.starts_with("mcp__lean-ctx__");
191 let event_type = if is_lctx {
192 "mcp_call"
193 } else if command.is_some() {
194 "shell"
195 } else {
196 "native_tool"
197 };
198 let content = match command {
199 Some(cmd) => format!("$ {cmd}\n{result_text}"),
200 None => result_text,
201 };
202 return Some(ObserveEvent {
203 ts,
204 event_type,
205 tokens,
206 tool_name: Some(tool),
207 detail: command.map(|c| truncate_str(c, 80)),
208 content: Some(cap_content(&content)),
209 model: None,
210 conversation_id: None,
211 });
212 }
213
214 if let Some(result) = v
215 .get("result_json")
216 .or_else(|| v.get("result"))
217 .or_else(|| v.get("tool_response"))
218 .or_else(|| v.get("tool_output"))
219 {
220 let tool = v
221 .get("tool_name")
222 .and_then(|t| t.as_str())
223 .unwrap_or("unknown");
224 let tokens = estimate_tokens_json(result);
225 let content_str = match result {
226 serde_json::Value::String(s) => s.clone(),
227 other => other.to_string(),
228 };
229 return Some(ObserveEvent {
230 ts,
231 event_type: "mcp_call",
232 tokens,
233 tool_name: Some(tool.to_string()),
234 detail: v
235 .get("server_name")
236 .and_then(|s| s.as_str())
237 .map(String::from),
238 content: Some(cap_content(&content_str)),
239 model: None,
240 conversation_id: None,
241 });
242 }
243
244 if let Some(output) = v.get("output") {
245 let cmd = v
246 .get("command")
247 .and_then(|c| c.as_str())
248 .unwrap_or("")
249 .to_string();
250 let tokens = estimate_tokens_value(output);
251 let out_str = match output {
252 serde_json::Value::String(s) => s.clone(),
253 other => other.to_string(),
254 };
255 return Some(ObserveEvent {
256 ts,
257 event_type: "shell",
258 tokens,
259 tool_name: None,
260 detail: Some(truncate_str(&cmd, 80)),
261 content: Some(cap_content(&format!("$ {cmd}\n{out_str}"))),
262 model: None,
263 conversation_id: None,
264 });
265 }
266
267 if v.get("content").is_some() && v.get("file_path").is_some() {
268 let path = v
269 .get("file_path")
270 .and_then(|p| p.as_str())
271 .unwrap_or("")
272 .to_string();
273 let file_content = v.get("content").and_then(|c| c.as_str()).unwrap_or("");
274 let tokens = file_content.len() / 4;
275 return Some(ObserveEvent {
276 ts,
277 event_type: "file_read",
278 tokens,
279 tool_name: None,
280 detail: Some(truncate_str(&path, 120)),
281 content: Some(cap_content(file_content)),
282 model: None,
283 conversation_id: None,
284 });
285 }
286
287 if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
288 let has_duration = v.get("duration_ms").is_some();
289 let event_type = if has_duration {
290 "thinking"
291 } else {
292 "agent_response"
293 };
294 let tokens = text.len() / 4;
295 return Some(ObserveEvent {
296 ts,
297 event_type,
298 tokens,
299 tool_name: None,
300 detail: None,
301 content: Some(cap_content(text)),
302 model: None,
303 conversation_id: None,
304 });
305 }
306
307 if let Some(prompt) = v.get("prompt").and_then(|p| p.as_str()) {
308 let tokens = prompt.len() / 4;
309 let mut full = prompt.to_string();
310 if let Some(attachments) = v.get("attachments").and_then(|a| a.as_array())
311 && !attachments.is_empty()
312 {
313 full.push_str(&format!("\n\n[{} attachments]", attachments.len()));
314 for att in attachments {
315 if let Some(name) = att.get("name").and_then(|n| n.as_str()) {
316 full.push_str(&format!("\n - {name}"));
317 }
318 }
319 }
320 return Some(ObserveEvent {
321 ts,
322 event_type: "user_message",
323 tokens,
324 tool_name: None,
325 detail: v
326 .get("attachments")
327 .and_then(|a| a.as_array())
328 .map(|a| format!("{} attachments", a.len())),
329 content: Some(cap_content(&full)),
330 model: None,
331 conversation_id: None,
332 });
333 }
334
335 if v.get("tool_name").is_some() || v.get("tool_input").is_some() {
336 let tool = v
337 .get("tool_name")
338 .and_then(|t| t.as_str())
339 .unwrap_or("unknown")
340 .to_string();
341 let is_lctx = tool.starts_with("ctx_") || tool.starts_with("mcp__lean-ctx__");
342 let tokens = v.get("tool_input").map_or(0, estimate_tokens_json);
343 let input_str = v
344 .get("tool_input")
345 .map(std::string::ToString::to_string)
346 .unwrap_or_default();
347 return Some(ObserveEvent {
348 ts,
349 event_type: if is_lctx { "mcp_call" } else { "native_tool" },
350 tokens,
351 tool_name: Some(tool),
352 detail: None,
353 content: if input_str.is_empty() {
354 None
355 } else {
356 Some(cap_content(&input_str))
357 },
358 model: None,
359 conversation_id: None,
360 });
361 }
362
363 let is_compaction = v.get("compaction").is_some()
373 || v.get("messages_count").is_some()
374 || v.get("hook_event_name")
375 .and_then(|e| e.as_str())
376 .is_some_and(|e| e == "PreCompact")
377 || v.get("event")
378 .and_then(|e| e.as_str())
379 .is_some_and(|e| e == "compaction" || e == "compact");
380 if !is_compaction && v.get("session_id").is_some() {
381 return Some(ObserveEvent {
382 ts,
383 event_type: "session",
384 tokens: 0,
385 tool_name: None,
386 detail: v
387 .get("session_id")
388 .and_then(|s| s.as_str())
389 .map(String::from),
390 content: None,
391 model: None,
392 conversation_id: None,
393 });
394 }
395
396 if is_compaction {
397 return Some(ObserveEvent {
398 ts,
399 event_type: "compaction",
400 tokens: 0,
401 tool_name: None,
402 detail: None,
403 content: None,
404 model: None,
405 conversation_id: None,
406 });
407 }
408
409 None
410}
411
412fn estimate_tokens_json(v: &serde_json::Value) -> usize {
413 match v {
414 serde_json::Value::String(s) => s.len() / 4,
415 _ => v.to_string().len() / 4,
416 }
417}
418
419fn estimate_tokens_value(v: &serde_json::Value) -> usize {
420 match v {
421 serde_json::Value::String(s) => s.len() / 4,
422 _ => v.to_string().len() / 4,
423 }
424}
425
426fn persist_detected_model(model: &str) {
427 let m = model.to_lowercase();
428 let is_bg_model = m.contains("flash")
429 || m.contains("mini")
430 || m.contains("haiku")
431 || m.contains("fast")
432 || m.contains("nano")
433 || m.contains("small");
434 if is_bg_model {
435 return;
436 }
437
438 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
439 return;
440 };
441 let path = data_dir.join("detected_model.json");
442 let ts = std::time::SystemTime::now()
443 .duration_since(std::time::UNIX_EPOCH)
444 .unwrap_or_default()
445 .as_secs();
446 let window = model_context_window(model);
447 let payload = serde_json::json!({
448 "model": model,
449 "window_size": window,
450 "detected_at": ts,
451 });
452 if let Ok(json) = serde_json::to_string_pretty(&payload) {
453 let tmp = path.with_extension("tmp");
454 if std::fs::write(&tmp, &json).is_ok() {
455 let _ = std::fs::rename(&tmp, &path);
456 }
457 }
458}
459
460pub fn model_context_window(model: &str) -> usize {
461 crate::core::model_registry::context_window_for_model(model)
462}
463
464pub fn load_detected_model() -> Option<(String, usize)> {
465 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
466 let path = data_dir.join("detected_model.json");
467 let content = std::fs::read_to_string(&path).ok()?;
468 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
469 let model = v.get("model")?.as_str()?.to_string();
470 let window = v.get("window_size")?.as_u64()? as usize;
471 let detected_at = v.get("detected_at")?.as_u64()?;
472 let now = std::time::SystemTime::now()
473 .duration_since(std::time::UNIX_EPOCH)
474 .unwrap_or_default()
475 .as_secs();
476 if now.saturating_sub(detected_at) > 7200 {
477 return None;
478 }
479 Some((model, window))
480}
481
482fn persist_transcript_path(path: &str, conversation_id: Option<&str>, session_id: Option<&str>) {
483 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
484 return;
485 };
486 let meta_path = data_dir.join("active_transcript.json");
487 let ts = std::time::SystemTime::now()
488 .duration_since(std::time::UNIX_EPOCH)
489 .unwrap_or_default()
490 .as_secs();
491 let payload = serde_json::json!({
492 "transcript_path": path,
493 "conversation_id": conversation_id,
494 "session_id": session_id,
495 "updated_at": ts,
496 });
497 if let Ok(json) = serde_json::to_string_pretty(&payload) {
498 let tmp = meta_path.with_extension("tmp");
499 if std::fs::write(&tmp, &json).is_ok() {
500 let _ = std::fs::rename(&tmp, &meta_path);
501 }
502 }
503}
504
505pub fn load_active_transcript() -> Option<(String, Option<String>)> {
506 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
507 let path = data_dir.join("active_transcript.json");
508 let content = std::fs::read_to_string(&path).ok()?;
509 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
510 let tp = v.get("transcript_path")?.as_str()?.to_string();
511 let conv = v
515 .get("conversation_id")
516 .and_then(|c| c.as_str())
517 .or_else(|| v.get("session_id").and_then(|s| s.as_str()))
518 .map(String::from);
519 let updated = v.get("updated_at")?.as_u64()?;
520 let now = std::time::SystemTime::now()
521 .duration_since(std::time::UNIX_EPOCH)
522 .unwrap_or_default()
523 .as_secs();
524 if now.saturating_sub(updated) > 7200 {
525 return None;
526 }
527 Some((tp, conv))
528}
529
530fn cap_content(s: &str) -> String {
531 if s.len() <= MAX_CONTENT_CHARS {
532 s.to_string()
533 } else {
534 let truncated = safe_truncate(s, MAX_CONTENT_CHARS);
535 format!("{}…\n\n[truncated: {} total chars]", truncated, s.len())
536 }
537}
538
539fn truncate_str(s: &str, max: usize) -> String {
540 if s.len() <= max {
541 s.to_string()
542 } else {
543 format!("{}...", safe_truncate(s, max))
544 }
545}
546
547fn safe_truncate(s: &str, max: usize) -> &str {
549 if max >= s.len() {
550 return s;
551 }
552 let mut end = max;
553 while end > 0 && !s.is_char_boundary(end) {
554 end -= 1;
555 }
556 &s[..end]
557}
558
559fn append_radar_event(event: &ObserveEvent) {
560 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
561 return;
562 };
563 let radar_path = data_dir.join("context_radar.jsonl");
564
565 if event.event_type == "session"
566 && let Ok(meta) = std::fs::metadata(&radar_path)
567 {
568 const MAX_RADAR_SIZE: u64 = 10 * 1024 * 1024; if meta.len() > MAX_RADAR_SIZE {
570 let prev = data_dir.join("context_radar.prev.jsonl");
571 let _ = std::fs::rename(&radar_path, &prev);
572 }
573 }
574
575 let Ok(line) = serde_json::to_string(event) else {
576 return;
577 };
578
579 use std::fs::OpenOptions;
580 use std::io::Write;
581 if let Ok(mut f) = OpenOptions::new()
582 .create(true)
583 .append(true)
584 .open(&radar_path)
585 {
586 let _ = writeln!(f, "{line}");
587 }
588
589 if event.event_type == "compaction" {
592 write_compaction_marker(&data_dir, event.ts);
593 }
594}
595
596fn write_compaction_marker(data_dir: &std::path::Path, ts: u64) {
599 use std::io::Write;
600 let marker_path = data_dir.join("last_compaction.json");
601 let tmp_path = data_dir.join("last_compaction.json.tmp");
602 let payload = format!(r#"{{"ts":{ts}}}"#);
603 let Ok(mut f) = std::fs::File::create(&tmp_path) else {
604 return;
605 };
606 if f.write_all(payload.as_bytes()).is_err() || f.sync_all().is_err() {
607 let _ = std::fs::remove_file(&tmp_path);
608 return;
609 }
610 drop(f);
611 let _ = std::fs::rename(&tmp_path, &marker_path);
612}
613
614#[must_use]
622pub fn radar_event_count() -> usize {
623 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
624 return 0;
625 };
626 let Ok(file) = std::fs::File::open(data_dir.join("context_radar.jsonl")) else {
627 return 0;
628 };
629 use std::io::{BufRead, BufReader};
630 BufReader::new(file)
631 .lines()
632 .map_while(Result::ok)
633 .filter(|l| !l.trim().is_empty())
634 .count()
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640
641 #[test]
642 fn detect_event_type_tool_response_is_mcp_call() {
643 let v = serde_json::json!({
644 "tool_name": "ctx_read",
645 "tool_response": "file contents here"
646 });
647 let event = detect_event_type(&v, 1000).unwrap();
648 assert_eq!(event.event_type, "mcp_call");
649 }
650
651 #[test]
652 fn detect_event_type_tool_output_is_mcp_call() {
653 let v = serde_json::json!({
654 "tool_name": "ctx_search",
655 "tool_output": "search results"
656 });
657 let event = detect_event_type(&v, 1000).unwrap();
658 assert_eq!(event.event_type, "mcp_call");
659 }
660
661 #[test]
662 fn detect_event_type_ctx_prefix_is_mcp_call() {
663 let v = serde_json::json!({
664 "tool_name": "ctx_read",
665 "tool_input": {"path": "src/main.rs"}
666 });
667 let event = detect_event_type(&v, 1000).unwrap();
668 assert_eq!(event.event_type, "mcp_call");
669 }
670
671 #[test]
672 fn detect_event_type_mcp_prefix_is_mcp_call() {
673 let v = serde_json::json!({
674 "tool_name": "mcp__lean-ctx__ctx_read",
675 "tool_input": {"path": "src/main.rs"}
676 });
677 let event = detect_event_type(&v, 1000).unwrap();
678 assert_eq!(event.event_type, "mcp_call");
679 }
680
681 #[test]
682 fn detect_event_type_native_read_is_native_tool() {
683 let v = serde_json::json!({
684 "tool_name": "Read",
685 "tool_input": {"path": "src/main.rs"}
686 });
687 let event = detect_event_type(&v, 1000).unwrap();
688 assert_eq!(event.event_type, "native_tool");
689 }
690
691 #[test]
692 fn detect_event_type_copilot_bash_posttooluse_is_shell() {
693 let v = serde_json::json!({
696 "toolName": "bash",
697 "toolArgs": "{\"command\":\"npm test\"}",
698 "toolResult": {
699 "resultType": "success",
700 "textResultForLlm": "All tests passed (15/15)"
701 }
702 });
703 let event = detect_event_type(&v, 1000).unwrap();
704 assert_eq!(event.event_type, "shell");
705 assert_eq!(event.tool_name.as_deref(), Some("bash"));
706 assert_eq!(event.detail.as_deref(), Some("npm test"));
707 assert!(event.content.unwrap().contains("All tests passed"));
708 }
709
710 #[test]
711 fn detect_event_type_copilot_ctx_tool_is_mcp_call() {
712 let v = serde_json::json!({
713 "toolName": "ctx_read",
714 "toolArgs": "{\"path\":\"src/main.rs\"}",
715 "toolResult": { "textResultForLlm": "file contents" }
716 });
717 let event = detect_event_type(&v, 1000).unwrap();
718 assert_eq!(event.event_type, "mcp_call");
719 assert_eq!(event.tool_name.as_deref(), Some("ctx_read"));
720 }
721
722 #[test]
723 fn detect_event_type_result_json_is_mcp_call() {
724 let v = serde_json::json!({
725 "tool_name": "ctx_read",
726 "result_json": {"content": "..."}
727 });
728 let event = detect_event_type(&v, 1000).unwrap();
729 assert_eq!(event.event_type, "mcp_call");
730 }
731
732 #[test]
736 fn detect_event_type_claude_precompact_is_compaction() {
737 let v = serde_json::json!({
738 "session_id": "abc123",
739 "transcript_path": "/Users/u/.claude/projects/x/abc123.jsonl",
740 "cwd": "/Users/u/project",
741 "hook_event_name": "PreCompact",
742 "trigger": "auto",
743 "custom_instructions": ""
744 });
745 let event = detect_event_type(&v, 1000).unwrap();
746 assert_eq!(event.event_type, "compaction");
747 }
748
749 #[test]
750 fn detect_event_type_plain_session_event_still_session() {
751 let v = serde_json::json!({
752 "session_id": "abc123",
753 "hook_event_name": "SessionStart"
754 });
755 let event = detect_event_type(&v, 1000).unwrap();
756 assert_eq!(event.event_type, "session");
757 }
758
759 #[test]
760 fn session_start_honoured_for_claude_payload() {
761 let v = serde_json::json!({
763 "hook_event_name": "SessionStart",
764 "session_id": "abc123",
765 "source": "startup"
766 });
767 assert!(session_start_honours_additional_context(&v));
768 }
769
770 #[test]
771 fn session_start_honoured_for_cursor_payload() {
772 let v = serde_json::json!({
774 "hook_event_name": "SessionStart",
775 "conversation_id": "0e1f4ed8-d858-4557-9fc5-6cbf5298eb8b",
776 "model": "claude-opus"
777 });
778 assert!(session_start_honours_additional_context(&v));
779 }
780
781 #[test]
782 fn session_start_skipped_for_non_session_event() {
783 let v = serde_json::json!({ "hook_event_name": "PreToolUse", "session_id": "x" });
784 assert!(!session_start_honours_additional_context(&v));
785 }
786
787 #[test]
791 fn session_id_is_used_as_scope_id_when_no_conversation_id() {
792 let _dir = crate::core::data_dir::isolated_data_dir();
793 persist_transcript_path("/tmp/x/abc.jsonl", None, Some("sess-abc"));
794 let (_, conv) = load_active_transcript().expect("marker just written");
795 assert_eq!(conv.as_deref(), Some("sess-abc"));
796 }
797
798 #[test]
800 fn conversation_id_wins_over_session_id() {
801 let _dir = crate::core::data_dir::isolated_data_dir();
802 persist_transcript_path("/tmp/x/abc.jsonl", Some("conv-1"), Some("sess-abc"));
803 let (_, conv) = load_active_transcript().expect("marker just written");
804 assert_eq!(conv.as_deref(), Some("conv-1"));
805 }
806}