1use std::sync::Arc;
29use std::time::Duration;
30
31use tokio_util::sync::CancellationToken;
32use zeph_db::sql;
33use zeph_llm::any::AnyProvider;
34use zeph_llm::provider::{LlmProvider as _, Message, MessageMetadata, Role};
35
36pub use zeph_config::memory::OpticalForgettingConfig;
37
38use crate::error::MemoryError;
39use crate::store::SqliteStore;
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
55#[non_exhaustive]
56pub enum ContentFidelity {
57 Full,
59 Compressed,
61 SummaryOnly,
63}
64
65impl ContentFidelity {
66 #[must_use]
68 pub fn as_str(self) -> &'static str {
69 match self {
70 Self::Full => "Full",
71 Self::Compressed => "Compressed",
72 Self::SummaryOnly => "SummaryOnly",
73 }
74 }
75}
76
77impl std::fmt::Display for ContentFidelity {
78 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79 f.write_str(self.as_str())
80 }
81}
82
83impl std::str::FromStr for ContentFidelity {
84 type Err = String;
85 fn from_str(s: &str) -> Result<Self, Self::Err> {
86 match s {
87 "Full" => Ok(Self::Full),
88 "Compressed" => Ok(Self::Compressed),
89 "SummaryOnly" => Ok(Self::SummaryOnly),
90 other => Err(format!("unknown content_fidelity: {other}")),
91 }
92 }
93}
94
95#[derive(Debug, Default)]
99pub struct OpticalForgettingResult {
100 pub compressed: u32,
102 pub summarized: u32,
104 pub skipped: u32,
106}
107
108pub async fn start_optical_forgetting_loop(
117 store: Arc<SqliteStore>,
118 provider: AnyProvider,
119 config: OpticalForgettingConfig,
120 forgetting_floor: f32,
121 cancel: CancellationToken,
122) {
123 if !config.enabled {
124 tracing::debug!("optical forgetting disabled (optical_forgetting.enabled = false)");
125 return;
126 }
127
128 let provider = Arc::new(provider);
129 let mut ticker = tokio::time::interval(Duration::from_secs(config.sweep_interval_secs));
130 ticker.tick().await; loop {
133 tokio::select! {
134 () = cancel.cancelled() => {
135 tracing::debug!("optical forgetting loop shutting down");
136 return;
137 }
138 _ = ticker.tick() => {}
139 }
140
141 tracing::debug!("optical_forgetting: starting sweep");
142 let start = std::time::Instant::now();
143
144 match run_optical_forgetting_sweep(&store, &provider, &config, forgetting_floor).await {
145 Ok(r) => {
146 tracing::info!(
147 compressed = r.compressed,
148 summarized = r.summarized,
149 skipped = r.skipped,
150 elapsed_ms = start.elapsed().as_millis(),
151 "optical_forgetting: sweep complete"
152 );
153 }
154 Err(e) => {
155 tracing::warn!(
156 error = %e,
157 elapsed_ms = start.elapsed().as_millis(),
158 "optical_forgetting: sweep failed, will retry"
159 );
160 }
161 }
162 }
163}
164
165#[tracing::instrument(name = "memory.optical_forgetting", skip_all)]
179pub async fn run_optical_forgetting_sweep(
180 store: &SqliteStore,
181 provider: &Arc<AnyProvider>,
182 config: &OpticalForgettingConfig,
183 forgetting_floor: f32,
184) -> Result<OpticalForgettingResult, MemoryError> {
185 let mut result = OpticalForgettingResult::default();
186
187 let full_candidates = fetch_full_candidates(store, config, forgetting_floor).await?;
189 for (msg_id, content) in full_candidates {
190 match compress_content(provider, &content).await {
191 Ok(compressed) => {
192 store_compressed(store, msg_id, &compressed).await?;
193 result.compressed += 1;
194 tracing::debug!(msg_id, "optical_forgetting: Full → Compressed");
195 }
196 Err(e) => {
197 tracing::warn!(error = %e, msg_id, "optical_forgetting: compression failed, skipping");
198 result.skipped += 1;
199 }
200 }
201 }
202
203 let compressed_candidates =
205 fetch_compressed_candidates(store, config, forgetting_floor).await?;
206 for (msg_id, compressed_content) in compressed_candidates {
207 match summarize_content(provider, &compressed_content).await {
208 Ok(summary) => {
209 store_summary_only(store, msg_id, &summary).await?;
210 result.summarized += 1;
211 tracing::debug!(msg_id, "optical_forgetting: Compressed → SummaryOnly");
212 }
213 Err(e) => {
214 tracing::warn!(error = %e, msg_id, "optical_forgetting: summarization failed, skipping");
215 result.skipped += 1;
216 }
217 }
218 }
219
220 Ok(result)
221}
222
223async fn fetch_full_candidates(
229 store: &SqliteStore,
230 config: &OpticalForgettingConfig,
231 forgetting_floor: f32,
232) -> Result<Vec<(i64, String)>, MemoryError> {
233 let rows = zeph_db::query_as::<_, (i64, String)>(sql!(
241 "SELECT id, content FROM messages
242 WHERE content_fidelity = 'Full'
243 AND deleted_at IS NULL
244 AND (importance_score IS NULL OR importance_score >= ?)
245 AND id <= (SELECT COALESCE(MAX(id), 0) - ? FROM messages)
246 ORDER BY id ASC
247 LIMIT ?"
248 ))
249 .bind(forgetting_floor)
250 .bind(i64::from(config.compress_after_turns))
251 .bind(i64::try_from(config.sweep_batch_size).unwrap_or(i64::MAX))
252 .fetch_all(store.pool())
253 .await?;
254
255 Ok(rows)
256}
257
258async fn fetch_compressed_candidates(
260 store: &SqliteStore,
261 config: &OpticalForgettingConfig,
262 forgetting_floor: f32,
263) -> Result<Vec<(i64, String)>, MemoryError> {
264 let rows = zeph_db::query_as::<_, (i64, Option<String>)>(sql!(
265 "SELECT id, compressed_content FROM messages
266 WHERE content_fidelity = 'Compressed'
267 AND deleted_at IS NULL
268 AND (importance_score IS NULL OR importance_score >= ?)
269 AND id <= (SELECT COALESCE(MAX(id), 0) - ? FROM messages)
270 ORDER BY id ASC
271 LIMIT ?"
272 ))
273 .bind(forgetting_floor)
274 .bind(i64::from(config.summarize_after_turns))
275 .bind(i64::try_from(config.sweep_batch_size).unwrap_or(i64::MAX))
276 .fetch_all(store.pool())
277 .await?;
278
279 Ok(rows
280 .into_iter()
281 .filter_map(|(id, content)| content.map(|c| (id, c)))
282 .collect())
283}
284
285async fn store_compressed(
287 store: &SqliteStore,
288 msg_id: i64,
289 compressed: &str,
290) -> Result<(), MemoryError> {
291 zeph_db::query(sql!(
292 "UPDATE messages
293 SET content_fidelity = 'Compressed', compressed_content = ?
294 WHERE id = ?"
295 ))
296 .bind(compressed)
297 .bind(msg_id)
298 .execute(store.pool())
299 .await?;
300 Ok(())
301}
302
303async fn store_summary_only(
305 store: &SqliteStore,
306 msg_id: i64,
307 summary: &str,
308) -> Result<(), MemoryError> {
309 zeph_db::query(sql!(
310 "UPDATE messages
311 SET content_fidelity = 'SummaryOnly', content = ?, compressed_content = NULL
312 WHERE id = ?"
313 ))
314 .bind(summary)
315 .bind(msg_id)
316 .execute(store.pool())
317 .await?;
318 Ok(())
319}
320
321#[tracing::instrument(name = "memory.optical_forgetting.compress", skip_all, err)]
325async fn compress_content(
326 provider: &Arc<AnyProvider>,
327 content: &str,
328) -> Result<String, MemoryError> {
329 let cleaned = zeph_common::sanitize::strip_control_chars_preserve_whitespace(content);
330 let snippet = cleaned.chars().take(2000).collect::<String>();
331 let messages = vec![
332 Message {
333 role: Role::System,
334 content: "You compress conversation messages into concise summaries that preserve \
335 all key facts, decisions, and action items. Output only the summary text, \
336 no preamble."
337 .to_owned(),
338 parts: vec![],
339 metadata: MessageMetadata::default(),
340 },
341 Message {
342 role: Role::User,
343 content: format!("Compress this message:\n\n{snippet}"),
344 parts: vec![],
345 metadata: MessageMetadata::default(),
346 },
347 ];
348
349 let raw = tokio::time::timeout(Duration::from_secs(15), provider.chat(&messages))
350 .await
351 .map_err(|_| MemoryError::Timeout("optical_forgetting: compress timed out".into()))?
352 .map_err(MemoryError::Llm)?;
353
354 Ok(raw.trim().to_owned())
355}
356
357#[tracing::instrument(name = "memory.optical_forgetting.summarize", skip_all, err)]
359async fn summarize_content(
360 provider: &Arc<AnyProvider>,
361 content: &str,
362) -> Result<String, MemoryError> {
363 let cleaned = zeph_common::sanitize::strip_control_chars_preserve_whitespace(content);
364 let snippet = cleaned.chars().take(1000).collect::<String>();
365 let messages = vec![
366 Message {
367 role: Role::System,
368 content: "You distill summaries into single sentences that capture the essential \
369 fact or outcome. Output only the one-sentence summary, no preamble."
370 .to_owned(),
371 parts: vec![],
372 metadata: MessageMetadata::default(),
373 },
374 Message {
375 role: Role::User,
376 content: format!("Distill into one sentence:\n\n{snippet}"),
377 parts: vec![],
378 metadata: MessageMetadata::default(),
379 },
380 ];
381
382 let raw = tokio::time::timeout(Duration::from_secs(10), provider.chat(&messages))
383 .await
384 .map_err(|_| MemoryError::Timeout("optical_forgetting: summarize timed out".into()))?
385 .map_err(MemoryError::Llm)?;
386
387 Ok(raw.trim().to_owned())
388}
389
390#[cfg(test)]
393mod tests {
394 use super::*;
395 use zeph_config::providers::ProviderName;
396
397 #[test]
398 fn content_fidelity_round_trip() {
399 for fidelity in [
400 ContentFidelity::Full,
401 ContentFidelity::Compressed,
402 ContentFidelity::SummaryOnly,
403 ] {
404 let s = fidelity.as_str();
405 let parsed: ContentFidelity = s.parse().expect("should parse");
406 assert_eq!(parsed, fidelity);
407 assert_eq!(format!("{fidelity}"), s);
408 }
409 }
410
411 #[test]
412 fn content_fidelity_unknown_string_errors() {
413 assert!("unknown".parse::<ContentFidelity>().is_err());
414 }
415
416 #[test]
417 fn optical_forgetting_config_defaults() {
418 let cfg = OpticalForgettingConfig::default();
419 assert!(!cfg.enabled);
420 assert_eq!(cfg.compress_after_turns, 100);
421 assert_eq!(cfg.summarize_after_turns, 500);
422 assert_eq!(cfg.sweep_interval_secs, 3600);
423 assert_eq!(cfg.sweep_batch_size, 50);
424 }
425
426 #[test]
427 fn optical_forgetting_result_default() {
428 let r = OpticalForgettingResult::default();
429 assert_eq!(r.compressed, 0);
430 assert_eq!(r.summarized, 0);
431 assert_eq!(r.skipped, 0);
432 }
433
434 #[tokio::test]
437 async fn sweep_skips_when_no_candidates_old_enough() {
438 use std::sync::Arc;
439
440 use zeph_llm::any::AnyProvider;
441 use zeph_llm::mock::MockProvider;
442
443 use crate::store::SqliteStore;
444
445 let store = Arc::new(
446 SqliteStore::new(":memory:")
447 .await
448 .expect("SqliteStore::new"),
449 );
450 let provider = Arc::new(AnyProvider::Mock(MockProvider::default()));
451
452 let cid = store.create_conversation().await.expect("conversation");
453 store
454 .save_message(cid, "user", "hello")
455 .await
456 .expect("save_message");
457
458 let config = OpticalForgettingConfig {
459 enabled: true,
460 compress_after_turns: 100, summarize_after_turns: 500,
462 sweep_interval_secs: 3600,
463 sweep_batch_size: 50,
464 compress_provider: ProviderName::default(),
465 };
466 let result = run_optical_forgetting_sweep(&store, &provider, &config, 0.0)
467 .await
468 .expect("sweep");
469
470 assert_eq!(
471 result.compressed, 0,
472 "no message should be compressed when not old enough"
473 );
474 assert_eq!(result.summarized, 0);
475 }
476
477 #[tokio::test]
480 async fn sweep_compresses_eligible_full_message() {
481 use std::sync::Arc;
482
483 use zeph_llm::any::AnyProvider;
484 use zeph_llm::mock::MockProvider;
485
486 use crate::store::SqliteStore;
487
488 let store = Arc::new(
489 SqliteStore::new(":memory:")
490 .await
491 .expect("SqliteStore::new"),
492 );
493 let mock = MockProvider::with_responses(vec!["compressed summary".to_owned()]);
494 let provider = Arc::new(AnyProvider::Mock(mock));
495
496 let cid = store.create_conversation().await.expect("conversation");
497 store
499 .save_message(cid, "user", "first message")
500 .await
501 .expect("save_message 1");
502 store
503 .save_message(cid, "user", "second message")
504 .await
505 .expect("save_message 2");
506
507 let config = OpticalForgettingConfig {
508 enabled: true,
509 compress_after_turns: 0, summarize_after_turns: 500,
511 sweep_interval_secs: 3600,
512 sweep_batch_size: 50,
513 compress_provider: ProviderName::default(),
514 };
515 let result = run_optical_forgetting_sweep(&store, &provider, &config, 0.0)
516 .await
517 .expect("sweep");
518
519 assert!(
521 result.compressed >= 1,
522 "at least one message must be compressed"
523 );
524 }
525
526 #[tokio::test]
528 async fn sweep_disabled_returns_empty_result() {
529 use std::sync::Arc;
530
531 use zeph_llm::any::AnyProvider;
532 use zeph_llm::mock::MockProvider;
533
534 use crate::store::SqliteStore;
535
536 let store = Arc::new(
537 SqliteStore::new(":memory:")
538 .await
539 .expect("SqliteStore::new"),
540 );
541 let provider = Arc::new(AnyProvider::Mock(MockProvider::default()));
542 let config = OpticalForgettingConfig {
543 enabled: false,
544 ..Default::default()
545 };
546 let result = run_optical_forgetting_sweep(&store, &provider, &config, 0.0)
549 .await
550 .expect("sweep with disabled config");
551 assert_eq!(result.compressed, 0);
552 assert_eq!(result.summarized, 0);
553 }
554}