Skip to main content

brainwires_core/
working_set.rs

1//! Working Set for File Context Management
2//!
3//! Tracks files that are currently "in context" for the AI agent.
4//! Supports LRU-style eviction to prevent context bloat.
5
6use serde::{Deserialize, Serialize};
7use std::collections::HashMap;
8use std::path::{Path, PathBuf};
9
10/// Maximum number of files in the working set by default
11pub const DEFAULT_MAX_FILES: usize = 15;
12
13/// Maximum total tokens in working set by default (rough estimate)
14pub const DEFAULT_MAX_TOKENS: usize = 100_000;
15
16/// A file entry in the working set
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct WorkingSetEntry {
19    /// File path.
20    pub path: PathBuf,
21    /// Estimated token count for this file.
22    pub tokens: usize,
23    /// Number of times this file has been accessed.
24    pub access_count: u32,
25    /// Turn number when this file was last accessed.
26    pub last_access_turn: u32,
27    /// Turn number when this file was added.
28    pub added_at_turn: u32,
29    /// Whether this file is pinned (immune to eviction).
30    pub pinned: bool,
31    /// Optional label for categorizing the entry.
32    pub label: Option<String>,
33}
34
35impl WorkingSetEntry {
36    /// Create a new working set entry at the given turn.
37    pub fn new(path: PathBuf, tokens: usize, current_turn: u32) -> Self {
38        Self {
39            path,
40            tokens,
41            access_count: 1,
42            last_access_turn: current_turn,
43            added_at_turn: current_turn,
44            pinned: false,
45            label: None,
46        }
47    }
48
49    /// Attach a label to this entry (builder pattern).
50    pub fn with_label(mut self, label: impl Into<String>) -> Self {
51        self.label = Some(label.into());
52        self
53    }
54
55    /// Mark this entry as pinned (builder pattern).
56    pub fn pinned(mut self) -> Self {
57        self.pinned = true;
58        self
59    }
60}
61
62/// Working set configuration
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct WorkingSetConfig {
65    /// Maximum number of files allowed in the working set.
66    pub max_files: usize,
67    /// Maximum total token count across all files.
68    pub max_tokens: usize,
69    /// Number of turns after which an unpinned file is considered stale.
70    pub stale_after_turns: u32,
71    /// Whether to automatically evict stale files on each turn.
72    pub auto_evict: bool,
73}
74
75impl Default for WorkingSetConfig {
76    fn default() -> Self {
77        Self {
78            max_files: DEFAULT_MAX_FILES,
79            max_tokens: DEFAULT_MAX_TOKENS,
80            stale_after_turns: 10,
81            auto_evict: true,
82        }
83    }
84}
85
86/// Manages the set of files currently in the agent's context
87#[derive(Debug, Clone, Default)]
88pub struct WorkingSet {
89    entries: HashMap<String, WorkingSetEntry>,
90    config: WorkingSetConfig,
91    current_turn: u32,
92    last_eviction: Option<String>,
93}
94
95impl WorkingSet {
96    /// Create a new working set with default configuration.
97    pub fn new() -> Self {
98        Self {
99            entries: HashMap::new(),
100            config: WorkingSetConfig::default(),
101            current_turn: 0,
102            last_eviction: None,
103        }
104    }
105
106    /// Create a new working set with the given configuration.
107    pub fn with_config(config: WorkingSetConfig) -> Self {
108        Self {
109            entries: HashMap::new(),
110            config,
111            current_turn: 0,
112            last_eviction: None,
113        }
114    }
115
116    /// Advance to the next turn, triggering stale eviction if enabled.
117    pub fn next_turn(&mut self) {
118        self.current_turn += 1;
119        if self.config.auto_evict {
120            self.evict_stale();
121        }
122    }
123
124    /// Returns the current turn number.
125    pub fn current_turn(&self) -> u32 {
126        self.current_turn
127    }
128
129    /// Add a file to the working set, evicting LRU entries if needed.
130    pub fn add(&mut self, path: PathBuf, tokens: usize) -> Option<String> {
131        let key = path.to_string_lossy().to_string();
132        if let Some(entry) = self.entries.get_mut(&key) {
133            entry.access_count += 1;
134            entry.last_access_turn = self.current_turn;
135            return None;
136        }
137        let eviction_reason = self.maybe_evict(tokens);
138        let entry = WorkingSetEntry::new(path, tokens, self.current_turn);
139        self.entries.insert(key, entry);
140        eviction_reason
141    }
142
143    /// Add a file with a label, evicting LRU entries if needed.
144    pub fn add_labeled(&mut self, path: PathBuf, tokens: usize, label: &str) -> Option<String> {
145        let key = path.to_string_lossy().to_string();
146        if let Some(entry) = self.entries.get_mut(&key) {
147            entry.access_count += 1;
148            entry.last_access_turn = self.current_turn;
149            entry.label = Some(label.to_string());
150            return None;
151        }
152        let eviction_reason = self.maybe_evict(tokens);
153        let entry = WorkingSetEntry::new(path, tokens, self.current_turn).with_label(label);
154        self.entries.insert(key, entry);
155        eviction_reason
156    }
157
158    /// Add a pinned file that is immune to eviction.
159    pub fn add_pinned(&mut self, path: PathBuf, tokens: usize, label: Option<&str>) {
160        let key = path.to_string_lossy().to_string();
161        if let Some(entry) = self.entries.get_mut(&key) {
162            entry.pinned = true;
163            entry.access_count += 1;
164            entry.last_access_turn = self.current_turn;
165            if let Some(l) = label {
166                entry.label = Some(l.to_string());
167            }
168            return;
169        }
170        let mut entry = WorkingSetEntry::new(path, tokens, self.current_turn).pinned();
171        if let Some(l) = label {
172            entry.label = Some(l.to_string());
173        }
174        self.entries.insert(key, entry);
175    }
176
177    /// Touch a file to update its access count and turn.
178    pub fn touch(&mut self, path: &Path) -> bool {
179        let key = path.to_string_lossy().to_string();
180        if let Some(entry) = self.entries.get_mut(&key) {
181            entry.access_count += 1;
182            entry.last_access_turn = self.current_turn;
183            true
184        } else {
185            false
186        }
187    }
188
189    /// Remove a file from the working set.
190    pub fn remove(&mut self, path: &Path) -> bool {
191        let key = path.to_string_lossy().to_string();
192        self.entries.remove(&key).is_some()
193    }
194
195    /// Pin a file to prevent eviction.
196    pub fn pin(&mut self, path: &Path) -> bool {
197        let key = path.to_string_lossy().to_string();
198        if let Some(entry) = self.entries.get_mut(&key) {
199            entry.pinned = true;
200            true
201        } else {
202            false
203        }
204    }
205
206    /// Unpin a file, allowing it to be evicted.
207    pub fn unpin(&mut self, path: &Path) -> bool {
208        let key = path.to_string_lossy().to_string();
209        if let Some(entry) = self.entries.get_mut(&key) {
210            entry.pinned = false;
211            true
212        } else {
213            false
214        }
215    }
216
217    /// Clear the working set, optionally keeping pinned entries.
218    pub fn clear(&mut self, keep_pinned: bool) {
219        if keep_pinned {
220            self.entries.retain(|_, entry| entry.pinned);
221        } else {
222            self.entries.clear();
223        }
224        self.last_eviction = None;
225    }
226
227    /// Iterate over all entries in the working set.
228    pub fn entries(&self) -> impl Iterator<Item = &WorkingSetEntry> {
229        self.entries.values()
230    }
231
232    /// Get an entry by path.
233    pub fn get(&self, path: &Path) -> Option<&WorkingSetEntry> {
234        let key = path.to_string_lossy().to_string();
235        self.entries.get(&key)
236    }
237
238    /// Check if a path is in the working set.
239    pub fn contains(&self, path: &Path) -> bool {
240        let key = path.to_string_lossy().to_string();
241        self.entries.contains_key(&key)
242    }
243
244    /// Returns the number of entries in the working set.
245    pub fn len(&self) -> usize {
246        self.entries.len()
247    }
248
249    /// Returns true if the working set is empty.
250    pub fn is_empty(&self) -> bool {
251        self.entries.is_empty()
252    }
253
254    /// Returns the total estimated token count across all entries.
255    pub fn total_tokens(&self) -> usize {
256        self.entries.values().map(|e| e.tokens).sum()
257    }
258
259    /// Returns the last eviction message, if any.
260    pub fn last_eviction(&self) -> Option<&str> {
261        self.last_eviction.as_deref()
262    }
263
264    /// Returns all file paths in the working set.
265    pub fn file_paths(&self) -> Vec<&PathBuf> {
266        self.entries.values().map(|e| &e.path).collect()
267    }
268
269    fn evict_stale(&mut self) {
270        let stale_threshold = self
271            .current_turn
272            .saturating_sub(self.config.stale_after_turns);
273        let before_count = self.entries.len();
274        self.entries
275            .retain(|_, entry| entry.pinned || entry.last_access_turn > stale_threshold);
276        let evicted = before_count - self.entries.len();
277        if evicted > 0 {
278            self.last_eviction = Some(format!("Evicted {} stale file(s)", evicted));
279        }
280    }
281
282    fn maybe_evict(&mut self, new_tokens: usize) -> Option<String> {
283        let mut evicted_files = Vec::new();
284        while self.entries.len() >= self.config.max_files {
285            if let Some(key) = self.find_lru_candidate() {
286                if let Some(entry) = self.entries.remove(&key) {
287                    evicted_files.push(entry.path.to_string_lossy().to_string());
288                }
289            } else {
290                break;
291            }
292        }
293        while self.total_tokens() + new_tokens > self.config.max_tokens {
294            if let Some(key) = self.find_lru_candidate() {
295                if let Some(entry) = self.entries.remove(&key) {
296                    evicted_files.push(entry.path.to_string_lossy().to_string());
297                }
298            } else {
299                break;
300            }
301        }
302        if evicted_files.is_empty() {
303            None
304        } else {
305            let reason = format!("Evicted: {}", evicted_files.join(", "));
306            self.last_eviction = Some(reason.clone());
307            Some(reason)
308        }
309    }
310
311    fn find_lru_candidate(&self) -> Option<String> {
312        self.entries
313            .iter()
314            .filter(|(_, entry)| !entry.pinned)
315            .min_by_key(|(_, entry)| (entry.last_access_turn, entry.access_count))
316            .map(|(key, _)| key.clone())
317    }
318}
319
320/// Estimate tokens for a string (rough: ~4 chars per token)
321pub fn estimate_tokens(content: &str) -> usize {
322    content.len().div_ceil(4)
323}
324
325/// Estimate tokens for a file by size
326pub fn estimate_tokens_from_size(bytes: u64) -> usize {
327    (bytes as usize).div_ceil(4)
328}
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    #[test]
335    fn test_working_set_add_and_access() {
336        let mut ws = WorkingSet::new();
337        ws.add(PathBuf::from("/test/file1.rs"), 1000);
338        assert_eq!(ws.len(), 1);
339        assert!(ws.contains(&PathBuf::from("/test/file1.rs")));
340    }
341
342    #[test]
343    fn test_working_set_lru_eviction() {
344        let config = WorkingSetConfig {
345            max_files: 3,
346            max_tokens: 100_000,
347            stale_after_turns: 10,
348            auto_evict: false,
349        };
350        let mut ws = WorkingSet::with_config(config);
351        ws.add(PathBuf::from("/test/file1.rs"), 100);
352        ws.next_turn();
353        ws.add(PathBuf::from("/test/file2.rs"), 100);
354        ws.next_turn();
355        ws.add(PathBuf::from("/test/file3.rs"), 100);
356        ws.next_turn();
357        ws.add(PathBuf::from("/test/file4.rs"), 100);
358        assert_eq!(ws.len(), 3);
359        assert!(!ws.contains(&PathBuf::from("/test/file1.rs")));
360    }
361
362    #[test]
363    fn test_estimate_tokens() {
364        assert_eq!(estimate_tokens(""), 0);
365        assert_eq!(estimate_tokens("test"), 1);
366    }
367}