use std::collections::HashMap;
use std::sync::{Arc, Mutex};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MemorySnippet {
pub key: String,
pub text: String,
}
pub fn split_snippets(md: &str) -> Vec<MemorySnippet> {
let mut out = Vec::new();
let mut pending_heading: Option<String> = None;
for para in md.split("\n\n") {
let kept: Vec<&str> = para
.lines()
.filter(|l| {
let l = l.trim();
!(l.starts_with("<!--") && l.ends_with("-->"))
})
.collect();
let joined = kept.join("\n");
let t = joined.trim();
if t.is_empty() {
continue;
}
if t.starts_with('#') && !t.contains('\n') {
pending_heading = Some(match pending_heading.take() {
Some(h) => format!("{h}\n{t}"),
None => t.to_owned(),
});
continue;
}
let text = match pending_heading.take() {
Some(h) => format!("{h}\n{t}"),
None => t.to_owned(),
};
out.push(MemorySnippet { key: fnv_key(&text), text });
}
if let Some(h) = pending_heading {
out.push(MemorySnippet { key: fnv_key(&h), text: h });
}
out
}
fn fnv_key(s: &str) -> String {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in s.as_bytes() {
h ^= u64::from(*b);
h = h.wrapping_mul(0x0100_0000_01b3);
}
format!("{h:016x}")
}
pub trait MemoryRetriever: Send + Sync {
fn top_k(&self, query: &str, snippets: &[MemorySnippet], k: usize) -> Vec<MemorySnippet>;
}
pub trait MemoryReranker: Send + Sync {
fn rerank(&self, query: &str, candidates: Vec<MemorySnippet>) -> Vec<MemorySnippet>;
fn observe(&self, query: &str, rewards: &[(MemorySnippet, i8)]);
}
#[derive(Debug, Default, Clone, Copy)]
pub struct IdentityMemoryReranker;
impl MemoryReranker for IdentityMemoryReranker {
fn rerank(&self, _query: &str, candidates: Vec<MemorySnippet>) -> Vec<MemorySnippet> {
candidates
}
fn observe(&self, _query: &str, _rewards: &[(MemorySnippet, i8)]) {}
}
#[derive(Debug, Clone)]
struct Pending {
query: String,
picked: Vec<(String, MemorySnippet)>,
}
pub struct MemoryRecall {
retriever: Arc<dyn MemoryRetriever>,
reranker: Arc<dyn MemoryReranker>,
top_k: usize,
top_m: usize,
pending: Mutex<HashMap<String, Pending>>,
}
pub const RECALL_HEADER: &str = "# Relevant Memory (retrieved)\n\n\
Each entry below has an id like [m1]. If you use an entry in your answer, cite its id in \
square brackets, e.g. [m2]. Do not cite ids you did not use.";
impl MemoryRecall {
pub fn new(retriever: Arc<dyn MemoryRetriever>, reranker: Arc<dyn MemoryReranker>, top_k: usize, top_m: usize) -> Self {
Self { retriever, reranker, top_k: top_k.max(1), top_m: top_m.max(1), pending: Mutex::new(HashMap::new()) }
}
pub fn select(&self, session_key: &str, query: &str, memory: &str) -> Vec<(String, MemorySnippet)> {
let snippets = split_snippets(memory);
if query.trim().is_empty() || snippets.is_empty() {
self.pending.lock().unwrap().remove(session_key);
return Vec::new();
}
let candidates = self.retriever.top_k(query, &snippets, self.top_k);
let ranked = self.reranker.rerank(query, candidates);
let picked: Vec<(String, MemorySnippet)> =
ranked.into_iter().take(self.top_m).enumerate().map(|(i, s)| (format!("m{}", i + 1), s)).collect();
let mut pending = self.pending.lock().unwrap();
if picked.is_empty() {
pending.remove(session_key);
} else {
pending.insert(session_key.to_owned(), Pending { query: query.to_owned(), picked: picked.clone() });
}
picked
}
pub fn render(picked: &[(String, MemorySnippet)]) -> String {
let mut s = String::from(RECALL_HEADER);
for (label, snip) in picked {
s.push_str(&format!("\n\n[{label}] {}", snip.text));
}
s
}
pub fn attribute(&self, session_key: &str, reply: &str) -> String {
let Some(p) = self.pending.lock().unwrap().remove(session_key) else { return reply.to_owned() };
let cited = cited_labels(reply);
let rewards: Vec<(MemorySnippet, i8)> = p
.picked
.iter()
.map(|(label, snip)| (snip.clone(), if cited.iter().any(|c| c == label) { 1 } else { -1 }))
.collect();
self.reranker.observe(&p.query, &rewards);
strip_labels(reply, p.picked.iter().map(|(l, _)| l.as_str()))
}
}
pub fn cited_labels(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let b = text.as_bytes();
let mut i = 0;
while i < b.len() {
if b[i] == b'[' && i + 1 < b.len() && b[i + 1] == b'm' {
let mut j = i + 2;
while j < b.len() && b[j].is_ascii_digit() {
j += 1;
}
if j > i + 2 && j < b.len() && b[j] == b']' {
let label = &text[i + 1..j];
if !out.iter().any(|l| l == label) {
out.push(label.to_owned());
}
i = j + 1;
continue;
}
}
i += 1;
}
out
}
fn strip_labels<'a>(text: &str, labels: impl Iterator<Item = &'a str>) -> String {
let mut s = text.to_owned();
for l in labels {
let tag = format!("[{l}]");
s = s.replace(&format!(" {tag}"), "").replace(&tag, "");
}
s
}
#[cfg(feature = "vector-memory")]
pub struct HashRetriever {
embedder: crate::embeddings::hash_embedder::HashEmbedder,
}
#[cfg(feature = "vector-memory")]
impl Default for HashRetriever {
fn default() -> Self {
Self { embedder: crate::embeddings::hash_embedder::HashEmbedder::default_dimension() }
}
}
#[cfg(feature = "vector-memory")]
impl MemoryRetriever for HashRetriever {
fn top_k(&self, query: &str, snippets: &[MemorySnippet], k: usize) -> Vec<MemorySnippet> {
let q = self.embedder.compute_embedding(query);
let mut scored: Vec<(f32, usize)> = snippets
.iter()
.enumerate()
.map(|(i, s)| {
let e = self.embedder.compute_embedding(&s.text);
(q.iter().zip(&e).map(|(a, b)| a * b).sum::<f32>(), i)
})
.collect();
scored.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal).then(a.1.cmp(&b.1)));
scored.into_iter().take(k).map(|(_, i)| snippets[i].clone()).collect()
}
}
pub fn from_config(cfg: &clawft_types::config::MemoryRecallConfig) -> Option<Arc<MemoryRecall>> {
if !cfg.enabled {
return None;
}
#[cfg(feature = "vector-memory")]
{
let reranker: Arc<dyn MemoryReranker> = default_reranker();
Some(Arc::new(MemoryRecall::new(Arc::new(HashRetriever::default()), reranker, cfg.top_k, cfg.top_m)))
}
#[cfg(not(feature = "vector-memory"))]
{
tracing::warn!("agents.memory_recall is enabled but this build has no retriever (vector-memory); injecting the full MEMORY.md");
None
}
}
#[cfg(all(feature = "vector-memory", feature = "hybrid-rerank"))]
fn default_reranker() -> Arc<dyn MemoryReranker> {
Arc::new(crate::agent::context_router::sona_rerank::SonaSkillReranker::new())
}
#[cfg(all(feature = "vector-memory", not(feature = "hybrid-rerank")))]
fn default_reranker() -> Arc<dyn MemoryReranker> {
Arc::new(IdentityMemoryReranker)
}
#[cfg(test)]
#[path = "memory_recall_tests.rs"]
mod tests;