use std::sync::Arc;
use async_trait::async_trait;
use cel_memory::{CallerScope, ChunkKind, MemoryProvider, MemoryQuery, RetrievalProfile};
use crate::source::{Contribution, ContributionContent, Source, SourceError};
use crate::types::{BriefContext, Priority, Role, SourceId};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum MemoryQueryStrategy {
#[default]
UserMessageThenGoal,
UserMessageOnly,
GoalOnly,
}
impl MemoryQueryStrategy {
fn extract<'a>(&self, ctx: &'a BriefContext) -> Option<&'a str> {
match self {
MemoryQueryStrategy::UserMessageThenGoal => ctx
.user_message
.as_deref()
.or(ctx.goal.as_deref())
.or(Some("")),
MemoryQueryStrategy::UserMessageOnly => ctx.user_message.as_deref(),
MemoryQueryStrategy::GoalOnly => ctx.goal.as_deref(),
}
}
}
pub type QueryTextExtractor = Arc<dyn for<'a> Fn(&'a BriefContext) -> Option<String> + Send + Sync>;
pub struct MemorySource {
id: SourceId,
provider: Arc<dyn MemoryProvider>,
caller_id: String,
k: usize,
strategy: MemoryQueryStrategy,
profile: RetrievalProfile,
caller_scope: CallerScope,
kinds: Option<Vec<ChunkKind>>,
custom_extractor: Option<QueryTextExtractor>,
}
impl std::fmt::Debug for MemorySource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MemorySource")
.field("id", &self.id)
.field("caller_id", &self.caller_id)
.field("k", &self.k)
.field("strategy", &self.strategy)
.field("profile", &self.profile)
.field("caller_scope", &self.caller_scope)
.field("kinds", &self.kinds)
.field(
"custom_extractor",
&self.custom_extractor.as_ref().map(|_| "<fn>"),
)
.finish()
}
}
impl MemorySource {
pub fn new(provider: Arc<dyn MemoryProvider>, caller_id: impl Into<String>, k: usize) -> Self {
MemorySource {
id: SourceId::new("memory"),
provider,
caller_id: caller_id.into(),
k,
strategy: MemoryQueryStrategy::default(),
profile: RetrievalProfile::default(),
caller_scope: CallerScope::default(),
kinds: None,
custom_extractor: None,
}
}
pub fn with_id(mut self, id: impl Into<SourceId>) -> Self {
self.id = id.into();
self
}
pub fn with_strategy(mut self, strategy: MemoryQueryStrategy) -> Self {
self.strategy = strategy;
self.custom_extractor = None;
self
}
pub fn with_query_text_extractor<F>(mut self, extractor: F) -> Self
where
F: for<'a> Fn(&'a BriefContext) -> Option<String> + Send + Sync + 'static,
{
self.custom_extractor = Some(Arc::new(extractor));
self
}
pub fn with_profile(mut self, profile: RetrievalProfile) -> Self {
self.profile = profile;
self
}
pub fn with_caller_scope(mut self, scope: CallerScope) -> Self {
self.caller_scope = scope;
self
}
pub fn with_kinds(mut self, kinds: Option<Vec<ChunkKind>>) -> Self {
self.kinds = kinds;
self
}
fn build_query_text(&self, ctx: &BriefContext) -> Option<String> {
if let Some(extractor) = &self.custom_extractor {
return extractor(ctx);
}
self.strategy.extract(ctx).map(|s| s.to_owned())
}
}
#[async_trait]
impl Source for MemorySource {
fn id(&self) -> SourceId {
self.id.clone()
}
fn priority(&self) -> Priority {
Priority::Normal
}
async fn contribute(&self, ctx: &BriefContext) -> Result<Vec<Contribution>, SourceError> {
if self.k == 0 {
return Err(SourceError::Skipped("k is 0".into()));
}
let Some(text) = self.build_query_text(ctx) else {
return Err(SourceError::Skipped("no query text".into()));
};
let query = MemoryQuery {
text,
kinds: self.kinds.clone(),
since: None,
until: None,
session_id: None,
caller_scope: self.caller_scope,
project_root_prefix: None,
k: self.k,
include_rollups: true,
min_importance: None,
profile: self.profile,
caller_id: self.caller_id.clone(),
};
let chunks = self
.provider
.retrieve(query)
.await
.map_err(|e| SourceError::Backend(format!("memory retrieve: {e}")))?;
if chunks.is_empty() {
return Err(SourceError::Skipped("no matching memories".into()));
}
Ok(chunks
.into_iter()
.map(|chunk| {
let est = chunk.content.len().div_ceil(4);
let prefix = format!("[memory:{:?}] ", chunk.kind).to_lowercase();
let body = format!("{prefix}{}", chunk.content);
let importance = chunk.importance.clamp(0.0, 1.0).max(0.3);
Contribution {
content: ContributionContent::Text {
role: Role::System,
content: body,
},
estimated_tokens: est + prefix.len().div_ceil(4),
importance,
redactable: true,
tags: vec!["memory".into()],
}
})
.collect())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::TokenBudget;
use cel_memory::{BasicMemoryProvider, ChunkSource, NewMemoryChunk, NewMemorySession};
use serde_json::json;
async fn seeded_provider() -> Arc<BasicMemoryProvider> {
let p = Arc::new(BasicMemoryProvider::new());
let session = p
.open_session(NewMemorySession {
caller_id: "test".into(),
title: None,
metadata: json!(null),
})
.await
.expect("open session");
for content in [
"User prefers dry-run mode",
"Q4 report under ~/Workspace/q4.md",
"Last action: copy draft.md to Workspace",
] {
p.write(NewMemoryChunk {
kind: ChunkKind::Chat,
source: ChunkSource::Embedded,
session_id: Some(session.id.clone()),
project_root: None,
caller_id: "test".into(),
content: content.into(),
metadata: json!(null),
importance: Some(0.5),
shareable: false,
pinned: false,
})
.await
.expect("write");
}
p
}
#[tokio::test]
async fn retrieves_chunks_under_default_strategy() {
let provider = seeded_provider().await;
let src = MemorySource::new(provider, "test", 5);
let ctx = BriefContext::new(TokenBudget::default()).with_user_message("dry-run");
let cs = src.contribute(&ctx).await.expect("ok");
assert!(!cs.is_empty());
for c in &cs {
assert!(c.redactable);
assert_eq!(c.tags, vec!["memory".to_owned()]);
match &c.content {
ContributionContent::Text { role, content } => {
assert_eq!(*role, Role::System);
assert!(content.starts_with("[memory:"));
}
other => panic!("expected Text, got {other:?}"),
}
}
}
#[tokio::test]
async fn skipped_when_no_match() {
let provider = seeded_provider().await;
let src = MemorySource::new(provider, "test", 5);
let ctx = BriefContext::new(TokenBudget::default())
.with_user_message("nothing-matches-this-token-xyz-pqr");
match src.contribute(&ctx).await {
Err(SourceError::Skipped(_)) => {}
other => panic!("expected Skipped, got {other:?}"),
}
}
#[tokio::test]
async fn skipped_when_k_is_zero() {
let provider = seeded_provider().await;
let src = MemorySource::new(provider, "test", 0);
let ctx = BriefContext::new(TokenBudget::default()).with_user_message("dry-run");
match src.contribute(&ctx).await {
Err(SourceError::Skipped(_)) => {}
other => panic!("expected Skipped, got {other:?}"),
}
}
#[tokio::test]
async fn user_message_only_strategy_skips_without_message() {
let provider = seeded_provider().await;
let src = MemorySource::new(provider, "test", 5)
.with_strategy(MemoryQueryStrategy::UserMessageOnly);
let ctx = BriefContext::new(TokenBudget::default()).with_goal("ship it");
match src.contribute(&ctx).await {
Err(SourceError::Skipped(_)) => {}
other => panic!("expected Skipped, got {other:?}"),
}
}
#[tokio::test]
async fn goal_only_strategy_uses_goal() {
let provider = seeded_provider().await;
let src =
MemorySource::new(provider, "test", 5).with_strategy(MemoryQueryStrategy::GoalOnly);
let ctx = BriefContext::new(TokenBudget::default()).with_goal("dry-run");
let cs = src.contribute(&ctx).await.expect("ok");
assert!(!cs.is_empty());
}
#[tokio::test]
async fn user_then_goal_falls_back_to_goal() {
let provider = seeded_provider().await;
let src = MemorySource::new(provider, "test", 5);
let ctx = BriefContext::new(TokenBudget::default()).with_goal("dry-run");
let cs = src.contribute(&ctx).await.expect("ok");
assert!(!cs.is_empty());
}
#[tokio::test]
async fn custom_extractor_overrides_strategy() {
let provider = seeded_provider().await;
let src = MemorySource::new(provider, "test", 5)
.with_strategy(MemoryQueryStrategy::UserMessageOnly)
.with_query_text_extractor(|_| Some("Q4".into()));
let ctx = BriefContext::new(TokenBudget::default());
let cs = src.contribute(&ctx).await.expect("ok");
assert!(!cs.is_empty());
}
#[tokio::test]
async fn priority_and_id_defaults() {
let provider = seeded_provider().await;
let src = MemorySource::new(provider, "test", 5);
assert_eq!(src.priority(), Priority::Normal);
assert_eq!(src.id(), SourceId::new("memory"));
}
#[tokio::test]
async fn with_id_overrides_default() {
let provider = seeded_provider().await;
let src = MemorySource::new(provider, "test", 5).with_id("long_term");
assert_eq!(src.id(), SourceId::new("long_term"));
}
}