Skip to main content

pe_core/
cognitive_memory.rs

1//! Cognitive memory types — working notes, categories, and memory configuration.
2//!
3//! These types form the agent's scratchpad — structured notes that the agent
4//! writes to itself during execution. Unlike free-text logs, these are
5//! queryable by category, sortable by relevance, and decayable over time.
6
7use serde::{Deserialize, Serialize};
8
9/// A note the agent writes to itself during execution.
10///
11/// Like a human jotting thoughts while working. Notes have a category
12/// for structured queries and a relevance score that can decay over time.
13///
14/// # Example
15///
16/// ```
17/// use pe_core::cognitive_memory::{WorkingNote, NoteCategory};
18///
19/// let note = WorkingNote::new(
20///     "User prefers concise responses",
21///     NoteCategory::Observation,
22/// );
23/// assert_eq!(note.relevance, 1.0);
24/// ```
25#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
26pub struct WorkingNote {
27    /// The note content.
28    pub content: String,
29
30    /// Classification for structured queries.
31    pub category: NoteCategory,
32
33    /// ISO 8601 timestamp when the note was created.
34    pub created_at: String,
35
36    /// Relevance score — 1.0 = just created, decays toward 0.0 over time.
37    pub relevance: f64,
38}
39
40impl WorkingNote {
41    /// Create a new working note with full relevance.
42    pub fn new(content: impl Into<String>, category: NoteCategory) -> Self {
43        Self {
44            content: content.into(),
45            category,
46            created_at: String::new(), // Caller sets timestamp
47            relevance: 1.0,
48        }
49    }
50
51    /// Create a note with a specific timestamp.
52    ///
53    /// If not called, `created_at` will be empty — meaning "unknown creation time".
54    /// Callers are responsible for setting timestamps when recency matters.
55    #[must_use]
56    pub fn with_timestamp(mut self, timestamp: impl Into<String>) -> Self {
57        self.created_at = timestamp.into();
58        self
59    }
60
61    /// Decay the relevance by a factor (0.0 to 1.0).
62    pub fn decay(&mut self, factor: f64) {
63        self.relevance = (self.relevance * factor).max(0.0);
64    }
65}
66
67/// Classification for working notes.
68///
69/// Enables structured queries — a lobe can ask "give me all Concerns"
70/// instead of parsing free text.
71#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
72#[non_exhaustive]
73pub enum NoteCategory {
74    /// "I noticed X" — things observed during execution.
75    Observation,
76    /// "I should try X next" — planned actions.
77    Plan,
78    /// "X might be a problem" — potential issues flagged.
79    Concern,
80    /// "I found that X" — new information discovered.
81    Discovery,
82    /// "Looking back, X worked/didn't work" — lessons learned.
83    Reflection,
84    /// "Come back to X later" — deferred items.
85    Bookmark,
86    /// User-defined category.
87    Custom(String),
88}
89
90/// Configuration for the agent's memory tiers.
91///
92/// Controls how much memory the agent allocates to each tier
93/// and how aggressively to compress when limits are reached.
94///
95/// # Example
96///
97/// ```
98/// use pe_core::cognitive_memory::MemoryConfig;
99///
100/// let config = MemoryConfig::default();
101/// assert_eq!(config.short_term_limit, 8192);
102/// ```
103#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
104pub struct MemoryConfig {
105    /// Max tokens for short-term memory (current conversation).
106    pub short_term_limit: u32,
107
108    /// Max tokens for working memory (session scratchpad).
109    pub working_limit: u32,
110
111    /// Max tokens for long-term memory (cross-session, compressed).
112    pub long_term_limit: u32,
113
114    /// How aggressively to compress (0.0 = keep everything, 1.0 = compress hard).
115    pub compression_ratio: f64,
116}
117
118impl Default for MemoryConfig {
119    fn default() -> Self {
120        Self {
121            short_term_limit: 8192,
122            working_limit: 2048,
123            long_term_limit: 16384,
124            compression_ratio: 0.5,
125        }
126    }
127}
128
129/// Configuration for the meditate (memory consolidation) operation.
130///
131/// Meditate is the agent's "sleep cycle" — it prunes stale notes, decays relevance,
132/// consolidates related notes, and optionally uses an LLM for deeper synthesis.
133/// All fields have sensible defaults for typical agent workloads.
134///
135/// # Example
136///
137/// ```
138/// use pe_core::cognitive_memory::MeditateConfig;
139///
140/// let config = MeditateConfig::default();
141/// assert_eq!(config.prune_threshold, 0.1);
142/// assert!(config.use_llm);
143/// ```
144#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
145pub struct MeditateConfig {
146    /// Relevance threshold below which notes are pruned.
147    /// Notes with `relevance < prune_threshold` are removed during the prune phase.
148    pub prune_threshold: f64,
149
150    /// Decay factor applied to all notes during prune (0.0-1.0).
151    /// Lower values are more aggressive. Applied before the threshold check.
152    pub decay_factor: f64,
153
154    /// Max working notes before meditate auto-triggers (for MeditateLobe).
155    pub max_notes_before_trigger: usize,
156
157    /// Max failure records before meditate auto-triggers.
158    pub max_failures_before_trigger: usize,
159
160    /// Whether to use an LLM for consolidation and indexing phases.
161    /// When false, uses algorithmic fallback (group-by-category, truncate).
162    pub use_llm: bool,
163
164    /// Max notes to keep after consolidation. Excess pruned by relevance.
165    pub max_notes_after_consolidation: usize,
166
167    /// Max failure records to keep after pruning. Oldest resolved go first.
168    pub max_failures_after_prune: usize,
169}
170
171impl Default for MeditateConfig {
172    fn default() -> Self {
173        Self {
174            prune_threshold: 0.1,
175            decay_factor: 0.9,
176            max_notes_before_trigger: 50,
177            max_failures_before_trigger: 20,
178            use_llm: true,
179            max_notes_after_consolidation: 30,
180            max_failures_after_prune: 10,
181        }
182    }
183}
184
185/// Summary of what a meditate operation did.
186///
187/// Returned after each meditate cycle for logging, debugging, and agent
188/// self-awareness. An agent can inspect this to understand how its memory
189/// was reshaped.
190///
191/// # Example
192///
193/// ```
194/// use pe_core::cognitive_memory::MeditateResult;
195///
196/// let result = MeditateResult {
197///     notes_before: 45,
198///     notes_after: 28,
199///     notes_pruned: 12,  // includes threshold + cap removals
200///     notes_merged: 5,
201///     failures_pruned: 3,
202///     constraints_removed: 1,
203///     used_llm: true,
204///     insights_summary: Some("Consolidated 5 observation notes into 2".into()),
205/// };
206/// assert!(result.notes_after <= result.notes_before);
207/// ```
208#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
209pub struct MeditateResult {
210    /// Number of working notes before meditate ran.
211    pub notes_before: usize,
212
213    /// Number of working notes after meditate completed.
214    pub notes_after: usize,
215
216    /// Number of notes removed during prune phase (threshold + cap combined).
217    pub notes_pruned: usize,
218
219    /// Number of notes merged during consolidation.
220    pub notes_merged: usize,
221
222    /// Number of failure records pruned (resolved or stale).
223    pub failures_pruned: usize,
224
225    /// Number of constraints removed (no longer applicable).
226    pub constraints_removed: usize,
227
228    /// Whether the LLM was used for consolidation.
229    pub used_llm: bool,
230
231    /// Optional summary of insights generated during consolidation.
232    pub insights_summary: Option<String>,
233}
234
235#[cfg(test)]
236mod tests {
237    use super::*;
238
239    #[test]
240    fn test_working_note_creation() {
241        let note = WorkingNote::new("test note", NoteCategory::Observation);
242        assert_eq!(note.content, "test note");
243        assert_eq!(note.category, NoteCategory::Observation);
244        assert_eq!(note.relevance, 1.0);
245    }
246
247    #[test]
248    fn test_working_note_with_timestamp() {
249        let note =
250            WorkingNote::new("test", NoteCategory::Plan).with_timestamp("2026-03-23T12:00:00Z");
251        assert_eq!(note.created_at, "2026-03-23T12:00:00Z");
252    }
253
254    #[test]
255    fn test_working_note_decay() {
256        let mut note = WorkingNote::new("test", NoteCategory::Discovery);
257        assert_eq!(note.relevance, 1.0);
258        note.decay(0.9);
259        assert!((note.relevance - 0.9).abs() < f64::EPSILON);
260        note.decay(0.5);
261        assert!((note.relevance - 0.45).abs() < f64::EPSILON);
262    }
263
264    #[test]
265    fn test_decay_floor_at_zero() {
266        let mut note = WorkingNote::new("test", NoteCategory::Concern);
267        note.relevance = 0.01;
268        note.decay(0.0);
269        assert_eq!(note.relevance, 0.0);
270    }
271
272    #[test]
273    fn test_note_category_custom() {
274        let cat = NoteCategory::Custom("legal_flag".into());
275        let json = serde_json::to_string(&cat).unwrap();
276        let back: NoteCategory = serde_json::from_str(&json).unwrap();
277        assert_eq!(back, NoteCategory::Custom("legal_flag".into()));
278    }
279
280    #[test]
281    fn test_memory_config_defaults() {
282        let config = MemoryConfig::default();
283        assert_eq!(config.short_term_limit, 8192);
284        assert_eq!(config.working_limit, 2048);
285        assert_eq!(config.long_term_limit, 16384);
286        assert!((config.compression_ratio - 0.5).abs() < f64::EPSILON);
287    }
288
289    #[test]
290    fn test_working_note_serialization() {
291        let note = WorkingNote::new("important", NoteCategory::Reflection)
292            .with_timestamp("2026-03-23T10:00:00Z");
293        let json = serde_json::to_string(&note).unwrap();
294        let back: WorkingNote = serde_json::from_str(&json).unwrap();
295        assert_eq!(back, note);
296    }
297
298    #[test]
299    fn test_meditate_config_defaults() {
300        let config = MeditateConfig::default();
301        assert!((config.prune_threshold - 0.1).abs() < f64::EPSILON);
302        assert!((config.decay_factor - 0.9).abs() < f64::EPSILON);
303        assert_eq!(config.max_notes_before_trigger, 50);
304        assert_eq!(config.max_failures_before_trigger, 20);
305        assert!(config.use_llm);
306        assert_eq!(config.max_notes_after_consolidation, 30);
307        assert_eq!(config.max_failures_after_prune, 10);
308    }
309
310    #[test]
311    fn test_meditate_config_serialization() {
312        let config = MeditateConfig {
313            prune_threshold: 0.2,
314            decay_factor: 0.8,
315            max_notes_before_trigger: 100,
316            max_failures_before_trigger: 10,
317            use_llm: false,
318            max_notes_after_consolidation: 50,
319            max_failures_after_prune: 5,
320        };
321        let json = serde_json::to_string(&config).unwrap();
322        let back: MeditateConfig = serde_json::from_str(&json).unwrap();
323        assert_eq!(back, config);
324    }
325
326    #[test]
327    fn test_meditate_result_default() {
328        let result = MeditateResult::default();
329        assert_eq!(result.notes_before, 0);
330        assert_eq!(result.notes_after, 0);
331        assert_eq!(result.notes_pruned, 0);
332        assert_eq!(result.notes_merged, 0);
333        assert_eq!(result.failures_pruned, 0);
334        assert_eq!(result.constraints_removed, 0);
335        assert!(!result.used_llm);
336        assert_eq!(result.insights_summary, None);
337    }
338
339    #[test]
340    fn test_meditate_result_serialization() {
341        let result = MeditateResult {
342            notes_before: 45,
343            notes_after: 28,
344            notes_pruned: 12,
345            notes_merged: 5,
346            failures_pruned: 3,
347            constraints_removed: 1,
348            used_llm: true,
349            insights_summary: Some("Consolidated observation notes".into()),
350        };
351        let json = serde_json::to_string(&result).unwrap();
352        let back: MeditateResult = serde_json::from_str(&json).unwrap();
353        assert_eq!(back, result);
354        assert_eq!(
355            back.insights_summary.as_deref(),
356            Some("Consolidated observation notes")
357        );
358    }
359}