1use serde::{Deserialize, Serialize};
24use std::path::PathBuf;
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29#[derive(Default)]
30pub enum IdlePhase {
31 #[default]
33 Starting,
34 Consolidating,
36 Updating,
38 Completed,
40}
41
42impl IdlePhase {
43 pub fn is_terminal(&self) -> bool {
45 matches!(self, IdlePhase::Completed)
46 }
47}
48
49impl std::fmt::Display for IdlePhase {
50 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51 match self {
52 IdlePhase::Starting => write!(f, "starting"),
53 IdlePhase::Consolidating => write!(f, "consolidating"),
54 IdlePhase::Updating => write!(f, "updating"),
55 IdlePhase::Completed => write!(f, "completed"),
56 }
57 }
58}
59
60#[derive(Debug, Clone, Default, Serialize, Deserialize)]
62pub struct IdleTurn {
63 pub text: String,
65 pub tool_calls: Vec<IdleToolCall>,
67 pub touched_files: Vec<PathBuf>,
69 pub input_tokens: u64,
71 pub output_tokens: u64,
72}
73
74impl IdleTurn {
75 pub fn new(text: impl Into<String>) -> Self {
77 Self {
78 text: text.into(),
79 ..Default::default()
80 }
81 }
82
83 pub fn add_tool_call(&mut self, call: IdleToolCall) {
85 self.tool_calls.push(call);
86 }
87
88 pub fn add_touched_file(&mut self, path: impl Into<PathBuf>) {
90 self.touched_files.push(path.into());
91 }
92}
93
94#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct IdleToolCall {
97 pub name: String,
99 pub args_summary: String,
101 pub success: bool,
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct MemoryUpdate {
108 pub semantic_facts: Vec<String>,
110 pub episodic_entries: Vec<EpisodicEntry>,
112 pub procedural_updates: Vec<String>,
114 pub total_tokens: u64,
116 pub duration_ms: u64,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct EpisodicEntry {
123 pub timestamp: chrono::DateTime<chrono::Utc>,
125 pub description: String,
127 pub related_files: Vec<PathBuf>,
129 pub importance: f32,
131}
132
133#[derive(Debug, Clone, Serialize, Deserialize)]
135pub struct IdleTask {
136 pub id: uuid::Uuid,
138 pub phase: IdlePhase,
140 pub reason: String,
142 pub turns: Vec<IdleTurn>,
144 pub touched_files: Vec<PathBuf>,
146 pub start_time: std::time::SystemTime,
148 pub end_time: Option<std::time::SystemTime>,
150 pub memory_update: Option<MemoryUpdate>,
152 pub error: Option<String>,
154 max_turns: usize,
156}
157
158impl IdleTask {
159 pub fn new(reason: impl Into<String>) -> Self {
161 Self {
162 id: uuid::Uuid::new_v4(),
163 phase: IdlePhase::Starting,
164 reason: reason.into(),
165 turns: Vec::new(),
166 touched_files: Vec::new(),
167 start_time: std::time::SystemTime::now(),
168 end_time: None,
169 memory_update: None,
170 error: None,
171 max_turns: 30,
172 }
173 }
174
175 pub fn add_turn(&mut self, mut turn: IdleTurn) {
177 for file in turn.touched_files.drain(..) {
179 if !self.touched_files.contains(&file) {
180 self.touched_files.push(file);
181 }
182 }
183
184 self.turns.push(turn);
185
186 while self.turns.len() > self.max_turns {
188 self.turns.remove(0);
189 }
190 }
191
192 pub fn transition(&mut self, new_phase: IdlePhase) {
194 if new_phase.is_terminal() && !self.phase.is_terminal() {
195 self.end_time = Some(std::time::SystemTime::now());
196 }
197 self.phase = new_phase;
198 }
199
200 pub fn start_consolidation(&mut self) {
202 self.transition(IdlePhase::Consolidating);
203 }
204
205 pub fn start_update(&mut self) {
207 self.transition(IdlePhase::Updating);
208 }
209
210 pub fn complete(mut self) -> MemoryUpdate {
214 self.transition(IdlePhase::Completed);
215
216 let duration_ms = self
217 .end_time
218 .unwrap_or_else(std::time::SystemTime::now)
219 .duration_since(self.start_time)
220 .map(|d| d.as_millis() as u64)
221 .unwrap_or(0);
222
223 let total_tokens: u64 = self
224 .turns
225 .iter()
226 .map(|t| t.input_tokens + t.output_tokens)
227 .sum();
228
229 let semantic_facts: Vec<String> = self
231 .turns
232 .iter()
233 .filter_map(|t| {
234 if t.text.len() > 10 {
235 Some(t.text.clone())
236 } else {
237 None
238 }
239 })
240 .take(10)
241 .collect();
242
243 let episodic_entries: Vec<EpisodicEntry> = self
245 .touched_files
246 .iter()
247 .map(|f| EpisodicEntry {
248 timestamp: chrono::Utc::now(),
249 description: format!("File accessed: {}", f.display()),
250 related_files: vec![f.clone()],
251 importance: 0.5,
252 })
253 .collect();
254
255 let update = MemoryUpdate {
256 semantic_facts,
257 episodic_entries,
258 procedural_updates: Vec::new(),
259 total_tokens,
260 duration_ms,
261 };
262
263 self.memory_update = Some(update.clone());
264 update
265 }
266
267 pub fn fail(mut self, error: impl Into<String>) -> Self {
269 self.transition(IdlePhase::Completed);
270 self.error = Some(error.into());
271 self
272 }
273
274 pub fn is_completed(&self) -> bool {
276 self.phase.is_terminal()
277 }
278
279 pub fn recent_turns(&self, count: usize) -> &[IdleTurn] {
281 let start = self.turns.len().saturating_sub(count);
282 &self.turns[start..]
283 }
284
285 pub fn duration_ms(&self) -> Option<u64> {
287 self.end_time
288 .or_else(|| {
289 if self.phase.is_terminal() {
290 Some(std::time::SystemTime::now())
291 } else {
292 None
293 }
294 })
295 .and_then(|end| end.duration_since(self.start_time).ok())
296 .map(|d| d.as_millis() as u64)
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303
304 #[test]
305 fn test_idle_lifecycle() {
306 let mut idle = IdleTask::new("idle_timeout");
307
308 assert_eq!(idle.phase, IdlePhase::Starting);
309
310 idle.start_consolidation();
311 assert_eq!(idle.phase, IdlePhase::Consolidating);
312
313 idle.add_turn(IdleTurn::new("Consolidating memory..."));
314 assert_eq!(idle.turns.len(), 1);
315
316 idle.start_update();
317 assert_eq!(idle.phase, IdlePhase::Updating);
318
319 idle.add_turn(IdleTurn::new("Updating semantic memory"));
320 assert_eq!(idle.turns.len(), 2);
321
322 let update = idle.complete();
323 assert!(!update.semantic_facts.is_empty() || !update.episodic_entries.is_empty());
324 }
325
326 #[test]
327 fn test_idle_sliding_window() {
328 let mut idle = IdleTask::new("test");
329 idle.max_turns = 3;
330
331 for i in 0..5 {
332 idle.add_turn(IdleTurn::new(format!("Turn {}", i)));
333 }
334
335 assert_eq!(idle.turns.len(), 3);
336 assert_eq!(idle.turns[0].text, "Turn 2");
337 assert_eq!(idle.turns[2].text, "Turn 4");
338 }
339
340 #[test]
341 fn test_idle_touched_files() {
342 let mut idle = IdleTask::new("test");
343
344 let mut turn1 = IdleTurn::new("First turn");
345 turn1.add_touched_file("/path/to/file1.rs");
346 turn1.add_touched_file("/path/to/file2.rs");
347
348 let mut turn2 = IdleTurn::new("Second turn");
349 turn2.add_touched_file("/path/to/file1.rs"); turn2.add_touched_file("/path/to/file3.rs");
351
352 idle.add_turn(turn1);
353 idle.add_turn(turn2);
354
355 assert_eq!(idle.touched_files.len(), 3);
357 assert!(idle
358 .touched_files
359 .contains(&PathBuf::from("/path/to/file1.rs")));
360 assert!(idle
361 .touched_files
362 .contains(&PathBuf::from("/path/to/file2.rs")));
363 assert!(idle
364 .touched_files
365 .contains(&PathBuf::from("/path/to/file3.rs")));
366 }
367}