1use std::sync::Mutex;
22use std::time::{Duration, Instant};
23
24use serde::{Deserialize, Serialize};
25
26const HEAVY_READ_TOKEN_FLOOR: usize = 1500;
29const CHAIN_WINDOW: Duration = Duration::from_mins(2);
31
32#[derive(Debug, Default, Clone, Serialize, Deserialize)]
35pub(crate) struct BehaviorStore {
36 pub heavy_reads_detected: u64,
38 pub chains_detected: u64,
40 pub hints_shown: u64,
42 pub updated_at: Option<String>,
43}
44
45impl BehaviorStore {
46 pub(crate) fn load() -> Self {
47 let Some(path) = store_path() else {
48 return Self::default();
49 };
50 std::fs::read_to_string(path)
51 .ok()
52 .and_then(|raw| serde_json::from_str(&raw).ok())
53 .unwrap_or_default()
54 }
55
56 fn save(&self) {
57 let Some(path) = store_path() else {
58 return;
59 };
60 if let Some(parent) = path.parent()
61 && std::fs::create_dir_all(parent).is_err()
62 {
63 return;
64 }
65 if let Ok(json) = serde_json::to_string(self) {
66 let tmp = path.with_extension("tmp");
67 if std::fs::write(&tmp, json).is_ok() {
68 let _ = std::fs::rename(&tmp, &path);
69 }
70 }
71 }
72}
73
74fn store_path() -> Option<std::path::PathBuf> {
75 crate::core::paths::state_dir()
76 .ok()
77 .map(|d| d.join("behavior_nudge.json"))
78}
79
80#[derive(Debug, Default)]
83struct SessionState {
84 recent: Vec<(&'static str, Instant)>,
85 heavy_hint_shown: bool,
86 chain_hint_shown: bool,
87}
88
89static SESSION: Mutex<Option<SessionState>> = Mutex::new(None);
90
91#[derive(Debug, PartialEq, Eq, Clone, Copy)]
93enum Detection {
94 HeavyRead,
95 Chain,
96}
97
98fn chain_step(name: &str) -> Option<&'static str> {
102 match name {
103 "ctx_search" => Some("search"),
104 "ctx_read" => Some("read"),
105 "ctx_callgraph" => Some("graph"),
106 _ => None,
107 }
108}
109
110fn detect(
112 state: &mut SessionState,
113 name: &str,
114 explicit_mode: Option<&str>,
115 output_tokens: usize,
116 now: Instant,
117) -> Option<Detection> {
118 if name == "ctx_read"
120 && matches!(explicit_mode, Some("full" | "raw"))
121 && output_tokens >= HEAVY_READ_TOKEN_FLOOR
122 {
123 return Some(Detection::HeavyRead);
124 }
125
126 if name == "ctx_compose" {
127 state.recent.clear();
128 return None;
129 }
130 let step = chain_step(name)?;
131 state
132 .recent
133 .retain(|(_, at)| now.saturating_duration_since(*at) <= CHAIN_WINDOW);
134 state.recent.push((step, now));
135 if state.recent.len() > 3 {
136 let excess = state.recent.len() - 3;
137 state.recent.drain(0..excess);
138 }
139 let steps: Vec<&str> = state.recent.iter().map(|(s, _)| *s).collect();
140 if steps == ["search", "read", "search"] || steps == ["search", "read", "graph"] {
141 state.recent.clear();
142 return Some(Detection::Chain);
143 }
144 None
145}
146
147fn hint_for(detection: Detection) -> &'static str {
150 match detection {
151 Detection::HeavyRead => {
152 "[lean-ctx nudge] large explicit full/raw read — mode=signatures/map covers orientation at a fraction of the tokens; reserve full/raw for edit prep. (once per session; behavior_nudges=off to silence)"
153 }
154 Detection::Chain => {
155 "[lean-ctx nudge] search→read→search chain — one ctx_compose call bundles search+read+symbols and saves the extra turns. (once per session; behavior_nudges=off to silence)"
156 }
157 }
158}
159
160pub fn observe(
168 name: &str,
169 args: Option<&serde_json::Map<String, serde_json::Value>>,
170 output_tokens: usize,
171 can_show: bool,
172) -> Option<String> {
173 let nudges_on = can_show && crate::core::config::Config::load().behavior_nudges != "off";
174 let explicit_mode = args
175 .and_then(|a| a.get("mode"))
176 .and_then(|m| m.as_str())
177 .map(str::to_string);
178 let mut guard = SESSION
179 .lock()
180 .unwrap_or_else(std::sync::PoisonError::into_inner);
181 let state = guard.get_or_insert_with(SessionState::default);
182 let detection = detect(
183 state,
184 name,
185 explicit_mode.as_deref(),
186 output_tokens,
187 Instant::now(),
188 )?;
189
190 let mut store = BehaviorStore::load();
191 match detection {
192 Detection::HeavyRead => store.heavy_reads_detected += 1,
193 Detection::Chain => store.chains_detected += 1,
194 }
195 let show = nudges_on
196 && match detection {
197 Detection::HeavyRead => !std::mem::replace(&mut state.heavy_hint_shown, true),
198 Detection::Chain => !std::mem::replace(&mut state.chain_hint_shown, true),
199 };
200 if show {
201 store.hints_shown += 1;
202 }
203 store.updated_at = Some(chrono::Utc::now().to_rfc3339());
204 store.save();
205 show.then(|| hint_for(detection).to_string())
206}
207
208#[cfg(test)]
209mod tests {
210 use super::*;
211
212 fn state() -> SessionState {
213 SessionState::default()
214 }
215
216 #[test]
217 fn heavy_read_needs_explicit_heavy_mode_and_size() {
218 let now = Instant::now();
219 let mut s = state();
220 assert_eq!(
221 detect(&mut s, "ctx_read", Some("full"), 2000, now),
222 Some(Detection::HeavyRead)
223 );
224 assert_eq!(detect(&mut s, "ctx_read", Some("full"), 200, now), None);
225 assert_eq!(
226 detect(&mut s, "ctx_read", Some("signatures"), 5000, now),
227 None
228 );
229 assert_eq!(
230 detect(&mut s, "ctx_read", None, 5000, now),
231 None,
232 "auto-resolved reads are the resolver's business, not the agent's"
233 );
234 assert_eq!(
235 detect(&mut s, "ctx_read", Some("raw"), 1500, now),
236 Some(Detection::HeavyRead)
237 );
238 }
239
240 #[test]
241 fn chain_detected_for_search_read_search_within_window() {
242 let now = Instant::now();
243 let mut s = state();
244 assert_eq!(detect(&mut s, "ctx_search", None, 10, now), None);
245 assert_eq!(detect(&mut s, "ctx_read", Some("map"), 10, now), None);
246 assert_eq!(
247 detect(&mut s, "ctx_search", None, 10, now),
248 Some(Detection::Chain)
249 );
250 assert_eq!(detect(&mut s, "ctx_search", None, 10, now), None);
252 }
253
254 #[test]
255 fn chain_accepts_callgraph_as_final_step() {
256 let now = Instant::now();
257 let mut s = state();
258 detect(&mut s, "ctx_search", None, 10, now);
259 detect(&mut s, "ctx_read", Some("map"), 10, now);
260 assert_eq!(
261 detect(&mut s, "ctx_callgraph", None, 10, now),
262 Some(Detection::Chain)
263 );
264 }
265
266 #[test]
267 fn compose_resets_the_chain_window() {
268 let now = Instant::now();
269 let mut s = state();
270 detect(&mut s, "ctx_search", None, 10, now);
271 detect(&mut s, "ctx_read", Some("map"), 10, now);
272 detect(&mut s, "ctx_compose", None, 10, now);
273 assert_eq!(
274 detect(&mut s, "ctx_search", None, 10, now),
275 None,
276 "compose is the desired end state — it must not complete a chain"
277 );
278 }
279
280 #[test]
281 fn stale_steps_age_out_of_the_window() {
282 let start = Instant::now();
283 let later = start + CHAIN_WINDOW + Duration::from_secs(1);
284 let mut s = state();
285 detect(&mut s, "ctx_search", None, 10, start);
286 detect(&mut s, "ctx_read", Some("map"), 10, start);
287 assert_eq!(
288 detect(&mut s, "ctx_search", None, 10, later),
289 None,
290 "steps older than the window are unrelated work"
291 );
292 }
293
294 #[test]
295 fn unrelated_tools_do_not_extend_chains() {
296 let now = Instant::now();
297 let mut s = state();
298 detect(&mut s, "ctx_search", None, 10, now);
299 detect(&mut s, "ctx_read", Some("map"), 10, now);
300 assert_eq!(detect(&mut s, "ctx_shell", None, 10, now), None);
301 assert_eq!(
303 detect(&mut s, "ctx_search", None, 10, now),
304 Some(Detection::Chain)
305 );
306 }
307
308 #[test]
309 fn hints_stay_short() {
310 for d in [Detection::HeavyRead, Detection::Chain] {
311 assert!(
312 hint_for(d).len() < 220,
313 "a nudge must cost far less than what it saves"
314 );
315 }
316 }
317}