pub mod embed;
pub mod engine;
#[cfg(feature = "mempalace")]
mod mcp;
pub mod scorer;
pub mod sqlite;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory {
pub id: i64,
pub text: String,
pub created_at: DateTime<Utc>,
pub last_used: DateTime<Utc>,
pub mentions: u32,
pub importance: f32,
pub tags: Vec<String>,
#[serde(default)]
pub embedding: Option<Vec<f32>>,
}
#[derive(Debug, Clone)]
pub struct Query {
pub text: String,
pub now: DateTime<Utc>,
pub task_tags: Vec<String>,
pub half_life_days: f32,
}
impl Query {
pub fn new(text: impl Into<String>, now: DateTime<Utc>) -> Self {
Self {
text: text.into(),
now,
task_tags: Vec::new(),
half_life_days: 14.0,
}
}
pub fn with_task(mut self, tags: Vec<String>) -> Self {
self.task_tags = tags;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Weights {
pub similarity: f32,
pub recency: f32,
pub importance: f32,
pub reinforcement: f32,
pub task: f32,
}
impl Default for Weights {
fn default() -> Self {
Self {
similarity: 0.40,
recency: 0.20,
importance: 0.15,
reinforcement: 0.10,
task: 0.15,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Signal {
pub name: String,
pub weight: f32,
pub score: f32,
pub applicable: bool,
pub detail: String,
}
impl Signal {
pub fn contribution(&self) -> f32 {
if self.applicable {
self.weight * self.score
} else {
0.0
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ScoredMemory {
pub memory: Memory,
pub score: f32,
pub signals: Vec<Signal>,
}
impl ScoredMemory {
pub fn percent(&self) -> u32 {
(self.score * 100.0).round().clamp(0.0, 100.0) as u32
}
pub fn reasons(&self) -> Vec<String> {
let mut active: Vec<&Signal> = self
.signals
.iter()
.filter(|s| s.applicable && s.score >= 0.15)
.collect();
active.sort_by(|a, b| {
b.contribution()
.partial_cmp(&a.contribution())
.unwrap_or(std::cmp::Ordering::Equal)
});
active.into_iter().map(|s| s.detail.clone()).collect()
}
pub fn explain(&self) -> String {
let m = &self.memory;
let mut out = String::new();
out.push_str(&format!(
"memory explain {}\n \"{}\"\n",
m.id,
truncate(&m.text, 72)
));
out.push_str(&format!(
" created {} · last used {} · mentioned {}× · importance {:.2}\n",
m.created_at.date_naive(),
m.last_used.date_naive(),
m.mentions,
m.importance
));
if !m.tags.is_empty() {
out.push_str(&format!(" links: {}\n", m.tags.join(", ")));
}
out.push_str(&format!(" score {}% =\n", self.percent()));
for s in &self.signals {
let mark = if s.applicable { " " } else { "·" };
out.push_str(&format!(
" {} {:<13} w{:.2} × {:.2} = {:+.3} {}\n",
mark,
s.name,
s.weight,
s.score,
s.contribution(),
s.detail
));
}
out
}
}
pub(crate) fn truncate(s: &str, max: usize) -> String {
let s = s.trim();
if s.chars().count() <= max {
s.to_string()
} else {
let cut: String = s.chars().take(max).collect();
format!("{cut}…")
}
}