1pub mod embed;
14pub mod engine;
15#[cfg(feature = "mempalace")]
16mod mcp;
17pub mod scorer;
18pub mod sqlite;
19
20use chrono::{DateTime, Utc};
21use serde::{Deserialize, Serialize};
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct Memory {
27 pub id: i64,
28 pub text: String,
29 pub created_at: DateTime<Utc>,
30 pub last_used: DateTime<Utc>,
32 pub mentions: u32,
34 pub importance: f32,
36 pub tags: Vec<String>,
38 #[serde(default)]
41 pub embedding: Option<Vec<f32>>,
42}
43
44#[derive(Debug, Clone)]
46pub struct Query {
47 pub text: String,
48 pub now: DateTime<Utc>,
51 pub task_tags: Vec<String>,
53 pub half_life_days: f32,
55}
56
57impl Query {
58 pub fn new(text: impl Into<String>, now: DateTime<Utc>) -> Self {
59 Self {
60 text: text.into(),
61 now,
62 task_tags: Vec::new(),
63 half_life_days: 14.0,
64 }
65 }
66
67 pub fn with_task(mut self, tags: Vec<String>) -> Self {
68 self.task_tags = tags;
69 self
70 }
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct Weights {
76 pub similarity: f32,
77 pub recency: f32,
78 pub importance: f32,
79 pub reinforcement: f32,
80 pub task: f32,
81}
82
83impl Default for Weights {
84 fn default() -> Self {
85 Self {
86 similarity: 0.40,
87 recency: 0.20,
88 importance: 0.15,
89 reinforcement: 0.10,
90 task: 0.15,
91 }
92 }
93}
94
95#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct Signal {
98 pub name: String,
99 pub weight: f32,
100 pub score: f32,
102 pub applicable: bool,
104 pub detail: String,
106}
107
108impl Signal {
109 pub fn contribution(&self) -> f32 {
110 if self.applicable {
111 self.weight * self.score
112 } else {
113 0.0
114 }
115 }
116}
117
118#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct ScoredMemory {
121 pub memory: Memory,
122 pub score: f32,
124 pub signals: Vec<Signal>,
125}
126
127impl ScoredMemory {
128 pub fn percent(&self) -> u32 {
129 (self.score * 100.0).round().clamp(0.0, 100.0) as u32
130 }
131
132 pub fn reasons(&self) -> Vec<String> {
134 let mut active: Vec<&Signal> = self
135 .signals
136 .iter()
137 .filter(|s| s.applicable && s.score >= 0.15)
138 .collect();
139 active.sort_by(|a, b| {
140 b.contribution()
141 .partial_cmp(&a.contribution())
142 .unwrap_or(std::cmp::Ordering::Equal)
143 });
144 active.into_iter().map(|s| s.detail.clone()).collect()
145 }
146
147 pub fn explain(&self) -> String {
149 let m = &self.memory;
150 let mut out = String::new();
151 out.push_str(&format!(
152 "memory explain {}\n \"{}\"\n",
153 m.id,
154 truncate(&m.text, 72)
155 ));
156 out.push_str(&format!(
157 " created {} · last used {} · mentioned {}× · importance {:.2}\n",
158 m.created_at.date_naive(),
159 m.last_used.date_naive(),
160 m.mentions,
161 m.importance
162 ));
163 if !m.tags.is_empty() {
164 out.push_str(&format!(" links: {}\n", m.tags.join(", ")));
165 }
166 out.push_str(&format!(" score {}% =\n", self.percent()));
167 for s in &self.signals {
168 let mark = if s.applicable { " " } else { "·" };
169 out.push_str(&format!(
170 " {} {:<13} w{:.2} × {:.2} = {:+.3} {}\n",
171 mark,
172 s.name,
173 s.weight,
174 s.score,
175 s.contribution(),
176 s.detail
177 ));
178 }
179 out
180 }
181}
182
183pub(crate) fn truncate(s: &str, max: usize) -> String {
184 let s = s.trim();
185 if s.chars().count() <= max {
186 s.to_string()
187 } else {
188 let cut: String = s.chars().take(max).collect();
189 format!("{cut}…")
190 }
191}