use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use tracing::{debug, info, warn};
const MAX_MEMORY_LINES: usize = 200;
const MAX_MEMORY_SIZE: usize = 25 * 1024;
const STALE_MEMORY_DAYS: u64 = 90;
const MIN_HOURS_BETWEEN_DREAMS: u64 = 24;
const MIN_SESSIONS_BETWEEN_DREAMS: usize = 5;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DreamTrigger {
pub min_hours_since_last: u64,
pub min_sessions_since_last: usize,
}
impl Default for DreamTrigger {
fn default() -> Self {
Self {
min_hours_since_last: MIN_HOURS_BETWEEN_DREAMS,
min_sessions_since_last: MIN_SESSIONS_BETWEEN_DREAMS,
}
}
}
impl DreamTrigger {
pub fn new() -> Self {
Self::default()
}
pub fn with_min_hours(mut self, hours: u64) -> Self {
self.min_hours_since_last = hours;
self
}
pub fn with_min_sessions(mut self, sessions: usize) -> Self {
self.min_sessions_since_last = sessions;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct DreamState {
#[serde(default)]
pub last_dream_timestamp: u64,
#[serde(default)]
pub sessions_since_last_dream: usize,
#[serde(skip)]
pub consolidation_lock: bool,
#[serde(default)]
pub dream_count: usize,
#[serde(skip)]
lock_file_path: Option<PathBuf>,
}
impl DreamState {
pub fn new() -> Self {
Self::default()
}
fn state_file_path(base_dir: &Path) -> PathBuf {
base_dir.join("dream_state.json")
}
pub fn load(base_dir: &Path) -> Result<Self> {
let path = Self::state_file_path(base_dir);
if !path.exists() {
return Ok(Self::new());
}
let content = std::fs::read_to_string(&path)?;
let mut state: DreamState = serde_json::from_str(&content)?;
state.lock_file_path = Some(base_dir.join("dream.lock"));
if state
.lock_file_path
.as_ref()
.map(|p| p.exists())
.unwrap_or(false)
{
warn!("Found existing dream lock file - previous dream may have crashed");
let _ = std::fs::remove_file(state.lock_file_path.as_ref().unwrap());
}
Ok(state)
}
pub fn save(&self, base_dir: &Path) -> Result<()> {
let path = Self::state_file_path(base_dir);
std::fs::create_dir_all(base_dir)?;
let json = serde_json::to_string_pretty(self)?;
let temp_path = path.with_extension(format!("tmp.{}", std::process::id()));
std::fs::write(&temp_path, json)?;
std::fs::rename(&temp_path, &path)?;
Ok(())
}
pub fn acquire_consolidation_lock(&mut self) -> bool {
if self.consolidation_lock {
return false;
}
if let Some(ref lock_path) = self.lock_file_path {
if lock_path.exists() {
return false;
}
if let Err(e) = std::fs::write(lock_path, std::process::id().to_string()) {
warn!("Failed to create dream lock file: {}", e);
return false;
}
}
self.consolidation_lock = true;
true
}
pub fn release_consolidation_lock(&mut self) {
self.consolidation_lock = false;
if let Some(ref lock_path) = self.lock_file_path {
let _ = std::fs::remove_file(lock_path);
}
}
pub fn record_session_end(&mut self) {
self.sessions_since_last_dream += 1;
}
pub fn should_run_dream_check_gates(&self, trigger: &DreamTrigger) -> bool {
if self.last_dream_timestamp == 0 {
debug!("Dream gate 1 failed: no previous dream recorded");
return false;
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let hours_since_last = (now - self.last_dream_timestamp) / 3600;
if hours_since_last < trigger.min_hours_since_last {
debug!(
"Dream gate 1 failed: only {} hours since last dream (need {})",
hours_since_last, trigger.min_hours_since_last
);
return false;
}
if self.sessions_since_last_dream < trigger.min_sessions_since_last {
debug!(
"Dream gate 2 failed: only {} sessions since last dream (need {})",
self.sessions_since_last_dream, trigger.min_sessions_since_last
);
return false;
}
true
}
pub fn hours_until_next(&self, trigger: &DreamTrigger) -> u64 {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let hours_since_last = (now - self.last_dream_timestamp) / 3600;
trigger
.min_hours_since_last
.saturating_sub(hours_since_last)
}
pub fn sessions_until_next(&self, trigger: &DreamTrigger) -> usize {
trigger
.min_sessions_since_last
.saturating_sub(self.sessions_since_last_dream)
}
}
impl Drop for DreamState {
fn drop(&mut self) {
if self.consolidation_lock {
self.release_consolidation_lock();
}
}
}
pub fn should_run_dream(state: &mut DreamState, trigger: &DreamTrigger) -> bool {
if !state.should_run_dream_check_gates(trigger) {
return false;
}
if !state.acquire_consolidation_lock() {
debug!("Dream gate 3 failed: consolidation already in progress");
return false;
}
info!("All dream gates passed - dream should run");
true
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum MemorySection {
Facts,
Preferences,
ArchitectureDecisions,
Unknown(String),
}
impl MemorySection {
fn from_header(header: &str) -> Self {
match header.trim() {
"Facts" | "Facts (consolidated)" => MemorySection::Facts,
"Preferences" | "Preferences (user-defined)" => MemorySection::Preferences,
"Architecture Decisions" => MemorySection::ArchitectureDecisions,
other => MemorySection::Unknown(other.to_string()),
}
}
fn header(&self) -> &str {
match self {
MemorySection::Facts => "## Facts (consolidated)",
MemorySection::Preferences => "## Preferences (user-defined)",
MemorySection::ArchitectureDecisions => "## Architecture Decisions",
MemorySection::Unknown(s) => s.as_str(),
}
}
}
#[derive(Debug, Clone)]
pub struct MemoryEntry {
pub date: Option<String>,
pub content: String,
pub section: MemorySection,
pub raw_line: String,
}
impl MemoryEntry {
pub fn parse(line: &str, current_section: &MemorySection) -> Option<Self> {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
return None;
}
let content_start = trimmed
.strip_prefix("- ")
.or_else(|| trimmed.strip_prefix("* "))
.unwrap_or(trimmed);
let (date, content) = if let Some(end_bracket) = content_start.find(']') {
if content_start.starts_with('[') && end_bracket > 1 {
let date_str = &content_start[1..end_bracket];
let rest = content_start[end_bracket + 1..].trim().to_string();
(Some(date_str.to_string()), rest)
} else {
(None, content_start.to_string())
}
} else {
(None, content_start.to_string())
};
Some(Self {
date,
content,
section: current_section.clone(),
raw_line: line.to_string(),
})
}
pub fn is_stale(&self, threshold_days: u64) -> bool {
let Some(ref date_str) = self.date else {
return false; };
let parsed_date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d").or_else(|_| {
chrono::NaiveDateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M").map(|dt| dt.date())
});
let Ok(memory_date) = parsed_date else {
return false;
};
let today = chrono::Local::now().date_naive();
let age_days = today.signed_duration_since(memory_date).num_days();
age_days > threshold_days as i64
}
}
#[derive(Debug, Clone)]
pub struct MemoryStore {
pub entries: Vec<MemoryEntry>,
pub sections: HashMap<MemorySection, Vec<usize>>, }
impl MemoryStore {
pub fn parse(content: &str) -> Self {
let mut entries = Vec::new();
let mut sections: HashMap<MemorySection, Vec<usize>> = HashMap::new();
let mut current_section = MemorySection::Facts;
for line in content.lines() {
if let Some(header) = line.strip_prefix("## ") {
current_section = MemorySection::from_header(header);
continue;
}
if let Some(entry) = MemoryEntry::parse(line, ¤t_section) {
let idx = entries.len();
entries.push(entry);
sections
.entry(current_section.clone())
.or_default()
.push(idx);
}
}
Self { entries, sections }
}
pub fn format(&self) -> String {
let mut output = String::from("# Project Memory\n\n");
let section_order = [
MemorySection::Facts,
MemorySection::Preferences,
MemorySection::ArchitectureDecisions,
];
for section in §ion_order {
if let Some(indices) = self.sections.get(section) {
if !indices.is_empty() {
output.push_str(section.header());
output.push('\n');
for &idx in indices {
if let Some(entry) = self.entries.get(idx) {
output.push_str(&entry.raw_line);
output.push('\n');
}
}
output.push('\n');
}
}
}
for (section, indices) in &self.sections {
if matches!(section, MemorySection::Unknown(_)) {
output.push_str(section.header());
output.push('\n');
for &idx in indices {
if let Some(entry) = self.entries.get(idx) {
output.push_str(&entry.raw_line);
output.push('\n');
}
}
output.push('\n');
}
}
output
}
pub fn prune_stale(&mut self, threshold_days: u64) -> usize {
let mut removed = 0;
let mut new_entries = Vec::new();
let mut old_to_new: HashMap<usize, usize> = HashMap::new();
for (old_idx, entry) in self.entries.iter().enumerate() {
if entry.is_stale(threshold_days) {
removed += 1;
} else {
let new_idx = new_entries.len();
new_entries.push(entry.clone());
old_to_new.insert(old_idx, new_idx);
}
}
for indices in self.sections.values_mut() {
let new_indices: Vec<usize> = indices
.iter()
.filter_map(|&old_idx| old_to_new.get(&old_idx).copied())
.collect();
*indices = new_indices;
}
self.entries = new_entries;
removed
}
pub fn cap_size(&mut self, max_lines: usize, max_bytes: usize) -> usize {
let content = self.format();
if content.lines().count() <= max_lines && content.len() <= max_bytes {
return 0;
}
let mut removed = 0;
let mut new_entries = Vec::new();
let mut old_to_new: HashMap<usize, usize> = HashMap::new();
let important_indices: Vec<usize> = self
.entries
.iter()
.enumerate()
.filter(|(_, e)| !matches!(e.section, MemorySection::Facts))
.map(|(i, _)| i)
.collect();
for &idx in &important_indices {
let new_idx = new_entries.len();
old_to_new.insert(idx, new_idx);
new_entries.push(self.entries[idx].clone());
}
let fact_indices = self
.sections
.get(&MemorySection::Facts)
.cloned()
.unwrap_or_default();
for idx in fact_indices {
if !important_indices.contains(&idx) {
let test_entries: Vec<MemoryEntry> = new_entries
.iter()
.chain(std::iter::once(&self.entries[idx]))
.cloned()
.collect();
let test_store = MemoryStore {
entries: test_entries,
sections: HashMap::new(), };
let test_content = test_store.format();
if test_content.lines().count() > max_lines || test_content.len() > max_bytes {
removed += 1;
} else {
let new_idx = new_entries.len();
old_to_new.insert(idx, new_idx);
new_entries.push(self.entries[idx].clone());
}
}
}
for indices in self.sections.values_mut() {
let new_indices: Vec<usize> = indices
.iter()
.filter_map(|&old_idx| old_to_new.get(&old_idx).copied())
.collect();
*indices = new_indices;
}
self.entries = new_entries;
removed
}
pub fn stats(&self) -> MemoryStats {
let total_entries = self.entries.len();
let facts_count = self
.sections
.get(&MemorySection::Facts)
.map(|v| v.len())
.unwrap_or(0);
let preferences_count = self
.sections
.get(&MemorySection::Preferences)
.map(|v| v.len())
.unwrap_or(0);
let architecture_count = self
.sections
.get(&MemorySection::ArchitectureDecisions)
.map(|v| v.len())
.unwrap_or(0);
let content = self.format();
MemoryStats {
total_entries,
facts_count,
preferences_count,
architecture_count,
total_lines: content.lines().count(),
total_bytes: content.len(),
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct MemoryStats {
pub total_entries: usize,
pub facts_count: usize,
pub preferences_count: usize,
pub architecture_count: usize,
pub total_lines: usize,
pub total_bytes: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DreamConfig {
pub max_memory_lines: usize,
pub max_memory_size: usize,
pub stale_memory_days: u64,
pub base_dir: PathBuf,
pub trigger: DreamTrigger,
}
impl Default for DreamConfig {
fn default() -> Self {
Self {
max_memory_lines: MAX_MEMORY_LINES,
max_memory_size: MAX_MEMORY_SIZE,
stale_memory_days: STALE_MEMORY_DAYS,
base_dir: Self::default_base_dir(),
trigger: DreamTrigger::default(),
}
}
}
impl DreamConfig {
fn default_base_dir() -> PathBuf {
dirs::home_dir()
.map(|h| h.join(".selfware").join("memory"))
.unwrap_or_else(|| PathBuf::from(".selfware").join("memory"))
}
pub fn new() -> Self {
Self::default()
}
pub fn with_base_dir(mut self, path: impl Into<PathBuf>) -> Self {
self.base_dir = path.into();
self
}
pub fn memory_file_path(&self, project_key: &str) -> PathBuf {
self.base_dir.join(format!("{}_MEMORY.md", project_key))
}
pub fn state_path(&self) -> PathBuf {
self.base_dir.clone()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DreamPhase {
Orient,
Gather,
Consolidate,
PruneAndIndex,
}
impl std::fmt::Display for DreamPhase {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
DreamPhase::Orient => write!(f, "Orient"),
DreamPhase::Gather => write!(f, "Gather Recent Signal"),
DreamPhase::Consolidate => write!(f, "Consolidate"),
DreamPhase::PruneAndIndex => write!(f, "Prune & Index"),
}
}
}
#[derive(Debug, Clone)]
pub struct DreamResult {
pub success: bool,
pub phases_completed: Vec<DreamPhase>,
pub memories_consolidated: usize,
pub memories_pruned: usize,
pub errors: Vec<String>,
pub duration_secs: u64,
}
impl DreamResult {
pub fn success(phases: Vec<DreamPhase>) -> Self {
Self {
success: true,
phases_completed: phases,
memories_consolidated: 0,
memories_pruned: 0,
errors: Vec::new(),
duration_secs: 0,
}
}
pub fn failure(error: impl Into<String>) -> Self {
Self {
success: false,
phases_completed: Vec::new(),
memories_consolidated: 0,
memories_pruned: 0,
errors: vec![error.into()],
duration_secs: 0,
}
}
pub fn with_phase(mut self, phase: DreamPhase) -> Self {
self.phases_completed.push(phase);
self
}
pub fn with_consolidated(mut self, count: usize) -> Self {
self.memories_consolidated = count;
self
}
pub fn with_pruned(mut self, count: usize) -> Self {
self.memories_pruned = count;
self
}
}
pub fn generate_consolidation_prompt(memories: &[MemoryEntry]) -> String {
let memory_text = memories
.iter()
.map(|m| {
if let Some(ref date) = m.date {
format!("- [{}] {}", date, m.content)
} else {
format!("- {}", m.content)
}
})
.collect::<Vec<_>>()
.join("\n");
format!(
r#"You are a memory consolidation assistant. Your task is to:
1. Merge similar memories into single, more comprehensive entries
2. Convert relative dates to absolute dates (YYYY-MM-DD format)
3. Delete or flag contradicted facts (keep the most recent)
4. Resolve conflicts by keeping the most specific and accurate information
Input memories:
{}
Consolidation rules:
- Group related facts together
- Remove duplicates
- Update outdated information
- Keep user preferences intact
- Preserve architecture decisions
Output format:
## Facts (consolidated)
- [YYYY-MM-DD] Consolidated fact here
- [YYYY-MM-DD] Another consolidated fact
## Preferences (user-defined)
- Preference 1
- Preference 2
## Architecture Decisions
- Decision 1 with rationale
Only output the consolidated memory content, no explanations."#,
memory_text
)
}
#[derive(Debug, Clone)]
pub struct DreamStatus {
pub last_dream_timestamp: Option<u64>,
pub sessions_since_last_dream: usize,
pub dream_count: usize,
pub hours_until_next: u64,
pub sessions_until_next: usize,
pub is_running: bool,
}
#[cfg(test)]
#[path = "../../tests/unit/cognitive/dream/dream_test.rs"]
mod tests;