use std::collections::{HashMap, VecDeque};
const INLINE_MAX:usize = 500;
pub const DEFAULT_INLINE_BYTE_BUDGET:usize = 256 * 1024 * 1024;
pub struct AphroditeState {
pub inline_store:VecDeque<(String, String)>,
inline_store_bytes:usize,
inline_store_byte_budget:usize,
pub recent_markers:Vec<MarkerEntry>,
pub conv_index:HashMap<usize, (String, String, usize)>,
pub referenced_files:VecDeque<(String, String)>,
pub turn_counter:usize,
pub scanned_msg_idx:usize,
pub file_tools:Vec<String>,
pub api_url:String,
pub model:String,
pub engine_threshold_pct:u64,
pub engine_min_msgs:usize,
pub engine_protect_first:usize,
pub engine_protect_last:usize,
pub context_engine_enabled:bool,
pub tool_threshold:usize,
pub terminal_threshold:usize,
pub catalog_mode:String,
pub expand_guidance:bool,
pub dev_mode:bool,
pub directives:std::collections::HashMap<String, crate::directives::Directive>,
pub active_directives:Vec<String>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct MarkerEntry {
pub hash:String,
pub ccr_type:String,
pub size:usize,
pub preview:String,
pub turn:usize,
pub center:Option<String>,
pub meta:Option<HashMap<String, String>>,
}
impl Default for AphroditeState {
fn default() -> Self {
Self {
inline_store:VecDeque::with_capacity(INLINE_MAX),
inline_store_bytes:0,
inline_store_byte_budget:DEFAULT_INLINE_BYTE_BUDGET,
recent_markers:Vec::new(),
conv_index:HashMap::new(),
referenced_files:VecDeque::new(),
turn_counter:0,
scanned_msg_idx:0,
file_tools:vec!["read_file".into(), "write_file".into(), "patch".into(), "search_files".into()],
api_url:String::new(),
model:"gpt-4o".into(),
engine_threshold_pct:45,
engine_min_msgs:8,
engine_protect_first:2,
engine_protect_last:5,
context_engine_enabled:true,
tool_threshold:4096,
terminal_threshold:1024,
catalog_mode:"tool".into(),
expand_guidance:false,
dev_mode:false,
directives:std::collections::HashMap::new(),
active_directives:Vec::new(),
}
}
}
impl AphroditeState {
pub fn inline_store_byte_budget(&self) -> usize { self.inline_store_byte_budget }
pub fn set_inline_store_byte_budget(&mut self, budget:usize) {
self.inline_store_byte_budget = budget;
self.evict_over_budget();
}
pub fn inline_store_bytes(&self) -> usize { self.inline_store_bytes }
fn evict_over_budget(&mut self) {
while self.inline_store.len() > INLINE_MAX
|| (self.inline_store_bytes > self.inline_store_byte_budget && !self.inline_store.is_empty())
{
if let Some((_, c)) = self.inline_store.pop_back() {
self.inline_store_bytes = self.inline_store_bytes.saturating_sub(c.len());
} else {
break;
}
}
}
pub fn inline_store_put(&mut self, hash:String, content:String) {
if let Some(pos) = self.inline_store.iter().position(|(h, _)| h == &hash) {
if let Some((_, old)) = self.inline_store.remove(pos) {
self.inline_store_bytes = self.inline_store_bytes.saturating_sub(old.len());
}
}
self.inline_store_bytes += content.len();
self.inline_store.push_front((hash, content));
self.evict_over_budget();
}
pub fn inline_store_get(&mut self, hash:&str) -> Option<String> {
if let Some(pos) = self.inline_store.iter().position(|(h, _)| h == hash) {
let (h, c) = self.inline_store.remove(pos).unwrap();
self.inline_store.push_front((h, c.clone()));
Some(c)
} else {
None
}
}
pub fn increment_turn(&mut self) -> usize {
self.turn_counter += 1;
self.turn_counter
}
pub fn reset_scanned(&mut self) { self.scanned_msg_idx = 0; }
pub fn reset_turns(&mut self) { self.turn_counter = 0; }
pub fn record_marker(&mut self, entry:MarkerEntry) {
self.recent_markers.push(entry);
while self.recent_markers.len() > 200 {
self.recent_markers.remove(0);
}
}
pub fn record_file(&mut self, path:String, tool:String) {
self.referenced_files.retain(|(p, _)| p != &path);
self.referenced_files.push_front((path, tool));
while self.referenced_files.len() > 100 {
self.referenced_files.pop_back();
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_inline_store_put_get() {
let mut s = AphroditeState::default();
s.inline_store_put("abc".into(), "hello world".into());
assert_eq!(s.inline_store_get("abc"), Some("hello world".into()));
}
#[test]
fn test_inline_store_missing() {
let mut s = AphroditeState::default();
assert_eq!(s.inline_store_get("nope"), None);
}
#[test]
fn test_inline_store_lru_promotion() {
let mut s = AphroditeState::default();
s.inline_store_put("a".into(), "first".into());
s.inline_store_put("b".into(), "second".into());
let _ = s.inline_store_get("a");
let front = s.inline_store.pop_front();
assert_eq!(front, Some(("a".into(), "first".into())));
}
#[test]
fn test_inline_store_eviction() {
let mut s = AphroditeState::default();
for i in 0..505 {
s.inline_store_put(format!("hash{}", i), format!("content{}", i));
}
assert!(s.inline_store.len() <= 500);
assert_eq!(s.inline_store_get("hash0"), None);
assert_eq!(s.inline_store_get("hash504"), Some("content504".into()));
}
#[test]
fn test_inline_store_byte_budget_evicts_oldest_first() {
let mut s = AphroditeState::default();
s.set_inline_store_byte_budget(10 * 1024 * 1024); let five_mb = "x".repeat(5 * 1024 * 1024);
for i in 0..100 {
s.inline_store_put(format!("hash{i}"), five_mb.clone());
}
assert!(
s.inline_store_bytes() <= 10 * 1024 * 1024,
"stored bytes ({}) must stay within the 10MB budget",
s.inline_store_bytes()
);
assert_eq!(s.inline_store_get("hash0"), None);
assert_eq!(s.inline_store_get("hash99"), Some(five_mb));
}
#[test]
fn test_inline_store_default_byte_budget_is_256mb() {
let s = AphroditeState::default();
assert_eq!(s.inline_store_byte_budget(), 256 * 1024 * 1024);
}
#[test]
fn test_inline_store_lowering_budget_evicts_immediately() {
let mut s = AphroditeState::default();
s.inline_store_put("a".into(), "x".repeat(1000));
s.inline_store_put("b".into(), "x".repeat(1000));
assert_eq!(s.inline_store_bytes(), 2000);
s.set_inline_store_byte_budget(1500);
assert!(s.inline_store_bytes() <= 1500);
assert_eq!(s.inline_store_get("b"), Some("x".repeat(1000)));
assert_eq!(
s.inline_store_get("a"),
None,
"oldest entry must have been evicted to fit the new budget"
);
}
#[test]
fn test_record_marker_eviction() {
let mut s = AphroditeState::default();
for i in 0..250 {
s.record_marker(MarkerEntry {
hash:format!("h{}", i),
ccr_type:"text".into(),
size:100,
preview:"[text]".into(),
turn:i,
center:None,
meta:None,
});
}
assert!(s.recent_markers.len() <= 200);
assert_eq!(s.recent_markers[0].hash, "h50"); }
#[test]
fn test_record_file_dedup() {
let mut s = AphroditeState::default();
s.record_file("/tmp/a".into(), "read".into());
s.record_file("/tmp/a".into(), "write".into());
assert_eq!(s.referenced_files.len(), 1);
assert_eq!(s.referenced_files[0].1, "write"); }
#[test]
fn test_default_values() {
let s = AphroditeState::default();
assert_eq!(s.turn_counter, 0);
assert_eq!(s.tool_threshold, 4096);
assert_eq!(s.terminal_threshold, 1024);
assert!(s.context_engine_enabled);
}
#[test]
fn test_inline_store_put_overwrite() {
let mut s = AphroditeState::default();
s.inline_store_put("hash".into(), "v1".into());
s.inline_store_put("hash".into(), "v2".into());
assert_eq!(s.inline_store.len(), 1);
assert_eq!(s.inline_store_get("hash"), Some("v2".into()));
}
}