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