use std::collections::HashMap;
use std::hash::{Hash, Hasher};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use serde_json::Value;
const SAFETY_MARGIN: u64 = 2;
const COLD_FLOOR_SECS: u64 = 600;
const DEFAULT_TTL_SECS: u64 = 300;
const HOUR_TTL_SECS: u64 = 3600;
const MAX_TRACKED: usize = 4096;
const PERSIST_MIN_INTERVAL_SECS: u64 = 30;
const TOUCH_FILE: &str = "cold_prefix_touch.json";
#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
struct ConvState {
last_touch: u64,
repacking: bool,
}
fn store() -> &'static Mutex<HashMap<u64, ConvState>> {
static STORE: OnceLock<Mutex<HashMap<u64, ConvState>>> = OnceLock::new();
STORE.get_or_init(|| Mutex::new(HashMap::new()))
}
fn last_persist() -> &'static AtomicU64 {
static LAST: AtomicU64 = AtomicU64::new(0);
&LAST
}
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_secs())
}
fn hash_bytes(bytes: &[u8]) -> u64 {
let mut h = std::collections::hash_map::DefaultHasher::new();
bytes.hash(&mut h);
h.finish()
}
fn conversation_key(messages: &[Value]) -> Option<u64> {
let mut first = messages.first()?.clone();
strip_cache_control(&mut first);
let bytes = serde_json::to_vec(&first).ok()?;
Some(hash_bytes(&bytes))
}
fn strip_cache_control(v: &mut Value) {
match v {
Value::Object(map) => {
map.remove("cache_control");
for val in map.values_mut() {
strip_cache_control(val);
}
}
Value::Array(arr) => {
for val in arr {
strip_cache_control(val);
}
}
_ => {}
}
}
fn parse_ttl_str(s: &str) -> Option<u64> {
match s.trim() {
"1h" => Some(HOUR_TTL_SECS),
"5m" => Some(DEFAULT_TTL_SECS),
_ => None,
}
}
fn max_ttl_in_message(msg: &Value) -> Option<u64> {
let mut best: Option<u64> = None;
collect_cc_ttl(msg, &mut best);
best
}
fn collect_cc_ttl(v: &Value, best: &mut Option<u64>) {
match v {
Value::Object(map) => {
if let Some(ttl) = map
.get("cache_control")
.and_then(|cc| cc.get("ttl"))
.and_then(Value::as_str)
.and_then(parse_ttl_str)
{
*best = Some(best.map_or(ttl, |b| b.max(ttl)));
}
for val in map.values() {
collect_cc_ttl(val, best);
}
}
Value::Array(arr) => {
for val in arr {
collect_cc_ttl(val, best);
}
}
_ => {}
}
}
fn resolved_ttl_secs(messages: &[Value], cached: usize) -> Option<u64> {
if cached == 0 {
return None;
}
let end = cached.min(messages.len());
let mut ttl = DEFAULT_TTL_SECS;
for msg in &messages[..end] {
if let Some(t) = max_ttl_in_message(msg) {
ttl = ttl.max(t);
}
}
Some(ttl)
}
fn evict_oldest(map: &mut HashMap<u64, ConvState>) {
if let Some(oldest_key) = map
.iter()
.min_by_key(|(_, s)| s.last_touch)
.map(|(k, _)| *k)
{
map.remove(&oldest_key);
}
}
pub fn repack_decision(messages: &[Value], cached: usize) -> bool {
let Some(key) = conversation_key(messages) else {
return false;
};
let now = now_secs();
let ttl = resolved_ttl_secs(messages, cached);
let (decision, changed) = {
let mut map = store()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let prev = map.get(&key).copied();
let was_first = prev.is_none();
let already_repacking = prev.is_some_and(|s| s.repacking);
let fresh_cold = match (prev, ttl) {
(Some(p), Some(t)) if cached > 0 => {
let idle = now.saturating_sub(p.last_touch);
idle > t.saturating_mul(SAFETY_MARGIN).max(COLD_FLOOR_SECS)
}
_ => false,
};
let repacking = already_repacking || fresh_cold;
map.insert(
key,
ConvState {
last_touch: now,
repacking,
},
);
if map.len() > MAX_TRACKED {
evict_oldest(&mut map);
}
let changed = was_first || (repacking && !already_repacking);
(repacking && cached > 0, changed)
};
maybe_persist(changed, now);
decision
}
#[derive(Debug, Default, Serialize, Deserialize)]
struct PersistedTouch {
ts: u64,
conversations: HashMap<u64, ConvState>,
}
fn touch_path() -> Option<std::path::PathBuf> {
crate::core::data_dir::lean_ctx_data_dir()
.ok()
.map(|d| d.join(TOUCH_FILE))
}
pub fn resume_from_disk() {
let Some(path) = touch_path() else {
return;
};
let Ok(data) = std::fs::read_to_string(&path) else {
return;
};
let Ok(persisted) = serde_json::from_str::<PersistedTouch>(&data) else {
return;
};
let mut map = store()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
for (key, state) in persisted.conversations {
let entry = map.entry(key).or_insert(state);
if state.last_touch > entry.last_touch {
entry.last_touch = state.last_touch;
}
entry.repacking |= state.repacking;
}
while map.len() > MAX_TRACKED {
evict_oldest(&mut map);
}
}
fn maybe_persist(force: bool, now: u64) {
let last = last_persist().load(Ordering::Relaxed);
if !force && now.saturating_sub(last) < PERSIST_MIN_INTERVAL_SECS {
return;
}
last_persist().store(now, Ordering::Relaxed);
persist_now(now);
}
fn persist_now(now: u64) {
let Some(path) = touch_path() else {
return;
};
let conversations = {
let map = store()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
map.clone()
};
let payload = PersistedTouch {
ts: now,
conversations,
};
let Ok(json) = serde_json::to_string(&payload) else {
return;
};
let tmp = path.with_extension("json.tmp");
if std::fs::write(&tmp, json).is_ok() {
let _ = std::fs::rename(&tmp, &path);
}
}
#[cfg(test)]
pub(crate) fn test_seed_last_touch(messages: &[Value], secs_ago: u64) {
if let Some(key) = conversation_key(messages) {
let when = now_secs().saturating_sub(secs_ago);
store()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.insert(
key,
ConvState {
last_touch: when,
repacking: false,
},
);
}
}
#[cfg(test)]
fn test_remove(messages: &[Value]) {
if let Some(key) = conversation_key(messages) {
store()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.remove(&key);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn cached_body(first_text: &str, ttl: Option<&str>) -> Vec<Value> {
let cc = ttl.map_or_else(
|| json!({"type": "ephemeral"}),
|t| json!({"type": "ephemeral", "ttl": t}),
);
vec![
json!({"role": "user", "content": [
{"type": "text", "text": first_text, "cache_control": cc}
]}),
json!({"role": "assistant", "content": "ok"}),
]
}
#[test]
fn key_is_stable_across_turns_and_distinct_per_conversation() {
let mut a1 = cached_body("conversation A opening", None);
let a2 = {
let mut m = a1.clone();
m.push(json!({"role": "user", "content": "a follow-up turn"}));
m
};
let b1 = cached_body("conversation B opening", None);
let ka = conversation_key(&a1).unwrap();
let ka2 = conversation_key(&a2).unwrap();
let kb = conversation_key(&b1).unwrap();
assert_eq!(ka, ka2, "key must be stable as the conversation grows");
assert_ne!(ka, kb, "distinct conversations must get distinct keys");
a1[0] = json!({"role": "user", "content": "different head"});
assert_ne!(conversation_key(&a1).unwrap(), ka);
}
#[test]
fn ttl_resolves_from_marker_else_default_else_none() {
let hour = cached_body("x", Some("1h"));
assert_eq!(resolved_ttl_secs(&hour, 1), Some(HOUR_TTL_SECS));
let five = cached_body("x", Some("5m"));
assert_eq!(resolved_ttl_secs(&five, 1), Some(DEFAULT_TTL_SECS));
let bare = cached_body("x", None);
assert_eq!(resolved_ttl_secs(&bare, 1), Some(DEFAULT_TTL_SECS));
assert_eq!(resolved_ttl_secs(&bare, 0), None);
}
use super::test_seed_last_touch as seed;
#[test]
fn first_sighting_only_sets_baseline() {
let msgs = cached_body("first-sighting conversation", None);
assert!(!repack_decision(&msgs, 1));
assert!(!repack_decision(&msgs, 1));
}
#[test]
fn warm_prefix_is_never_repacked() {
let msgs = cached_body("warm conversation", Some("5m"));
seed(&msgs, 60); assert!(!repack_decision(&msgs, 1));
}
#[test]
fn large_gap_triggers_repack() {
let msgs = cached_body("cold conversation 5m", Some("5m"));
seed(&msgs, 2 * 60 * 60); assert!(repack_decision(&msgs, 1));
}
#[test]
fn cached_zero_never_repacks_even_when_idle() {
let msgs = cached_body("idle but uncached", None);
seed(&msgs, 24 * 60 * 60);
assert!(!repack_decision(&msgs, 0));
}
#[test]
fn hour_ttl_skips_the_ambiguous_zone() {
let msgs = cached_body("cold conversation 1h", Some("1h"));
seed(&msgs, 7000);
assert!(!repack_decision(&msgs, 1));
seed(&msgs, 8000);
assert!(repack_decision(&msgs, 1));
}
#[test]
fn key_ignores_cache_control_marker() {
let none = cached_body("marker-invariant conversation", None);
let hour = cached_body("marker-invariant conversation", Some("1h"));
let five = cached_body("marker-invariant conversation", Some("5m"));
let k = conversation_key(&none).unwrap();
assert_eq!(k, conversation_key(&hour).unwrap());
assert_eq!(k, conversation_key(&five).unwrap());
let other = cached_body("a different opening", Some("1h"));
assert_ne!(k, conversation_key(&other).unwrap());
}
#[test]
fn sticky_repack_persists_into_warm_followups() {
let msgs = cached_body("sticky cold-then-warm conversation", Some("5m"));
seed(&msgs, 2 * 60 * 60);
assert!(
repack_decision(&msgs, 1),
"a cold gap must trigger the repack"
);
assert!(
repack_decision(&msgs, 1),
"an immediate warm follow-up must stay sticky and keep repacking"
);
assert!(
repack_decision(&msgs, 1),
"stickiness persists across the rest of the session"
);
}
#[test]
fn cold_baseline_survives_restart_via_disk() {
let _iso = crate::core::data_dir::isolated_data_dir();
let msgs = cached_body("restart-survival conversation", Some("5m"));
seed(&msgs, 3 * 60 * 60);
persist_now(now_secs());
test_remove(&msgs);
resume_from_disk();
assert!(
repack_decision(&msgs, 1),
"a persisted cold baseline must survive a restart and still repack"
);
}
}