1use roboticus_core::config::DigestConfig;
2use roboticus_db::Database;
3use roboticus_db::sessions::{self, Session};
4use tracing::{debug, info};
5
6#[derive(Debug, Clone)]
8pub struct EpisodicDigest {
9 pub session_id: String,
10 pub agent_id: String,
11 pub summary: String,
12 pub key_topics: Vec<String>,
13 pub turn_count: i64,
14 pub importance: i32,
15}
16
17impl EpisodicDigest {
18 pub fn from_session(db: &Database, session: &Session) -> Option<Self> {
20 let messages = sessions::list_messages(db, &session.id, None)
21 .inspect_err(|e| tracing::warn!(error = %e, session_id = %session.id, "failed to list messages for digest"))
22 .ok()?;
23 if messages.is_empty() {
24 return None;
25 }
26
27 let mut topics = Vec::new();
28 let mut summary_parts = Vec::new();
29 let turn_count = messages.len() as i64;
30
31 for msg in &messages {
32 if msg.role == "user" {
33 let first_line = msg.content.lines().next().unwrap_or("").trim();
34 if !first_line.is_empty() && first_line.len() < 200 {
35 topics.push(first_line.to_string());
36 }
37 }
38 }
39 topics.truncate(5);
40
41 if let Some(first_user) = messages.iter().find(|m| m.role == "user") {
42 let truncated = truncate_str(&first_user.content, 200);
43 summary_parts.push(format!("Started with: {truncated}"));
44 }
45 if let Some(last_assistant) = messages.iter().rev().find(|m| m.role == "assistant") {
46 let truncated = truncate_str(&last_assistant.content, 200);
47 summary_parts.push(format!("Concluded with: {truncated}"));
48 }
49 summary_parts.push(format!("Total turns: {turn_count}"));
50
51 let importance = calculate_importance(turn_count, topics.len());
52
53 Some(EpisodicDigest {
54 session_id: session.id.clone(),
55 agent_id: session.agent_id.clone(),
56 summary: summary_parts.join(". "),
57 key_topics: topics,
58 turn_count,
59 importance,
60 })
61 }
62
63 pub fn persist(&self, db: &Database) -> roboticus_core::Result<String> {
65 let content = format!(
66 "[Session Digest] {}\nTopics: {}\nTurns: {}",
67 self.summary,
68 self.key_topics.join(", "),
69 self.turn_count,
70 );
71 let digest_id = roboticus_db::memory::store_episodic_with_meta(
72 db,
73 "digest",
74 &content,
75 self.importance,
76 Some(&self.agent_id),
77 "active",
78 None,
79 )?;
80 let _ = roboticus_db::memory::mark_episodic_digests_stale_for_owner(
81 db,
82 &self.agent_id,
83 &digest_id,
84 "superseded_by_newer_digest",
85 );
86 Ok(digest_id)
87 }
88}
89
90fn calculate_importance(turn_count: i64, topic_count: usize) -> i32 {
92 let base = 5i32;
93 let turn_bonus = (turn_count as i32 / 5).min(3);
94 let topic_bonus = (topic_count as i32).min(2);
95 (base + turn_bonus + topic_bonus).min(10)
96}
97
98pub fn decay_importance(original_importance: i32, age_days: f64, half_life_days: f64) -> i32 {
100 if half_life_days <= 0.0 {
101 return original_importance;
102 }
103 let decay_factor = (0.5_f64).powf(age_days / half_life_days);
104 let decayed = (original_importance as f64 * decay_factor).round() as i32;
105 decayed.max(1)
106}
107
108fn truncate_str(s: &str, max_len: usize) -> String {
109 if s.len() <= max_len {
110 s.to_string()
111 } else {
112 let boundary = s
113 .char_indices()
114 .take_while(|&(i, _)| i < max_len)
115 .last()
116 .map(|(i, c)| i + c.len_utf8())
117 .unwrap_or(0);
118 s[..boundary].to_string()
119 }
120}
121
122pub fn digest_on_close(db: &Database, config: &DigestConfig, session: &Session) {
124 if !config.enabled {
125 debug!(session_id = %session.id, "digest generation disabled");
126 return;
127 }
128
129 match EpisodicDigest::from_session(db, session) {
130 Some(digest) => match digest.persist(db) {
131 Ok(id) => info!(
132 digest_id = %id,
133 session_id = %session.id,
134 topics = ?digest.key_topics,
135 importance = digest.importance,
136 "stored episodic digest"
137 ),
138 Err(e) => tracing::error!(error = %e, "failed to persist digest"),
139 },
140 None => debug!(session_id = %session.id, "no content to digest"),
141 }
142}
143
144#[cfg(test)]
145mod tests {
146 use super::*;
147
148 fn test_db() -> Database {
149 Database::new(":memory:").unwrap()
150 }
151
152 #[test]
153 fn empty_session_produces_no_digest() {
154 let db = test_db();
155 let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
156 let session = sessions::get_session(&db, &sid).unwrap().unwrap();
157 let digest = EpisodicDigest::from_session(&db, &session);
158 assert!(digest.is_none());
159 }
160
161 #[test]
162 fn session_with_messages_produces_digest() {
163 let db = test_db();
164 let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
165 sessions::append_message(&db, &sid, "user", "How do I sort a vector in Rust?").unwrap();
166 sessions::append_message(&db, &sid, "assistant", "Use vec.sort() or vec.sort_by()")
167 .unwrap();
168
169 let session = sessions::get_session(&db, &sid).unwrap().unwrap();
170 let digest = EpisodicDigest::from_session(&db, &session).unwrap();
171 assert_eq!(digest.session_id, sid);
172 assert!(!digest.summary.is_empty());
173 assert!(digest.summary.contains("sort"));
174 assert_eq!(digest.turn_count, 2);
175 assert!(!digest.key_topics.is_empty());
176 }
177
178 #[test]
179 fn digest_persist_stores_in_episodic_memory() {
180 let db = test_db();
181 let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
182 sessions::append_message(&db, &sid, "user", "Tell me about Rust").unwrap();
183 sessions::append_message(&db, &sid, "assistant", "Rust is a systems language").unwrap();
184
185 let session = sessions::get_session(&db, &sid).unwrap().unwrap();
186 let digest = EpisodicDigest::from_session(&db, &session).unwrap();
187 let id = digest.persist(&db).unwrap();
188 assert!(!id.is_empty());
189
190 let entries = roboticus_db::memory::retrieve_episodic(&db, 10).unwrap();
191 let found = entries
192 .iter()
193 .any(|e| e.content.contains("[Session Digest]"));
194 assert!(found, "digest should be stored in episodic memory");
195 }
196
197 #[test]
198 fn calculate_importance_base() {
199 assert_eq!(calculate_importance(1, 0), 5);
200 assert_eq!(calculate_importance(5, 1), 7);
201 assert_eq!(calculate_importance(20, 5), 10);
202 }
203
204 #[test]
205 fn decay_importance_halves_at_half_life() {
206 assert_eq!(decay_importance(10, 7.0, 7.0), 5);
207 }
208
209 #[test]
210 fn decay_importance_zero_age_no_change() {
211 assert_eq!(decay_importance(8, 0.0, 7.0), 8);
212 }
213
214 #[test]
215 fn decay_importance_never_below_one() {
216 assert_eq!(decay_importance(2, 100.0, 7.0), 1);
217 }
218
219 #[test]
220 fn decay_importance_zero_half_life_no_decay() {
221 assert_eq!(decay_importance(8, 30.0, 0.0), 8);
222 }
223
224 #[test]
225 fn truncate_str_short() {
226 assert_eq!(truncate_str("hello", 10), "hello");
227 }
228
229 #[test]
230 fn truncate_str_long() {
231 let long = "a".repeat(300);
232 assert!(truncate_str(&long, 200).len() <= 200);
233 }
234
235 #[test]
236 fn digest_on_close_disabled() {
237 let db = test_db();
238 let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
239 sessions::append_message(&db, &sid, "user", "hello").unwrap();
240 let session = sessions::get_session(&db, &sid).unwrap().unwrap();
241
242 let config = DigestConfig {
243 enabled: false,
244 max_tokens: 512,
245 decay_half_life_days: 7,
246 };
247 digest_on_close(&db, &config, &session);
248
249 let entries = roboticus_db::memory::retrieve_episodic(&db, 10).unwrap();
250 let has_digest = entries
251 .iter()
252 .any(|e| e.content.contains("[Session Digest]"));
253 assert!(!has_digest);
254 }
255
256 #[test]
257 fn digest_on_close_enabled() {
258 let db = test_db();
259 let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
260 sessions::append_message(&db, &sid, "user", "hello").unwrap();
261 sessions::append_message(&db, &sid, "assistant", "hi!").unwrap();
262 let session = sessions::get_session(&db, &sid).unwrap().unwrap();
263
264 let config = DigestConfig::default();
265 digest_on_close(&db, &config, &session);
266
267 let entries = roboticus_db::memory::retrieve_episodic(&db, 10).unwrap();
268 let has_digest = entries
269 .iter()
270 .any(|e| e.content.contains("[Session Digest]"));
271 assert!(has_digest);
272 }
273
274 #[test]
275 fn topics_limited_to_five() {
276 let db = test_db();
277 let sid = sessions::find_or_create(&db, "agent-1", None).unwrap();
278 for i in 0..10 {
279 sessions::append_message(&db, &sid, "user", &format!("Topic {i}")).unwrap();
280 sessions::append_message(&db, &sid, "assistant", "response").unwrap();
281 }
282 let session = sessions::get_session(&db, &sid).unwrap().unwrap();
283 let digest = EpisodicDigest::from_session(&db, &session).unwrap();
284 assert!(digest.key_topics.len() <= 5);
285 }
286}