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/CodeBuddy `SessionStart` hook
23    // injects the compact lean-ctx summary as `additionalContext` — the
24    // non-polluting stand-in for the (skipped) CLAUDE.md/CODEBUDDY.md/AGENTS.md block. All
25    // three 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    // Output-echo analysis (#501): measure how much of the agent's reply
34    // re-quotes content lean-ctx already delivered, and feed the adaptive
35    // mode policy with an automatic feedback event.
36    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    // Session start additional context removed — the MCP instructions
52    // already carry the compact rules block.
53}
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    // Claude Code emits `hook_event_name: "PreCompact"` (code.claude.com/docs/
263    // en/hooks); the generic `event`/`compaction` shapes cover other hosts.
264    // This check must run BEFORE the `session_id` catch-all below: every
265    // Claude hook payload carries `session_id` as a common field, so the
266    // compaction branch was unreachable for Claude — compactions were never
267    // recorded, `sync_if_compacted` never reset delivery flags, and
268    // post-compaction re-reads kept answering with "[unchanged]" stubs that
269    // pointed at context the host had already evicted (GL #555). Agents then
270    // fell back to native Read to recover the content.
271    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
441/// Truncate a string at a char boundary <= max bytes. Never panics on multi-byte UTF-8.
442fn 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; // 10 MB
463        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    /// Real Claude Code PreCompact payload (code.claude.com/docs/en/hooks):
549    /// carries `session_id` like every Claude hook, so the compaction check
550    /// must win over the generic session catch-all (GL #555).
551    #[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}