Skip to main content

lean_ctx/hook_handlers/
observe.rs

1//! Observe hook handler: records all IDE hook events for context awareness
2//! (event parsing, token estimation, model/transcript detection, radar log).
3//! Split out of `hook_handlers/mod.rs`; `use super::*` re-imports parent items.
4
5#[allow(clippy::wildcard_imports)]
6use super::*;
7
8// ---------------------------------------------------------------------------
9// Observe handler — records ALL hook events for context awareness
10// ---------------------------------------------------------------------------
11
12/// Unified observe handler for all IDE hook events.
13/// Reads JSON from stdin, normalizes to `ObserveEvent`, counts tokens,
14/// appends to `context_radar.jsonl`, and exits immediately.
15pub 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
402/// Truncate a string at a char boundary <= max bytes. Never panics on multi-byte UTF-8.
403fn 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; // 10 MB
423            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}