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