telos_agent/knowledge/memory/index/
query.rs1use std::cmp::Ordering;
2
3use crate::knowledge::memory::format::{MemoryEntry, MemoryStatus};
4use crate::knowledge::memory::index::MemoryStore;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum MemorySort {
9 Relevance,
10 RecentlyUpdated,
11 MostUsed,
12}
13
14#[derive(Debug, Clone)]
16pub struct MemoryQuery {
17 pub status: Option<MemoryStatus>,
18 pub tags: Vec<String>,
19 pub limit: Option<usize>,
20 pub include_body: bool,
21 pub sort: MemorySort,
22}
23
24impl Default for MemoryQuery {
25 fn default() -> Self {
26 Self {
27 status: None,
28 tags: Vec::new(),
29 limit: None,
30 include_body: true,
31 sort: MemorySort::Relevance,
32 }
33 }
34}
35
36impl MemoryStore {
37 pub fn query(&self, query: MemoryQuery) -> Vec<MemoryEntry> {
39 let tags: Vec<String> = query.tags.iter().map(|tag| tag.to_lowercase()).collect();
40 let mut entries: Vec<MemoryEntry> = self
41 .cache
42 .values()
43 .filter(|entry| {
44 query.status.as_ref().is_none_or(|status| &entry.status == status)
45 && tags.iter().all(|tag| entry.tags.iter().any(|t| t.to_lowercase() == *tag))
46 })
47 .cloned()
48 .collect();
49
50 match query.sort {
51 MemorySort::Relevance => entries.sort_by(|a, b| {
52 status_rank(&a.status)
53 .cmp(&status_rank(&b.status))
54 .then_with(|| {
55 std::cmp::Reverse(a.times_used).cmp(&std::cmp::Reverse(b.times_used))
56 })
57 .then_with(|| b.updated.cmp(&a.updated))
58 .then_with(|| a.name.cmp(&b.name))
59 }),
60 MemorySort::RecentlyUpdated => {
61 entries.sort_by(|a, b| b.updated.cmp(&a.updated).then_with(|| a.name.cmp(&b.name)));
62 }
63 MemorySort::MostUsed => entries.sort_by_key(|b| std::cmp::Reverse(b.times_used)),
64 }
65
66 if let Some(limit) = query.limit {
67 entries.truncate(limit);
68 }
69 if !query.include_body {
70 for entry in &mut entries {
71 entry.body.clear();
72 }
73 }
74 entries
75 }
76
77 pub fn search(&self, query: &str) -> Vec<MemoryEntry> {
80 let query_lower = query.to_lowercase();
81 self.cache
82 .values()
83 .filter(|entry| {
84 entry.name.to_lowercase().contains(&query_lower)
85 || entry.description.to_lowercase().contains(&query_lower)
86 || entry.tags.iter().any(|t| t.to_lowercase().contains(&query_lower))
87 || entry.body.to_lowercase().contains(&query_lower)
88 })
89 .cloned()
90 .collect()
91 }
92
93 pub fn top_by_usage(&self, n: usize) -> Vec<MemoryEntry> {
95 self.query(MemoryQuery {
96 status: Some(MemoryStatus::Working),
97 limit: Some(n),
98 sort: MemorySort::MostUsed,
99 ..MemoryQuery::default()
100 })
101 }
102
103 pub fn search_relevant(
115 &self,
116 query: &str,
117 limit: usize,
118 min_relevance: f64,
119 ) -> Vec<MemoryEntry> {
120 let query_tokens = tokenize(query);
121 if query_tokens.is_empty() || self.cache.is_empty() {
122 return Vec::new();
123 }
124
125 let mut scored: Vec<(f64, &MemoryEntry)> = self
126 .cache
127 .values()
128 .filter(|e| e.status != MemoryStatus::Deprecated)
129 .map(|entry| {
130 let raw_score = compute_relevance(&query_tokens, entry);
131 (raw_score, entry)
132 })
133 .collect();
134
135 scored.retain(|(score, _)| *score >= min_relevance);
137
138 scored.sort_by(|a, b| {
140 b.0.partial_cmp(&a.0).unwrap_or(Ordering::Equal).then_with(|| a.1.name.cmp(&b.1.name))
141 });
142 scored.truncate(limit);
143 scored.into_iter().map(|(_, e)| e.clone()).collect()
144 }
145}
146
147pub(super) fn status_rank(status: &MemoryStatus) -> u8 {
148 match status {
149 MemoryStatus::NeedsFix => 0,
150 MemoryStatus::Working => 1,
151 MemoryStatus::Deprecated => 2,
152 }
153}
154
155fn tokenize(text: &str) -> Vec<String> {
159 text.to_lowercase()
160 .split(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
161 .filter(|s| s.len() > 1)
162 .map(String::from)
163 .collect()
164}
165
166#[allow(dead_code)]
168fn tokenize_entry(entry: &MemoryEntry) -> Vec<String> {
169 let mut tokens = tokenize(&entry.name);
170 tokens.extend(tokenize(&entry.description));
171 for tag in &entry.tags {
172 tokens.extend(tokenize(tag));
173 }
174 tokens
175}
176
177fn token_overlap(query_tokens: &[String], entry_tokens: &[String]) -> f64 {
184 if query_tokens.is_empty() || entry_tokens.is_empty() {
185 return 0.0;
186 }
187 let matches =
188 query_tokens.iter().filter(|qt| entry_tokens.iter().any(|et| token_match(qt, et))).count();
189 matches as f64 / query_tokens.len() as f64
190}
191
192fn token_match(query_token: &str, entry_token: &str) -> bool {
196 if query_token == entry_token {
197 return true;
198 }
199 if query_token.len() < entry_token.len() {
200 let next_byte = entry_token.as_bytes()[query_token.len()];
201 (next_byte == b'-' || next_byte == b'_') && entry_token.starts_with(query_token)
202 } else {
203 false
204 }
205}
206
207fn compute_relevance(query_tokens: &[String], entry: &MemoryEntry) -> f64 {
213 let name_tokens = tokenize(&entry.name);
214 let desc_tokens = tokenize(&entry.description);
215 let tag_tokens: Vec<String> = entry.tags.iter().flat_map(|t| tokenize(t)).collect();
216 let body_tokens = tokenize(&entry.body);
217
218 let name_score = token_overlap(query_tokens, &name_tokens) * 0.35;
219 let desc_score = token_overlap(query_tokens, &desc_tokens) * 0.30;
220 let tag_score = token_overlap(query_tokens, &tag_tokens) * 0.25;
221 let body_score = token_overlap(query_tokens, &body_tokens) * 0.05;
222
223 let status_mult = match entry.status {
224 MemoryStatus::Working => 1.0,
225 MemoryStatus::NeedsFix => 0.5,
226 MemoryStatus::Deprecated => 0.0,
227 };
228
229 let usage_boost = ((entry.times_used as f64 + 1.0).ln() / 10.0).min(0.10);
230
231 (name_score + desc_score + tag_score + body_score) * status_mult + usage_boost
232}