Skip to main content

lean_ctx/core/
host_state.rs

1// SPDX-License-Identifier: Apache-2.0
2//! Host session state the observe hook records for the rest of the engine.
3//!
4//! The observe hook (`hook_handlers::observe`) learns which model and which
5//! transcript the host is running from its payloads and persists them in the
6//! data dir; the context radar, ledger, telemetry and conversation reader load
7//! them back. Both directions live here so the file formats have one owner and
8//! engine readers do not depend on the hook layer.
9
10/// Persist the host's current model (skips background models) to
11/// `detected_model.json`.
12pub fn persist_detected_model(model: &str) {
13    if crate::core::model_registry::is_background_model(model) {
14        return;
15    }
16
17    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
18        return;
19    };
20    let path = data_dir.join("detected_model.json");
21    let ts = std::time::SystemTime::now()
22        .duration_since(std::time::UNIX_EPOCH)
23        .unwrap_or_default()
24        .as_secs();
25    let window = crate::core::model_registry::context_window_for_model(model);
26    let payload = serde_json::json!({
27        "model": model,
28        "window_size": window,
29        "detected_at": ts,
30    });
31    if let Ok(json) = serde_json::to_string_pretty(&payload) {
32        let tmp = path.with_extension("tmp");
33        if std::fs::write(&tmp, &json).is_ok() {
34            let _ = std::fs::rename(&tmp, &path);
35        }
36    }
37}
38
39/// The model the host last reported, with its context window. `None` when
40/// nothing was recorded or the record is older than two hours.
41pub fn load_detected_model() -> Option<(String, usize)> {
42    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
43    let path = data_dir.join("detected_model.json");
44    let content = std::fs::read_to_string(&path).ok()?;
45    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
46    let model = v.get("model")?.as_str()?.to_string();
47    let window = v.get("window_size")?.as_u64()? as usize;
48    let detected_at = v.get("detected_at")?.as_u64()?;
49    let now = std::time::SystemTime::now()
50        .duration_since(std::time::UNIX_EPOCH)
51        .unwrap_or_default()
52        .as_secs();
53    if now.saturating_sub(detected_at) > 7200 {
54        return None;
55    }
56    Some((model, window))
57}
58
59/// Persist the host's active transcript path and session identity to
60/// `active_transcript.json`.
61pub fn persist_transcript_path(
62    path: &str,
63    conversation_id: Option<&str>,
64    session_id: Option<&str>,
65) {
66    let Ok(data_dir) = crate::core::data_dir::lean_ctx_data_dir() else {
67        return;
68    };
69    let meta_path = data_dir.join("active_transcript.json");
70    let ts = std::time::SystemTime::now()
71        .duration_since(std::time::UNIX_EPOCH)
72        .unwrap_or_default()
73        .as_secs();
74    let payload = serde_json::json!({
75        "transcript_path": path,
76        "conversation_id": conversation_id,
77        "session_id": session_id,
78        "updated_at": ts,
79    });
80    if let Ok(json) = serde_json::to_string_pretty(&payload) {
81        let tmp = meta_path.with_extension("tmp");
82        if std::fs::write(&tmp, &json).is_ok() {
83            let _ = std::fs::rename(&tmp, &meta_path);
84        }
85    }
86}
87
88pub fn load_active_transcript() -> Option<(String, Option<String>)> {
89    let data_dir = crate::core::data_dir::lean_ctx_data_dir().ok()?;
90    let path = data_dir.join("active_transcript.json");
91    let content = std::fs::read_to_string(&path).ok()?;
92    let v: serde_json::Value = serde_json::from_str(&content).ok()?;
93    let tp = v.get("transcript_path")?.as_str()?.to_string();
94    // Prefer Cursor's `conversation_id`; fall back to the host `session_id`
95    // (Claude Code / Codex / CodeBuddy) so the read-cache still has a per-session
96    // identity to scope stubs against (#1004).
97    let conv = v
98        .get("conversation_id")
99        .and_then(|c| c.as_str())
100        .or_else(|| v.get("session_id").and_then(|s| s.as_str()))
101        .map(String::from);
102    let updated = v.get("updated_at")?.as_u64()?;
103    let now = std::time::SystemTime::now()
104        .duration_since(std::time::UNIX_EPOCH)
105        .unwrap_or_default()
106        .as_secs();
107    if now.saturating_sub(updated) > 7200 {
108        return None;
109    }
110    Some((tp, conv))
111}