1use std::sync::Arc;
11
12use crate::embed::Embedder;
13use crate::scorer::score;
14use crate::{Memory, Query, ScoredMemory, Weights};
15
16pub trait MemoryEngine {
17 fn name(&self) -> &str;
18 fn retrieve(&self, query: &Query, k: usize) -> Vec<ScoredMemory>;
20 fn explain(&self, id: i64, query: &Query) -> Option<ScoredMemory>;
22}
23
24pub struct BuiltinEngine {
30 pub memories: Vec<Memory>,
31 pub weights: Weights,
32 embedder: Option<Arc<dyn Embedder>>,
33}
34
35impl BuiltinEngine {
36 pub fn new(memories: Vec<Memory>) -> Self {
37 Self {
38 memories,
39 weights: Weights::default(),
40 embedder: None,
41 }
42 }
43
44 pub fn with_weights(mut self, weights: Weights) -> Self {
45 self.weights = weights;
46 self
47 }
48
49 pub fn with_embedder(mut self, embedder: Arc<dyn Embedder>) -> anyhow::Result<Self> {
52 for m in &mut self.memories {
53 if m.embedding.is_none() {
54 m.embedding = Some(embedder.embed(&m.text)?);
55 }
56 }
57 self.embedder = Some(embedder);
58 Ok(self)
59 }
60
61 fn query_embedding(&self, query: &Query) -> Option<Vec<f32>> {
63 self.embedder.as_ref().and_then(|e| e.embed(&query.text).ok())
64 }
65}
66
67impl MemoryEngine for BuiltinEngine {
68 fn name(&self) -> &str {
69 if self.embedder.is_some() {
70 "builtin+embeddings"
71 } else {
72 "builtin"
73 }
74 }
75
76 fn retrieve(&self, query: &Query, k: usize) -> Vec<ScoredMemory> {
77 let qe = self.query_embedding(query);
78 let mut scored: Vec<ScoredMemory> = self
79 .memories
80 .iter()
81 .map(|m| score(m, query, &self.weights, qe.as_deref()))
82 .collect();
83 scored.sort_by(|a, b| {
84 b.score
85 .partial_cmp(&a.score)
86 .unwrap_or(std::cmp::Ordering::Equal)
87 });
88 scored.truncate(k);
89 scored
90 }
91
92 fn explain(&self, id: i64, query: &Query) -> Option<ScoredMemory> {
93 let qe = self.query_embedding(query);
94 self.memories
95 .iter()
96 .find(|m| m.id == id)
97 .map(|m| score(m, query, &self.weights, qe.as_deref()))
98 }
99}
100
101#[cfg(feature = "mempalace")]
116pub struct MemPalaceEngine {
117 pub command: String,
119 pub args: Vec<String>,
121 pub tool: String,
123}
124
125#[cfg(feature = "mempalace")]
126impl Default for MemPalaceEngine {
127 fn default() -> Self {
128 Self {
129 command: "mempalace-mcp".into(),
130 args: Vec::new(),
131 tool: "search".into(),
132 }
133 }
134}
135
136#[cfg(feature = "mempalace")]
137impl MemPalaceEngine {
138 pub fn new(command: impl Into<String>, args: Vec<String>) -> Self {
139 Self {
140 command: command.into(),
141 args,
142 tool: "search".into(),
143 }
144 }
145
146 pub fn with_tool(mut self, tool: impl Into<String>) -> Self {
147 self.tool = tool.into();
148 self
149 }
150
151 pub fn try_retrieve(&self, query: &Query, k: usize) -> anyhow::Result<Vec<ScoredMemory>> {
155 let mut client = crate::mcp::McpClient::spawn(&self.command, &self.args)?;
156 let tools = client.list_tools()?;
157 if let Some(names) = tools.get("tools").and_then(serde_json::Value::as_array) {
158 if !names
159 .iter()
160 .any(|t| t.get("name").and_then(serde_json::Value::as_str) == Some(&self.tool))
161 {
162 anyhow::bail!(
163 "`{}` does not expose a `{}` tool",
164 self.command,
165 self.tool
166 );
167 }
168 }
169 let text = client.call_tool(
170 &self.tool,
171 serde_json::json!({"query": query.text, "limit": k}),
172 )?;
173 let mut out = map_hits(&text, query)?;
174 out.truncate(k);
175 Ok(out)
176 }
177}
178
179#[cfg(feature = "mempalace")]
182fn map_hits(payload: &str, query: &Query) -> anyhow::Result<Vec<ScoredMemory>> {
183 use anyhow::Context;
184 use serde_json::Value;
185
186 let parsed: Value = serde_json::from_str(payload)
187 .context("mempalace search result was not JSON")?;
188 let hits = match &parsed {
189 Value::Array(a) => a.clone(),
190 Value::Object(o) => o
191 .get("results")
192 .and_then(Value::as_array)
193 .cloned()
194 .ok_or_else(|| anyhow::anyhow!("mempalace search result has no `results` array"))?,
195 _ => anyhow::bail!("unexpected mempalace search result shape"),
196 };
197
198 let ts = |h: &Value, key: &str| {
199 h.get(key)
200 .and_then(Value::as_str)
201 .and_then(|s| chrono::DateTime::parse_from_rfc3339(s).ok())
202 .map(|d| d.with_timezone(&chrono::Utc))
203 .unwrap_or(query.now)
204 };
205
206 Ok(hits
207 .iter()
208 .enumerate()
209 .map(|(i, h)| {
210 let score = h.get("score").and_then(Value::as_f64).unwrap_or(0.0) as f32;
211 let score = score.clamp(0.0, 1.0);
212 let memory = Memory {
213 id: h.get("id").and_then(Value::as_i64).unwrap_or(i as i64),
214 text: h
215 .get("text")
216 .and_then(Value::as_str)
217 .unwrap_or_default()
218 .to_string(),
219 created_at: ts(h, "created_at"),
220 last_used: ts(h, "last_used"),
221 mentions: h.get("mentions").and_then(Value::as_u64).unwrap_or(0) as u32,
222 importance: h.get("importance").and_then(Value::as_f64).unwrap_or(0.0) as f32,
223 tags: h
224 .get("tags")
225 .and_then(Value::as_array)
226 .map(|t| {
227 t.iter()
228 .filter_map(Value::as_str)
229 .map(str::to_string)
230 .collect()
231 })
232 .unwrap_or_default(),
233 embedding: None,
234 };
235 ScoredMemory {
236 memory,
237 score,
238 signals: vec![crate::Signal {
239 name: "similarity".into(),
240 weight: 1.0,
241 score,
242 applicable: true,
243 detail: format!("mempalace semantic score {score:.2}"),
244 }],
245 }
246 })
247 .collect())
248}
249
250#[cfg(feature = "mempalace")]
251impl MemoryEngine for MemPalaceEngine {
252 fn name(&self) -> &str {
253 "mempalace"
254 }
255
256 fn retrieve(&self, query: &Query, k: usize) -> Vec<ScoredMemory> {
257 match self.try_retrieve(query, k) {
258 Ok(hits) => hits,
259 Err(e) => {
260 eprintln!("[mw-memory] mempalace retrieval failed: {e:#}");
261 Vec::new()
262 }
263 }
264 }
265
266 fn explain(&self, id: i64, query: &Query) -> Option<ScoredMemory> {
267 self.try_retrieve(query, 50)
269 .ok()?
270 .into_iter()
271 .find(|s| s.memory.id == id)
272 }
273}
274
275#[cfg(feature = "mempalace")]
278pub struct Drawer {
279 pub wing: String,
280 pub room: String,
281 pub content: String,
282}
283
284#[cfg(feature = "mempalace")]
286#[derive(Debug, Default, PartialEq, Eq)]
287pub struct CheckpointOutcome {
288 pub added: usize,
289 pub duplicates: usize,
290 pub errors: usize,
291}
292
293#[cfg(feature = "mempalace")]
298pub fn checkpoint(
299 command: &str,
300 args: &[String],
301 tool: &str,
302 items: &[Drawer],
303) -> anyhow::Result<CheckpointOutcome> {
304 use serde_json::{json, Value};
305 let mut client = crate::mcp::McpClient::spawn(command, args)?;
306 let payload = json!({
307 "items": items
308 .iter()
309 .map(|d| json!({"wing": d.wing, "room": d.room, "content": d.content}))
310 .collect::<Vec<_>>(),
311 "added_by": "memorywhale",
312 });
313 let text = client.call_tool(tool, payload)?;
314 let v: Value = serde_json::from_str(&text).unwrap_or(Value::Null);
317 let count = |k: &str| v.get(k).and_then(Value::as_array).map(Vec::len).unwrap_or(0);
318 Ok(CheckpointOutcome {
319 added: count("added"),
320 duplicates: count("duplicates"),
321 errors: count("errors"),
322 })
323}
324
325#[cfg(test)]
326mod tests {
327 use super::*;
328 use chrono::{Duration, TimeZone, Utc};
329
330 fn now() -> chrono::DateTime<Utc> {
331 Utc.with_ymd_and_hms(2026, 6, 27, 12, 0, 0).unwrap()
332 }
333
334 fn sample() -> Vec<Memory> {
335 let n = now();
336 vec![
337 Memory { id: 143, text: "I use Rust for systems software.".into(), created_at: n - Duration::days(20), last_used: n, mentions: 27, importance: 0.98, tags: vec!["rust".into()], embedding: None },
338 Memory { id: 7, text: "I ate pizza.".into(), created_at: n - Duration::days(40), last_used: n - Duration::days(40), mentions: 1, importance: 0.01, tags: vec![], embedding: None },
339 Memory { id: 22, text: "Use Tokio for async runtime.".into(), created_at: n - Duration::days(5), last_used: n - Duration::days(3), mentions: 6, importance: 0.6, tags: vec!["rust".into(), "tokio".into()], embedding: None },
340 ]
341 }
342
343 #[test]
344 fn retrieve_ranks_and_truncates() {
345 let eng = BuiltinEngine::new(sample());
346 let q = Query::new("which language for systems programming?", now());
347 let top = eng.retrieve(&q, 2);
348 assert_eq!(top.len(), 2);
349 assert_eq!(top[0].memory.id, 143); }
351
352 #[test]
353 fn explain_returns_breakdown() {
354 let eng = BuiltinEngine::new(sample());
355 let q = Query::new("rust", now());
356 let e = eng.explain(143, &q).unwrap();
357 assert!(e.explain().contains("memory explain 143"));
358 assert!(eng.explain(9999, &q).is_none());
359 }
360
361 #[cfg(feature = "mempalace")]
362 #[test]
363 fn mempalace_maps_hits_to_signals() {
364 let payload = include_str!("../tests/fixtures/mempalace_search.json");
365 let hits = map_hits(payload, &Query::new("rust", now())).unwrap();
366 assert_eq!(hits.len(), 2);
367 assert_eq!(hits[0].memory.id, 143);
368 assert_eq!(hits[0].memory.tags, vec!["rust".to_string()]);
369 assert_eq!(hits[0].percent(), 87);
370 let sig = &hits[0].signals[0];
371 assert_eq!(sig.name, "similarity");
372 assert_eq!(sig.detail, "mempalace semantic score 0.87");
373 assert_eq!(hits[1].memory.created_at, now());
375 }
376
377 #[cfg(feature = "mempalace")]
378 #[test]
379 fn mempalace_rejects_garbage() {
380 assert!(map_hits("not json", &Query::new("x", now())).is_err());
381 assert!(map_hits("{\"oops\": 1}", &Query::new("x", now())).is_err());
382 }
383
384 #[cfg(all(feature = "mempalace", unix))]
387 #[test]
388 fn mempalace_talks_to_a_fake_server() {
389 let script = concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/fake-mempalace-mcp.sh");
390 let eng = MemPalaceEngine::new("sh", vec![script.to_string()]);
391 let hits = eng.try_retrieve(&Query::new("rust", now()), 10).unwrap();
392 assert_eq!(hits.len(), 2);
393 assert_eq!(hits[0].memory.id, 143);
394 assert!(hits[0].reasons()[0].starts_with("mempalace semantic score"));
395 }
396
397 #[cfg(all(feature = "mempalace", unix))]
398 #[test]
399 fn checkpoint_pushes_and_parses_the_summary() {
400 let script =
401 concat!(env!("CARGO_MANIFEST_DIR"), "/tests/fixtures/fake-mempalace-checkpoint.sh");
402 let items = vec![
403 Drawer { wing: "memorywhale".into(), room: "command".into(), content: "cargo build failed".into() },
404 Drawer { wing: "memorywhale".into(), room: "note".into(), content: "the fix was X".into() },
405 ];
406 let out = checkpoint("sh", &[script.to_string()], "mempalace_checkpoint", &items).unwrap();
407 assert_eq!(out, CheckpointOutcome { added: 2, duplicates: 1, errors: 0 });
409 }
410
411 #[cfg(feature = "mempalace")]
412 #[test]
413 fn missing_server_is_a_clear_error() {
414 let eng = MemPalaceEngine::new("mw-definitely-not-installed", vec![]);
415 let err = eng
416 .try_retrieve(&Query::new("x", now()), 3)
417 .unwrap_err()
418 .to_string();
419 assert!(err.contains("failed to start MCP server"), "{err}");
420 }
421}