mod event;
mod isolation;
mod layer;
mod porting;
pub(crate) mod schema;
#[cfg(feature = "test-util")]
mod testutil;
pub use event::{MemoryEvent, MemoryEventKind, MemorySubscription};
pub use isolation::AgentId;
pub use layer::{AgentStats, MemoryLayer, Record};
pub use porting::ImportReport;
#[cfg(feature = "test-util")]
pub use testutil::HashEmbedder;
use basemyai_core::{Embedder, Metric, Store};
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::broadcast;
use uuid::Uuid;
use event::DEFAULT_EVENT_CAPACITY;
use crate::storage::{LibsqlMemoryStore, MemoryStore, NewMemory};
use crate::temporal::Validity;
use crate::{MemoryError, RRF_K, Ranking, Result, now_unix, rrf_fuse};
pub const MAX_TEXT_LEN: usize = 65_536;
const SOURCE_USER: &str = "user";
pub(crate) const SOURCE_CONSOLIDATION: &str = "consolidation";
pub struct Memory {
engine: Arc<LibsqlMemoryStore>,
embedder: Box<dyn Embedder>,
agent: AgentId,
events: broadcast::Sender<MemoryEvent>,
}
impl Memory {
#[must_use]
pub fn new(store: Store, embedder: Box<dyn Embedder>, agent: AgentId) -> Self {
let (events, _) = broadcast::channel(DEFAULT_EVENT_CAPACITY);
Self {
engine: Arc::new(LibsqlMemoryStore::new(store)),
embedder,
agent,
events,
}
}
pub async fn open(store: Store, embedder: Box<dyn Embedder>, agent: AgentId) -> Result<Self> {
if store.path().is_some() && !store.is_encrypted() {
return Err(crate::MemoryError::EncryptionRequired);
}
store.migrate(&schema::schema()).await?;
Ok(Self::new(store, embedder, agent))
}
#[must_use]
pub fn agent(&self) -> &AgentId {
&self.agent
}
#[must_use]
pub fn watch(&self, agent_id: &str, layer: Option<MemoryLayer>) -> MemorySubscription {
MemorySubscription::new(self.events.subscribe(), agent_id.to_string(), layer)
}
fn emit(&self, kind: MemoryEventKind, layer: MemoryLayer, id: &str) {
let _ = self.events.send(MemoryEvent {
agent_id: self.agent.as_str().to_string(),
kind,
layer,
id: id.to_string(),
});
}
pub(crate) fn engine(&self) -> Arc<dyn MemoryStore> {
Arc::clone(&self.engine) as Arc<dyn MemoryStore>
}
pub(crate) fn libsql_engine(&self) -> &LibsqlMemoryStore {
&self.engine
}
#[must_use]
pub fn graph(&self) -> crate::Graph {
crate::Graph::new(self.engine(), self.agent.clone())
}
#[cfg(feature = "test-util")]
pub async fn open_in_memory(agent_id: &str) -> Result<Self> {
let agent = AgentId::new(agent_id).ok_or(crate::MemoryError::MissingAgent)?;
let store = Store::open_in_memory().await?;
Self::open(store, Box::new(HashEmbedder::new()), agent).await
}
pub async fn remember(&self, text: &str, layer: MemoryLayer) -> Result<String> {
let now = now_unix();
self.remember_with(text, layer, Validity::since(now)).await
}
pub async fn remember_with(&self, text: &str, layer: MemoryLayer, validity: Validity) -> Result<String> {
self.remember_with_source(text, layer, validity, SOURCE_USER).await
}
pub(crate) async fn remember_with_source(
&self,
text: &str,
layer: MemoryLayer,
validity: Validity,
source: &str,
) -> Result<String> {
check_text_len(text)?;
let vector = self.embedder.embed(text)?;
let id = Uuid::new_v4().to_string();
self.engine
.put_memory(&id, &self.agent, layer, text, validity, &vector, source)
.await?;
let kind = if source == SOURCE_CONSOLIDATION {
MemoryEventKind::Consolidated
} else {
MemoryEventKind::Remembered
};
self.emit(kind, layer, &id);
Ok(id)
}
pub async fn remember_batch(&self, texts: &[String], layer: MemoryLayer) -> Result<Vec<String>> {
let now = now_unix();
self.remember_batch_with(texts, layer, Validity::since(now)).await
}
pub async fn remember_batch_with(
&self,
texts: &[String],
layer: MemoryLayer,
validity: Validity,
) -> Result<Vec<String>> {
self.remember_batch_with_source(texts, layer, validity, SOURCE_USER)
.await
}
pub(crate) async fn remember_batch_with_source(
&self,
texts: &[String],
layer: MemoryLayer,
validity: Validity,
source: &str,
) -> Result<Vec<String>> {
if texts.is_empty() {
return Ok(Vec::new());
}
for text in texts {
check_text_len(text)?;
}
let vectors = self.embedder.embed_batch(texts)?;
let ids: Vec<String> = texts.iter().map(|_| Uuid::new_v4().to_string()).collect();
let items: Vec<NewMemory<'_>> = texts
.iter()
.zip(&vectors)
.zip(&ids)
.map(|((text, vector), id)| NewMemory {
id: id.clone(),
layer,
text,
validity,
vector,
source,
})
.collect();
self.engine.put_memory_batch(&self.agent, &items).await?;
let kind = if source == SOURCE_CONSOLIDATION {
MemoryEventKind::Consolidated
} else {
MemoryEventKind::Remembered
};
for id in &ids {
self.emit(kind, layer, id);
}
Ok(ids)
}
pub async fn recall(&self, query: &str, k: usize) -> Result<Vec<Record>> {
let qvec = self.embedder.embed(query)?;
let now = now_unix();
self.engine
.recall_vector(&self.agent, &qvec, k, None, Metric::Cosine, now)
.await
}
pub async fn recall_with_metric(&self, query: &str, k: usize, metric: Metric) -> Result<Vec<Record>> {
let qvec = self.embedder.embed(query)?;
let now = now_unix();
self.engine
.recall_vector(&self.agent, &qvec, k, None, metric, now)
.await
}
pub async fn recall_hybrid(&self, query: &str, k: usize) -> Result<Vec<Record>> {
let inner = k.saturating_mul(4).max(k);
let now = now_unix();
let qvec = self.embedder.embed(query)?;
let vector_ids = self.engine.vector_ranking_ids(&self.agent, &qvec, inner, now).await?;
let keyword_ids = match fts_match_expr(query) {
Some(match_expr) => {
self.engine
.keyword_ranking_ids(&self.agent, &match_expr, inner, now)
.await?
}
None => Vec::new(),
};
let fused = rrf_fuse(
&[
Ranking {
signal: "vector".to_string(),
ids: vector_ids,
},
Ranking {
signal: "keyword".to_string(),
ids: keyword_ids,
},
],
RRF_K,
);
let top_ids: Vec<String> = fused.iter().take(k).map(|f| f.id.clone()).collect();
#[allow(clippy::cast_possible_truncation)]
let scores: HashMap<&str, f32> = fused.iter().take(k).map(|f| (f.id.as_str(), f.score as f32)).collect();
let hydrated = self.engine.hydrate(&self.agent, &top_ids, now).await?;
Ok(hydrated
.into_iter()
.map(|h| {
let score = scores.get(h.id.as_str()).copied().unwrap_or(0.0);
Record {
id: h.id,
text: h.text,
layer: h.layer,
score,
}
})
.collect())
}
pub async fn recall_by_layer(&self, query: &str, layer: MemoryLayer, k: usize) -> Result<Vec<Record>> {
let qvec = self.embedder.embed(query)?;
let now = now_unix();
self.engine
.recall_vector(&self.agent, &qvec, k, Some(layer), Metric::Cosine, now)
.await
}
pub async fn invalidate(&self, id: &str) -> Result<()> {
let layer = self.engine.layer_of(&self.agent, id).await?;
self.engine.invalidate(&self.agent, id, now_unix()).await?;
if let Some(layer) = layer {
self.emit(MemoryEventKind::Invalidated, layer, id);
}
Ok(())
}
pub async fn forget(&self, id: &str) -> Result<()> {
let layer = self.engine.layer_of(&self.agent, id).await?;
self.engine.forget(&self.agent, id).await?;
if let Some(layer) = layer {
self.emit(MemoryEventKind::Forgotten, layer, id);
}
Ok(())
}
pub async fn purge_agent(&self) -> Result<()> {
self.engine.purge_agent(&self.agent).await
}
pub async fn stats(&self) -> Result<AgentStats> {
self.engine.agent_stats(&self.agent, now_unix()).await
}
pub async fn search_graph(&self, query: &str, k: usize) -> Result<Vec<Record>> {
let qvec = self.embedder.embed(query)?;
let now = now_unix();
self.engine.recall_graph_filtered(&self.agent, &qvec, k, now).await
}
}
fn check_text_len(text: &str) -> Result<()> {
let len = text.len();
if len > MAX_TEXT_LEN {
return Err(MemoryError::TextTooLong { len, max: MAX_TEXT_LEN });
}
Ok(())
}
fn fts_match_expr(query: &str) -> Option<String> {
let tokens: Vec<String> = query
.split(|c: char| !c.is_alphanumeric())
.filter(|t| !t.is_empty())
.take(32)
.map(|t| format!("\"{}\"", t.to_lowercase()))
.collect();
if tokens.is_empty() {
None
} else {
Some(tokens.join(" OR "))
}
}