use std::sync::Mutex;
use std::time::{Duration, Instant};
use serde::{Deserialize, Serialize};
const HEAVY_READ_TOKEN_FLOOR: usize = 1500;
const CHAIN_WINDOW: Duration = Duration::from_mins(2);
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
pub(crate) struct BehaviorStore {
pub heavy_reads_detected: u64,
pub chains_detected: u64,
pub hints_shown: u64,
pub updated_at: Option<String>,
}
impl BehaviorStore {
pub(crate) fn load() -> Self {
let Some(path) = store_path() else {
return Self::default();
};
std::fs::read_to_string(path)
.ok()
.and_then(|raw| serde_json::from_str(&raw).ok())
.unwrap_or_default()
}
fn save(&self) {
let Some(path) = store_path() else {
return;
};
if let Some(parent) = path.parent()
&& std::fs::create_dir_all(parent).is_err()
{
return;
}
if let Ok(json) = serde_json::to_string(self) {
let tmp = path.with_extension("tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
}
}
fn store_path() -> Option<std::path::PathBuf> {
crate::core::paths::state_dir()
.ok()
.map(|d| d.join("behavior_nudge.json"))
}
#[derive(Debug, Default)]
struct SessionState {
recent: Vec<(&'static str, Instant)>,
heavy_hint_shown: bool,
chain_hint_shown: bool,
}
static SESSION: Mutex<Option<SessionState>> = Mutex::new(None);
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
enum Detection {
HeavyRead,
Chain,
}
fn chain_step(name: &str) -> Option<&'static str> {
match name {
"ctx_search" => Some("search"),
"ctx_read" => Some("read"),
"ctx_callgraph" => Some("graph"),
_ => None,
}
}
fn detect(
state: &mut SessionState,
name: &str,
explicit_mode: Option<&str>,
output_tokens: usize,
now: Instant,
) -> Option<Detection> {
if name == "ctx_read"
&& matches!(explicit_mode, Some("full" | "raw"))
&& output_tokens >= HEAVY_READ_TOKEN_FLOOR
{
return Some(Detection::HeavyRead);
}
if name == "ctx_compose" {
state.recent.clear();
return None;
}
let step = chain_step(name)?;
state
.recent
.retain(|(_, at)| now.saturating_duration_since(*at) <= CHAIN_WINDOW);
state.recent.push((step, now));
if state.recent.len() > 3 {
let excess = state.recent.len() - 3;
state.recent.drain(0..excess);
}
let steps: Vec<&str> = state.recent.iter().map(|(s, _)| *s).collect();
if steps == ["search", "read", "search"] || steps == ["search", "read", "graph"] {
state.recent.clear();
return Some(Detection::Chain);
}
None
}
fn hint_for(detection: Detection) -> &'static str {
match detection {
Detection::HeavyRead => {
"[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)"
}
Detection::Chain => {
"[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)"
}
}
}
pub fn observe(
name: &str,
args: Option<&serde_json::Map<String, serde_json::Value>>,
output_tokens: usize,
can_show: bool,
) -> Option<String> {
let nudges_on = can_show && crate::core::config::Config::load().behavior_nudges != "off";
let explicit_mode = args
.and_then(|a| a.get("mode"))
.and_then(|m| m.as_str())
.map(str::to_string);
let mut guard = SESSION
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let state = guard.get_or_insert_with(SessionState::default);
let detection = detect(
state,
name,
explicit_mode.as_deref(),
output_tokens,
Instant::now(),
)?;
let mut store = BehaviorStore::load();
match detection {
Detection::HeavyRead => store.heavy_reads_detected += 1,
Detection::Chain => store.chains_detected += 1,
}
let show = nudges_on
&& match detection {
Detection::HeavyRead => !std::mem::replace(&mut state.heavy_hint_shown, true),
Detection::Chain => !std::mem::replace(&mut state.chain_hint_shown, true),
};
if show {
store.hints_shown += 1;
}
store.updated_at = Some(chrono::Utc::now().to_rfc3339());
store.save();
show.then(|| hint_for(detection).to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn state() -> SessionState {
SessionState::default()
}
#[test]
fn heavy_read_needs_explicit_heavy_mode_and_size() {
let now = Instant::now();
let mut s = state();
assert_eq!(
detect(&mut s, "ctx_read", Some("full"), 2000, now),
Some(Detection::HeavyRead)
);
assert_eq!(detect(&mut s, "ctx_read", Some("full"), 200, now), None);
assert_eq!(
detect(&mut s, "ctx_read", Some("signatures"), 5000, now),
None
);
assert_eq!(
detect(&mut s, "ctx_read", None, 5000, now),
None,
"auto-resolved reads are the resolver's business, not the agent's"
);
assert_eq!(
detect(&mut s, "ctx_read", Some("raw"), 1500, now),
Some(Detection::HeavyRead)
);
}
#[test]
fn chain_detected_for_search_read_search_within_window() {
let now = Instant::now();
let mut s = state();
assert_eq!(detect(&mut s, "ctx_search", None, 10, now), None);
assert_eq!(detect(&mut s, "ctx_read", Some("map"), 10, now), None);
assert_eq!(
detect(&mut s, "ctx_search", None, 10, now),
Some(Detection::Chain)
);
assert_eq!(detect(&mut s, "ctx_search", None, 10, now), None);
}
#[test]
fn chain_accepts_callgraph_as_final_step() {
let now = Instant::now();
let mut s = state();
detect(&mut s, "ctx_search", None, 10, now);
detect(&mut s, "ctx_read", Some("map"), 10, now);
assert_eq!(
detect(&mut s, "ctx_callgraph", None, 10, now),
Some(Detection::Chain)
);
}
#[test]
fn compose_resets_the_chain_window() {
let now = Instant::now();
let mut s = state();
detect(&mut s, "ctx_search", None, 10, now);
detect(&mut s, "ctx_read", Some("map"), 10, now);
detect(&mut s, "ctx_compose", None, 10, now);
assert_eq!(
detect(&mut s, "ctx_search", None, 10, now),
None,
"compose is the desired end state — it must not complete a chain"
);
}
#[test]
fn stale_steps_age_out_of_the_window() {
let start = Instant::now();
let later = start + CHAIN_WINDOW + Duration::from_secs(1);
let mut s = state();
detect(&mut s, "ctx_search", None, 10, start);
detect(&mut s, "ctx_read", Some("map"), 10, start);
assert_eq!(
detect(&mut s, "ctx_search", None, 10, later),
None,
"steps older than the window are unrelated work"
);
}
#[test]
fn unrelated_tools_do_not_extend_chains() {
let now = Instant::now();
let mut s = state();
detect(&mut s, "ctx_search", None, 10, now);
detect(&mut s, "ctx_read", Some("map"), 10, now);
assert_eq!(detect(&mut s, "ctx_shell", None, 10, now), None);
assert_eq!(
detect(&mut s, "ctx_search", None, 10, now),
Some(Detection::Chain)
);
}
#[test]
fn hints_stay_short() {
for d in [Detection::HeavyRead, Detection::Chain] {
assert!(
hint_for(d).len() < 220,
"a nudge must cost far less than what it saves"
);
}
}
}