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
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
427fn 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; 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}