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.current_turn.saturating_sub(self.config.stale_after_turns);
271        let before_count = self.entries.len();
272        self.entries.retain(|_, entry| {
273            entry.pinned || entry.last_access_turn > stale_threshold
274        });
275        let evicted = before_count - self.entries.len();
276        if evicted > 0 {
277            self.last_eviction = Some(format!("Evicted {} stale file(s)", evicted));
278        }
279    }
280
281    fn maybe_evict(&mut self, new_tokens: usize) -> Option<String> {
282        let mut evicted_files = Vec::new();
283        while self.entries.len() >= self.config.max_files {
284            if let Some(key) = self.find_lru_candidate() {
285                if let Some(entry) = self.entries.remove(&key) {
286                    evicted_files.push(entry.path.to_string_lossy().to_string());
287                }
288            } else {
289                break;
290            }
291        }
292        while self.total_tokens() + new_tokens > self.config.max_tokens {
293            if let Some(key) = self.find_lru_candidate() {
294                if let Some(entry) = self.entries.remove(&key) {
295                    evicted_files.push(entry.path.to_string_lossy().to_string());
296                }
297            } else {
298                break;
299            }
300        }
301        if evicted_files.is_empty() {
302            None
303        } else {
304            let reason = format!("Evicted: {}", evicted_files.join(", "));
305            self.last_eviction = Some(reason.clone());
306            Some(reason)
307        }
308    }
309
310    fn find_lru_candidate(&self) -> Option<String> {
311        self.entries
312            .iter()
313            .filter(|(_, entry)| !entry.pinned)
314            .min_by_key(|(_, entry)| (entry.last_access_turn, entry.access_count))
315            .map(|(key, _)| key.clone())
316    }
317}
318
319/// Estimate tokens for a string (rough: ~4 chars per token)
320pub fn estimate_tokens(content: &str) -> usize {
321    content.len().div_ceil(4)
322}
323
324/// Estimate tokens for a file by size
325pub fn estimate_tokens_from_size(bytes: u64) -> usize {
326    (bytes as usize).div_ceil(4)
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn test_working_set_add_and_access() {
335        let mut ws = WorkingSet::new();
336        ws.add(PathBuf::from("/test/file1.rs"), 1000);
337        assert_eq!(ws.len(), 1);
338        assert!(ws.contains(&PathBuf::from("/test/file1.rs")));
339    }
340
341    #[test]
342    fn test_working_set_lru_eviction() {
343        let config = WorkingSetConfig {
344            max_files: 3,
345            max_tokens: 100_000,
346            stale_after_turns: 10,
347            auto_evict: false,
348        };
349        let mut ws = WorkingSet::with_config(config);
350        ws.add(PathBuf::from("/test/file1.rs"), 100);
351        ws.next_turn();
352        ws.add(PathBuf::from("/test/file2.rs"), 100);
353        ws.next_turn();
354        ws.add(PathBuf::from("/test/file3.rs"), 100);
355        ws.next_turn();
356        ws.add(PathBuf::from("/test/file4.rs"), 100);
357        assert_eq!(ws.len(), 3);
358        assert!(!ws.contains(&PathBuf::from("/test/file1.rs")));
359    }
360
361    #[test]
362    fn test_estimate_tokens() {
363        assert_eq!(estimate_tokens(""), 0);
364        assert_eq!(estimate_tokens("test"), 1);
365    }
366}