Skip to main content

telos_agent/knowledge/memory/index/
maintenance.rs

1use std::collections::HashSet;
2
3use serde::Serialize;
4
5use crate::knowledge::memory::format::{MemoryCategory, MemoryEntry, MemoryStatus};
6use crate::knowledge::memory::index::MemoryStore;
7use crate::knowledge::memory::index::query::status_rank;
8
9/// Conservative cleanup knobs for persistent memory.
10#[derive(Debug, Clone)]
11pub struct MemoryMaintenancePolicy {
12    /// Keep at most this many active auto-learned command memories.
13    pub max_auto_learned_commands: Option<usize>,
14    /// Keep at most this many active memories overall.
15    pub max_active_entries: Option<usize>,
16    /// Move deprecated memories out of the active index.
17    pub archive_deprecated: bool,
18}
19
20impl Default for MemoryMaintenancePolicy {
21    fn default() -> Self {
22        Self {
23            max_auto_learned_commands: Some(20),
24            max_active_entries: None,
25            archive_deprecated: true,
26        }
27    }
28}
29
30#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
31#[serde(rename_all = "snake_case")]
32pub enum MemoryMaintenanceActionKind {
33    Archive,
34}
35
36#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
37pub struct MemoryMaintenanceAction {
38    pub name: String,
39    pub action: MemoryMaintenanceActionKind,
40    pub reason: String,
41}
42
43#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
44pub struct MemoryMaintenanceReport {
45    pub applied: bool,
46    pub active_before: usize,
47    pub archived_count: usize,
48    pub actions: Vec<MemoryMaintenanceAction>,
49}
50
51impl MemoryStore {
52    /// Build a dry-run report of memories that would be archived by the policy.
53    pub fn maintenance_report(&self, policy: &MemoryMaintenancePolicy) -> MemoryMaintenanceReport {
54        let mut entries: Vec<MemoryEntry> =
55            self.index.keys().filter_map(|name| self.read(name)).collect();
56        entries.sort_by(|a, b| a.name.cmp(&b.name));
57
58        let mut actions = Vec::new();
59        let mut selected = HashSet::new();
60
61        if policy.archive_deprecated {
62            for entry in entries.iter().filter(|entry| entry.status == MemoryStatus::Deprecated) {
63                push_archive_action(&mut actions, &mut selected, entry, "status is deprecated");
64            }
65        }
66
67        if let Some(max) = policy.max_auto_learned_commands {
68            let mut commands: Vec<&MemoryEntry> =
69                entries.iter().filter(|entry| is_auto_learned_command(entry)).collect();
70            if commands.len() > max {
71                commands.sort_by(|a, b| {
72                    a.times_used
73                        .cmp(&b.times_used)
74                        .then_with(|| {
75                            updated_sort_value(&a.updated).cmp(&updated_sort_value(&b.updated))
76                        })
77                        .then_with(|| a.updated.cmp(&b.updated))
78                        .then_with(|| a.name.cmp(&b.name))
79                });
80                let overflow = commands.len().saturating_sub(max);
81                for entry in commands.into_iter().take(overflow) {
82                    push_archive_action(
83                        &mut actions,
84                        &mut selected,
85                        entry,
86                        "exceeds auto-learned command retention limit",
87                    );
88                }
89            }
90        }
91
92        if let Some(max) = policy.max_active_entries {
93            let remaining_active = entries.len().saturating_sub(selected.len());
94            if remaining_active > max {
95                let mut candidates: Vec<&MemoryEntry> =
96                    entries.iter().filter(|entry| !selected.contains(&entry.name)).collect();
97                candidates.sort_by(|a, b| {
98                    status_rank(&b.status)
99                        .cmp(&status_rank(&a.status))
100                        .then_with(|| a.times_used.cmp(&b.times_used))
101                        .then_with(|| {
102                            updated_sort_value(&a.updated).cmp(&updated_sort_value(&b.updated))
103                        })
104                        .then_with(|| a.updated.cmp(&b.updated))
105                        .then_with(|| a.name.cmp(&b.name))
106                });
107                for entry in candidates.into_iter().take(remaining_active - max) {
108                    push_archive_action(
109                        &mut actions,
110                        &mut selected,
111                        entry,
112                        "exceeds active memory retention limit",
113                    );
114                }
115            }
116        }
117
118        MemoryMaintenanceReport {
119            applied: false,
120            active_before: entries.len(),
121            archived_count: 0,
122            actions,
123        }
124    }
125
126    /// Apply a maintenance policy by archiving the report's candidate memories.
127    pub fn apply_maintenance(
128        &mut self,
129        policy: &MemoryMaintenancePolicy,
130    ) -> std::io::Result<MemoryMaintenanceReport> {
131        let mut report = self.maintenance_report(policy);
132        let actions = report.actions.clone();
133        let mut archived = 0;
134        for action in actions {
135            match action.action {
136                MemoryMaintenanceActionKind::Archive => {
137                    self.archive(&action.name)?;
138                    archived += 1;
139                }
140            }
141        }
142        report.applied = true;
143        report.archived_count = archived;
144        Ok(report)
145    }
146}
147
148fn push_archive_action(
149    actions: &mut Vec<MemoryMaintenanceAction>,
150    selected: &mut HashSet<String>,
151    entry: &MemoryEntry,
152    reason: &str,
153) {
154    if selected.insert(entry.name.clone()) {
155        actions.push(MemoryMaintenanceAction {
156            name: entry.name.clone(),
157            action: MemoryMaintenanceActionKind::Archive,
158            reason: reason.to_string(),
159        });
160    }
161}
162
163fn is_auto_learned_command(entry: &MemoryEntry) -> bool {
164    entry.category == MemoryCategory::Command
165        && entry.status == MemoryStatus::Working
166        && entry.tags.iter().any(|tag| tag.eq_ignore_ascii_case("auto-learned"))
167}
168
169fn updated_sort_value(value: &str) -> u64 {
170    value.parse().unwrap_or(0)
171}