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 let Some(event) = parse_observe_event(&input) else {
29 return;
30 };
31 append_radar_event(&event);
32
33 if event.event_type == "agent_response"
37 && let Some(text) = event.content.as_deref()
38 {
39 crate::core::output_echo::analyze_and_record(text);
40 }
41}
42
43fn emit_dedicated_session_context(input: &str) {
44 let Ok(v) = serde_json::from_str::<serde_json::Value>(input) else {
45 return;
46 };
47 if v.get("hook_event_name").and_then(|e| e.as_str()) != Some("SessionStart") {
48 return;
49 }
50 if !crate::core::config::Config::load().dedicated_session_context_active() {}
51 }
54
55#[derive(serde::Serialize)]
56struct ObserveEvent {
57 ts: u64,
58 event_type: &'static str,
59 tokens: usize,
60 #[serde(skip_serializing_if = "Option::is_none")]
61 tool_name: Option<String>,
62 #[serde(skip_serializing_if = "Option::is_none")]
63 detail: Option<String>,
64 #[serde(skip_serializing_if = "Option::is_none")]
65 content: Option<String>,
66 #[serde(skip_serializing_if = "Option::is_none")]
67 model: Option<String>,
68 #[serde(skip_serializing_if = "Option::is_none")]
69 conversation_id: Option<String>,
70}
71
72const MAX_CONTENT_CHARS: usize = 50_000;
73
74fn parse_observe_event(input: &str) -> Option<ObserveEvent> {
75 let v: serde_json::Value = serde_json::from_str(input).ok()?;
76
77 let ts = std::time::SystemTime::now()
78 .duration_since(std::time::UNIX_EPOCH)
79 .unwrap_or_default()
80 .as_secs();
81
82 let model = v
83 .get("model")
84 .and_then(|m| m.as_str())
85 .filter(|m| !m.is_empty())
86 .map(String::from);
87 let conversation_id = v
88 .get("conversation_id")
89 .and_then(|c| c.as_str())
90 .filter(|c| !c.is_empty())
91 .map(String::from);
92
93 let transcript_path = v
94 .get("transcript_path")
95 .and_then(|t| t.as_str())
96 .filter(|t| !t.is_empty())
97 .map(String::from);
98
99 if let Some(ref m) = model {
100 persist_detected_model(m);
101 }
102 if let Some(ref tp) = transcript_path {
103 persist_transcript_path(tp, conversation_id.as_deref());
104 }
105
106 let mut event = detect_event_type(&v, ts)?;
107 event.model = model;
108 event.conversation_id = conversation_id;
109 Some(event)
110}
111
112fn detect_event_type(v: &serde_json::Value, ts: u64) -> Option<ObserveEvent> {
113 if let Some(result) = v
114 .get("result_json")
115 .or_else(|| v.get("result"))
116 .or_else(|| v.get("tool_response"))
117 .or_else(|| v.get("tool_output"))
118 {
119 let tool = v
120 .get("tool_name")
121 .and_then(|t| t.as_str())
122 .unwrap_or("unknown");
123 let tokens = estimate_tokens_json(result);
124 let content_str = match result {
125 serde_json::Value::String(s) => s.clone(),
126 other => other.to_string(),
127 };
128 return Some(ObserveEvent {
129 ts,
130 event_type: "mcp_call",
131 tokens,
132 tool_name: Some(tool.to_string()),
133 detail: v
134 .get("server_name")
135 .and_then(|s| s.as_str())
136 .map(String::from),
137 content: Some(cap_content(&content_str)),
138 model: None,
139 conversation_id: None,
140 });
141 }
142
143 if let Some(output) = v.get("output") {
144 let cmd = v
145 .get("command")
146 .and_then(|c| c.as_str())
147 .unwrap_or("")
148 .to_string();
149 let tokens = estimate_tokens_value(output);
150 let out_str = match output {
151 serde_json::Value::String(s) => s.clone(),
152 other => other.to_string(),
153 };
154 return Some(ObserveEvent {
155 ts,
156 event_type: "shell",
157 tokens,
158 tool_name: None,
159 detail: Some(truncate_str(&cmd, 80)),
160 content: Some(cap_content(&format!("$ {cmd}\n{out_str}"))),
161 model: None,
162 conversation_id: None,
163 });
164 }
165
166 if v.get("content").is_some() && v.get("file_path").is_some() {
167 let path = v
168 .get("file_path")
169 .and_then(|p| p.as_str())
170 .unwrap_or("")
171 .to_string();
172 let file_content = v.get("content").and_then(|c| c.as_str()).unwrap_or("");
173 let tokens = file_content.len() / 4;
174 return Some(ObserveEvent {
175 ts,
176 event_type: "file_read",
177 tokens,
178 tool_name: None,
179 detail: Some(truncate_str(&path, 120)),
180 content: Some(cap_content(file_content)),
181 model: None,
182 conversation_id: None,
183 });
184 }
185
186 if let Some(text) = v.get("text").and_then(|t| t.as_str()) {
187 let has_duration = v.get("duration_ms").is_some();
188 let event_type = if has_duration {
189 "thinking"
190 } else {
191 "agent_response"
192 };
193 let tokens = text.len() / 4;
194 return Some(ObserveEvent {
195 ts,
196 event_type,
197 tokens,
198 tool_name: None,
199 detail: None,
200 content: Some(cap_content(text)),
201 model: None,
202 conversation_id: None,
203 });
204 }
205
206 if let Some(prompt) = v.get("prompt").and_then(|p| p.as_str()) {
207 let tokens = prompt.len() / 4;
208 let mut full = prompt.to_string();
209 if let Some(attachments) = v.get("attachments").and_then(|a| a.as_array())
210 && !attachments.is_empty()
211 {
212 full.push_str(&format!("\n\n[{} attachments]", attachments.len()));
213 for att in attachments {
214 if let Some(name) = att.get("name").and_then(|n| n.as_str()) {
215 full.push_str(&format!("\n - {name}"));
216 }
217 }
218 }
219 return Some(ObserveEvent {
220 ts,
221 event_type: "user_message",
222 tokens,
223 tool_name: None,
224 detail: v
225 .get("attachments")
226 .and_then(|a| a.as_array())
227 .map(|a| format!("{} attachments", a.len())),
228 content: Some(cap_content(&full)),
229 model: None,
230 conversation_id: None,
231 });
232 }
233
234 if v.get("tool_name").is_some() || v.get("tool_input").is_some() {
235 let tool = v
236 .get("tool_name")
237 .and_then(|t| t.as_str())
238 .unwrap_or("unknown")
239 .to_string();
240 let is_lctx = tool.starts_with("ctx_") || tool.starts_with("mcp__lean-ctx__");
241 let tokens = v.get("tool_input").map_or(0, estimate_tokens_json);
242 let input_str = v
243 .get("tool_input")
244 .map(std::string::ToString::to_string)
245 .unwrap_or_default();
246 return Some(ObserveEvent {
247 ts,
248 event_type: if is_lctx { "mcp_call" } else { "native_tool" },
249 tokens,
250 tool_name: Some(tool),
251 detail: None,
252 content: if input_str.is_empty() {
253 None
254 } else {
255 Some(cap_content(&input_str))
256 },
257 model: None,
258 conversation_id: None,
259 });
260 }
261
262 let is_compaction = v.get("compaction").is_some()
272 || v.get("messages_count").is_some()
273 || v.get("hook_event_name")
274 .and_then(|e| e.as_str())
275 .is_some_and(|e| e == "PreCompact")
276 || v.get("event")
277 .and_then(|e| e.as_str())
278 .is_some_and(|e| e == "compaction" || e == "compact");
279 if !is_compaction && v.get("session_id").is_some() {
280 return Some(ObserveEvent {
281 ts,
282 event_type: "session",
283 tokens: 0,
284 tool_name: None,
285 detail: v
286 .get("session_id")
287 .and_then(|s| s.as_str())
288 .map(String::from),
289 content: None,
290 model: None,
291 conversation_id: None,
292 });
293 }
294
295 if is_compaction {
296 return Some(ObserveEvent {
297 ts,
298 event_type: "compaction",
299 tokens: 0,
300 tool_name: None,
301 detail: None,
302 content: None,
303 model: None,
304 conversation_id: None,
305 });
306 }
307
308 None
309}
310
311fn estimate_tokens_json(v: &serde_json::Value) -> usize {
312 match v {
313 serde_json::Value::String(s) => s.len() / 4,
314 _ => v.to_string().len() / 4,
315 }
316}
317
318fn estimate_tokens_value(v: &serde_json::Value) -> usize {
319 match v {
320 serde_json::Value::String(s) => s.len() / 4,
321 _ => v.to_string().len() / 4,
322 }
323}
324
325fn persist_detected_model(model: &str) {
326 let m = model.to_lowercase();
327 let is_bg_model = m.contains("flash")
328 || m.contains("mini")
329 || m.contains("haiku")
330 || m.contains("fast")
331 || m.contains("nano")
332 || m.contains("small");
333 if is_bg_model {
334 return;
335 }
336
337 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
338 return;
339 };
340 let path = data_dir.join("detected_model.json");
341 let ts = std::time::SystemTime::now()
342 .duration_since(std::time::UNIX_EPOCH)
343 .unwrap_or_default()
344 .as_secs();
345 let window = model_context_window(model);
346 let payload = serde_json::json!({
347 "model": model,
348 "window_size": window,
349 "detected_at": ts,
350 });
351 if let Ok(json) = serde_json::to_string_pretty(&payload) {
352 let tmp = path.with_extension("tmp");
353 if std::fs::write(&tmp, &json).is_ok() {
354 let _ = std::fs::rename(&tmp, &path);
355 }
356 }
357}
358
359pub fn model_context_window(model: &str) -> usize {
360 crate::core::model_registry::context_window_for_model(model)
361}
362
363pub fn load_detected_model() -> Option<(String, usize)> {
364 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
365 let path = data_dir.join("detected_model.json");
366 let content = std::fs::read_to_string(&path).ok()?;
367 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
368 let model = v.get("model")?.as_str()?.to_string();
369 let window = v.get("window_size")?.as_u64()? as usize;
370 let detected_at = v.get("detected_at")?.as_u64()?;
371 let now = std::time::SystemTime::now()
372 .duration_since(std::time::UNIX_EPOCH)
373 .unwrap_or_default()
374 .as_secs();
375 if now.saturating_sub(detected_at) > 7200 {
376 return None;
377 }
378 Some((model, window))
379}
380
381fn persist_transcript_path(path: &str, conversation_id: Option<&str>) {
382 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
383 return;
384 };
385 let meta_path = data_dir.join("active_transcript.json");
386 let ts = std::time::SystemTime::now()
387 .duration_since(std::time::UNIX_EPOCH)
388 .unwrap_or_default()
389 .as_secs();
390 let payload = serde_json::json!({
391 "transcript_path": path,
392 "conversation_id": conversation_id,
393 "updated_at": ts,
394 });
395 if let Ok(json) = serde_json::to_string_pretty(&payload) {
396 let tmp = meta_path.with_extension("tmp");
397 if std::fs::write(&tmp, &json).is_ok() {
398 let _ = std::fs::rename(&tmp, &meta_path);
399 }
400 }
401}
402
403pub fn load_active_transcript() -> Option<(String, Option<String>)> {
404 let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
405 let path = data_dir.join("active_transcript.json");
406 let content = std::fs::read_to_string(&path).ok()?;
407 let v: serde_json::Value = serde_json::from_str(&content).ok()?;
408 let tp = v.get("transcript_path")?.as_str()?.to_string();
409 let conv = v
410 .get("conversation_id")
411 .and_then(|c| c.as_str())
412 .map(String::from);
413 let updated = v.get("updated_at")?.as_u64()?;
414 let now = std::time::SystemTime::now()
415 .duration_since(std::time::UNIX_EPOCH)
416 .unwrap_or_default()
417 .as_secs();
418 if now.saturating_sub(updated) > 7200 {
419 return None;
420 }
421 Some((tp, conv))
422}
423
424fn cap_content(s: &str) -> String {
425 if s.len() <= MAX_CONTENT_CHARS {
426 s.to_string()
427 } else {
428 let truncated = safe_truncate(s, MAX_CONTENT_CHARS);
429 format!("{}…\n\n[truncated: {} total chars]", truncated, s.len())
430 }
431}
432
433fn truncate_str(s: &str, max: usize) -> String {
434 if s.len() <= max {
435 s.to_string()
436 } else {
437 format!("{}...", safe_truncate(s, max))
438 }
439}
440
441fn safe_truncate(s: &str, max: usize) -> &str {
443 if max >= s.len() {
444 return s;
445 }
446 let mut end = max;
447 while end > 0 && !s.is_char_boundary(end) {
448 end -= 1;
449 }
450 &s[..end]
451}
452
453fn append_radar_event(event: &ObserveEvent) {
454 let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
455 return;
456 };
457 let radar_path = data_dir.join("context_radar.jsonl");
458
459 if event.event_type == "session"
460 && let Ok(meta) = std::fs::metadata(&radar_path)
461 {
462 const MAX_RADAR_SIZE: u64 = 10 * 1024 * 1024; if meta.len() > MAX_RADAR_SIZE {
464 let prev = data_dir.join("context_radar.prev.jsonl");
465 let _ = std::fs::rename(&radar_path, &prev);
466 }
467 }
468
469 let Ok(line) = serde_json::to_string(event) else {
470 return;
471 };
472
473 use std::fs::OpenOptions;
474 use std::io::Write;
475 if let Ok(mut f) = OpenOptions::new()
476 .create(true)
477 .append(true)
478 .open(&radar_path)
479 {
480 let _ = writeln!(f, "{line}");
481 }
482}
483
484#[cfg(test)]
485mod tests {
486 use super::*;
487
488 #[test]
489 fn detect_event_type_tool_response_is_mcp_call() {
490 let v = serde_json::json!({
491 "tool_name": "ctx_read",
492 "tool_response": "file contents here"
493 });
494 let event = detect_event_type(&v, 1000).unwrap();
495 assert_eq!(event.event_type, "mcp_call");
496 }
497
498 #[test]
499 fn detect_event_type_tool_output_is_mcp_call() {
500 let v = serde_json::json!({
501 "tool_name": "ctx_search",
502 "tool_output": "search results"
503 });
504 let event = detect_event_type(&v, 1000).unwrap();
505 assert_eq!(event.event_type, "mcp_call");
506 }
507
508 #[test]
509 fn detect_event_type_ctx_prefix_is_mcp_call() {
510 let v = serde_json::json!({
511 "tool_name": "ctx_read",
512 "tool_input": {"path": "src/main.rs"}
513 });
514 let event = detect_event_type(&v, 1000).unwrap();
515 assert_eq!(event.event_type, "mcp_call");
516 }
517
518 #[test]
519 fn detect_event_type_mcp_prefix_is_mcp_call() {
520 let v = serde_json::json!({
521 "tool_name": "mcp__lean-ctx__ctx_read",
522 "tool_input": {"path": "src/main.rs"}
523 });
524 let event = detect_event_type(&v, 1000).unwrap();
525 assert_eq!(event.event_type, "mcp_call");
526 }
527
528 #[test]
529 fn detect_event_type_native_read_is_native_tool() {
530 let v = serde_json::json!({
531 "tool_name": "Read",
532 "tool_input": {"path": "src/main.rs"}
533 });
534 let event = detect_event_type(&v, 1000).unwrap();
535 assert_eq!(event.event_type, "native_tool");
536 }
537
538 #[test]
539 fn detect_event_type_result_json_is_mcp_call() {
540 let v = serde_json::json!({
541 "tool_name": "ctx_read",
542 "result_json": {"content": "..."}
543 });
544 let event = detect_event_type(&v, 1000).unwrap();
545 assert_eq!(event.event_type, "mcp_call");
546 }
547
548 #[test]
552 fn detect_event_type_claude_precompact_is_compaction() {
553 let v = serde_json::json!({
554 "session_id": "abc123",
555 "transcript_path": "/Users/u/.claude/projects/x/abc123.jsonl",
556 "cwd": "/Users/u/project",
557 "hook_event_name": "PreCompact",
558 "trigger": "auto",
559 "custom_instructions": ""
560 });
561 let event = detect_event_type(&v, 1000).unwrap();
562 assert_eq!(event.event_type, "compaction");
563 }
564
565 #[test]
566 fn detect_event_type_plain_session_event_still_session() {
567 let v = serde_json::json!({
568 "session_id": "abc123",
569 "hook_event_name": "SessionStart"
570 });
571 let event = detect_event_type(&v, 1000).unwrap();
572 assert_eq!(event.event_type, "session");
573 }
574}