use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[derive(Default)]
pub enum IdlePhase {
#[default]
Starting,
Consolidating,
Updating,
Completed,
}
impl IdlePhase {
pub fn is_terminal(&self) -> bool {
matches!(self, IdlePhase::Completed)
}
}
impl std::fmt::Display for IdlePhase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
IdlePhase::Starting => write!(f, "starting"),
IdlePhase::Consolidating => write!(f, "consolidating"),
IdlePhase::Updating => write!(f, "updating"),
IdlePhase::Completed => write!(f, "completed"),
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct IdleTurn {
pub text: String,
pub tool_calls: Vec<IdleToolCall>,
pub touched_files: Vec<PathBuf>,
pub input_tokens: u64,
pub output_tokens: u64,
}
impl IdleTurn {
pub fn new(text: impl Into<String>) -> Self {
Self {
text: text.into(),
..Default::default()
}
}
pub fn add_tool_call(&mut self, call: IdleToolCall) {
self.tool_calls.push(call);
}
pub fn add_touched_file(&mut self, path: impl Into<PathBuf>) {
self.touched_files.push(path.into());
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdleToolCall {
pub name: String,
pub args_summary: String,
pub success: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryUpdate {
pub semantic_facts: Vec<String>,
pub episodic_entries: Vec<EpisodicEntry>,
pub procedural_updates: Vec<String>,
pub total_tokens: u64,
pub duration_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EpisodicEntry {
pub timestamp: chrono::DateTime<chrono::Utc>,
pub description: String,
pub related_files: Vec<PathBuf>,
pub importance: f32,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdleTask {
pub id: uuid::Uuid,
pub phase: IdlePhase,
pub reason: String,
pub turns: Vec<IdleTurn>,
pub touched_files: Vec<PathBuf>,
pub start_time: std::time::SystemTime,
pub end_time: Option<std::time::SystemTime>,
pub memory_update: Option<MemoryUpdate>,
pub error: Option<String>,
max_turns: usize,
}
impl IdleTask {
pub fn new(reason: impl Into<String>) -> Self {
Self {
id: uuid::Uuid::new_v4(),
phase: IdlePhase::Starting,
reason: reason.into(),
turns: Vec::new(),
touched_files: Vec::new(),
start_time: std::time::SystemTime::now(),
end_time: None,
memory_update: None,
error: None,
max_turns: 30,
}
}
pub fn add_turn(&mut self, mut turn: IdleTurn) {
for file in turn.touched_files.drain(..) {
if !self.touched_files.contains(&file) {
self.touched_files.push(file);
}
}
self.turns.push(turn);
while self.turns.len() > self.max_turns {
self.turns.remove(0);
}
}
pub fn transition(&mut self, new_phase: IdlePhase) {
if new_phase.is_terminal() && !self.phase.is_terminal() {
self.end_time = Some(std::time::SystemTime::now());
}
self.phase = new_phase;
}
pub fn start_consolidation(&mut self) {
self.transition(IdlePhase::Consolidating);
}
pub fn start_update(&mut self) {
self.transition(IdlePhase::Updating);
}
pub fn complete(mut self) -> MemoryUpdate {
self.transition(IdlePhase::Completed);
let duration_ms = self
.end_time
.unwrap_or_else(std::time::SystemTime::now)
.duration_since(self.start_time)
.map(|d| d.as_millis() as u64)
.unwrap_or(0);
let total_tokens: u64 = self
.turns
.iter()
.map(|t| t.input_tokens + t.output_tokens)
.sum();
let semantic_facts: Vec<String> = self
.turns
.iter()
.filter_map(|t| {
if t.text.len() > 10 {
Some(t.text.clone())
} else {
None
}
})
.take(10)
.collect();
let episodic_entries: Vec<EpisodicEntry> = self
.touched_files
.iter()
.map(|f| EpisodicEntry {
timestamp: chrono::Utc::now(),
description: format!("File accessed: {}", f.display()),
related_files: vec![f.clone()],
importance: 0.5,
})
.collect();
let update = MemoryUpdate {
semantic_facts,
episodic_entries,
procedural_updates: Vec::new(),
total_tokens,
duration_ms,
};
self.memory_update = Some(update.clone());
update
}
pub fn fail(mut self, error: impl Into<String>) -> Self {
self.transition(IdlePhase::Completed);
self.error = Some(error.into());
self
}
pub fn is_completed(&self) -> bool {
self.phase.is_terminal()
}
pub fn recent_turns(&self, count: usize) -> &[IdleTurn] {
let start = self.turns.len().saturating_sub(count);
&self.turns[start..]
}
pub fn duration_ms(&self) -> Option<u64> {
self.end_time
.or_else(|| {
if self.phase.is_terminal() {
Some(std::time::SystemTime::now())
} else {
None
}
})
.and_then(|end| end.duration_since(self.start_time).ok())
.map(|d| d.as_millis() as u64)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_idle_lifecycle() {
let mut idle = IdleTask::new("idle_timeout");
assert_eq!(idle.phase, IdlePhase::Starting);
idle.start_consolidation();
assert_eq!(idle.phase, IdlePhase::Consolidating);
idle.add_turn(IdleTurn::new("Consolidating memory..."));
assert_eq!(idle.turns.len(), 1);
idle.start_update();
assert_eq!(idle.phase, IdlePhase::Updating);
idle.add_turn(IdleTurn::new("Updating semantic memory"));
assert_eq!(idle.turns.len(), 2);
let update = idle.complete();
assert!(!update.semantic_facts.is_empty() || !update.episodic_entries.is_empty());
}
#[test]
fn test_idle_sliding_window() {
let mut idle = IdleTask::new("test");
idle.max_turns = 3;
for i in 0..5 {
idle.add_turn(IdleTurn::new(format!("Turn {}", i)));
}
assert_eq!(idle.turns.len(), 3);
assert_eq!(idle.turns[0].text, "Turn 2");
assert_eq!(idle.turns[2].text, "Turn 4");
}
#[test]
fn test_idle_touched_files() {
let mut idle = IdleTask::new("test");
let mut turn1 = IdleTurn::new("First turn");
turn1.add_touched_file("/path/to/file1.rs");
turn1.add_touched_file("/path/to/file2.rs");
let mut turn2 = IdleTurn::new("Second turn");
turn2.add_touched_file("/path/to/file1.rs"); turn2.add_touched_file("/path/to/file3.rs");
idle.add_turn(turn1);
idle.add_turn(turn2);
assert_eq!(idle.touched_files.len(), 3);
assert!(idle
.touched_files
.contains(&PathBuf::from("/path/to/file1.rs")));
assert!(idle
.touched_files
.contains(&PathBuf::from("/path/to/file2.rs")));
assert!(idle
.touched_files
.contains(&PathBuf::from("/path/to/file3.rs")));
}
}