1use async_trait::async_trait;
2use deepstrike_core::memory::durable::SessionData;
3use deepstrike_core::mm::memory::{
4 MemoryAuthor, MemoryKind, MemoryProvenance, MemoryQuery, MemoryRecall, MemoryRecord,
5 MemoryScope, MemoryTrustLevel,
6};
7
8#[async_trait]
11pub trait MemoryStore: Send + Sync {
12 async fn put(&self, agent_id: &str, record: MemoryRecord) -> crate::Result<()>;
13
14 async fn get(&self, agent_id: &str, record_id: &str) -> crate::Result<Option<MemoryRecord>>;
16
17 async fn delete(&self, agent_id: &str, record_id: &str) -> crate::Result<()>;
19
20 async fn search(&self, agent_id: &str, query: &MemoryQuery)
23 -> crate::Result<Vec<MemoryRecall>>;
24
25 async fn save_session(
27 &self,
28 data: deepstrike_core::memory::durable::SessionData,
29 ) -> crate::Result<()>;
30}
31
32#[derive(Debug, Clone, Default)]
34pub struct MemorySearchOptions {
35 pub top_k: Option<usize>,
36 pub kinds: Vec<MemoryKind>,
37 pub min_score: Option<f64>,
38}
39
40pub struct DurableMemory {
45 store: std::sync::Arc<dyn MemoryStore>,
46 agent_id: String,
47 scope: MemoryScope,
48}
49
50impl DurableMemory {
51 pub fn new(
52 store: std::sync::Arc<dyn MemoryStore>,
53 agent_id: impl Into<String>,
54 scope: MemoryScope,
55 ) -> Self {
56 Self {
57 store,
58 agent_id: agent_id.into(),
59 scope,
60 }
61 }
62
63 pub fn namespace(&self) -> &str {
64 &self.scope.namespace
65 }
66
67 pub async fn search(
68 &self,
69 query: impl Into<String>,
70 options: MemorySearchOptions,
71 ) -> crate::Result<Vec<MemoryRecord>> {
72 let request = MemoryQuery {
73 scope: self.scope.clone(),
74 query: query.into(),
75 top_k: options.top_k.unwrap_or(5),
76 kinds: options.kinds,
77 min_score: options.min_score,
78 };
79 Ok(self
80 .store
81 .search(&self.agent_id, &request)
82 .await?
83 .into_iter()
84 .map(|hit| hit.record)
85 .filter(|record| record.scope == self.scope)
86 .collect())
87 }
88
89 pub async fn get(&self, record_id: &str) -> crate::Result<Option<MemoryRecord>> {
90 Ok(self
91 .store
92 .get(&self.agent_id, record_id)
93 .await?
94 .filter(|record| record.scope == self.scope))
95 }
96
97 pub async fn put(&self, record: MemoryRecord) -> crate::Result<()> {
98 if record.scope != self.scope {
99 return Err(crate::Error::Other(
100 "memory record scope must match the bound Memory scope".into(),
101 ));
102 }
103 self.store.put(&self.agent_id, record).await
104 }
105
106 pub async fn delete(&self, record_id: &str) -> crate::Result<()> {
107 if self.get(record_id).await?.is_some() {
108 self.store.delete(&self.agent_id, record_id).await?;
109 }
110 Ok(())
111 }
112}
113
114pub(crate) fn parse_extracted_memories(
115 output: &str,
116 session: &SessionData,
117 scope: &MemoryScope,
118) -> Vec<MemoryRecord> {
119 let cleaned = output
120 .trim()
121 .strip_prefix("```json")
122 .or_else(|| output.trim().strip_prefix("```"))
123 .unwrap_or(output.trim())
124 .strip_suffix("```")
125 .unwrap_or(output.trim())
126 .trim();
127 let Ok(value) = serde_json::from_str::<serde_json::Value>(cleaned) else {
128 return Vec::new();
129 };
130 let Some(drafts) = value.get("memories").and_then(serde_json::Value::as_array) else {
131 return Vec::new();
132 };
133 drafts
134 .iter()
135 .take(10)
136 .filter_map(|draft| {
137 let name = draft.get("name")?.as_str()?.trim();
138 let content = draft.get("content")?.as_str()?.trim();
139 if name.is_empty() || content.is_empty() {
140 return None;
141 }
142 let kind = match draft.get("kind")?.as_str()? {
143 "user" => MemoryKind::User,
144 "feedback" => MemoryKind::Feedback,
145 "project" => MemoryKind::Project,
146 "reference" => MemoryKind::Reference,
147 _ => return None,
148 };
149 let confidence = draft
150 .get("confidence")
151 .and_then(serde_json::Value::as_f64)
152 .unwrap_or(0.5)
153 .clamp(0.0, 1.0);
154 let strings = |field: &str| {
155 draft
156 .get(field)
157 .and_then(serde_json::Value::as_array)
158 .map(|values| {
159 values
160 .iter()
161 .filter_map(serde_json::Value::as_str)
162 .map(str::to_string)
163 .collect()
164 })
165 .unwrap_or_default()
166 };
167 Some(MemoryRecord {
168 record_id: format!(
169 "{}:{}:{}:{name}",
170 scope.tenant_id,
171 scope.namespace,
172 kind.label()
173 ),
174 scope: scope.clone(),
175 name: name.to_string(),
176 kind,
177 content: content.to_string(),
178 description: draft
179 .get("description")
180 .and_then(serde_json::Value::as_str)
181 .unwrap_or_default()
182 .trim()
183 .to_string(),
184 provenance: MemoryProvenance {
185 session_id: Some(session.session_id.clone()),
186 author: MemoryAuthor::Extraction,
187 trust: MemoryTrustLevel::Untrusted,
188 evidence_refs: strings("evidence_refs"),
189 },
190 created_at: session.updated_at_ms,
191 updated_at: session.updated_at_ms,
192 last_recalled_at: None,
193 recall_count: 0,
194 confidence,
195 links: strings("links"),
196 pinned: draft
197 .get("pinned")
198 .and_then(serde_json::Value::as_bool)
199 .unwrap_or(false),
200 ttl_days: draft
201 .get("ttl_days")
202 .and_then(serde_json::Value::as_u64)
203 .and_then(|days| u32::try_from(days).ok())
204 .filter(|days| *days > 0),
205 })
206 })
207 .collect()
208}
209
210#[derive(Default)]
212pub struct WorkingMemory {
213 store: std::collections::HashMap<String, serde_json::Value>,
214}
215
216impl WorkingMemory {
217 pub fn set(&mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) {
218 self.store.insert(key.into(), value.into());
219 }
220 pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
221 self.store.get(key)
222 }
223 pub fn clear(&mut self) {
224 self.store.clear();
225 }
226}
227
228pub struct InMemoryMemoryStore {
234 memories: std::sync::Mutex<std::collections::HashMap<String, Vec<MemoryRecord>>>,
235 initial_memories: Vec<MemoryRecord>,
236 saved_sessions: std::sync::Mutex<Vec<SessionData>>,
237}
238
239impl InMemoryMemoryStore {
240 pub fn new() -> Self {
241 Self::with_initial_memories(Vec::new())
242 }
243
244 pub fn with_initial_memories(initial: Vec<MemoryRecord>) -> Self {
245 Self {
246 memories: std::sync::Mutex::new(std::collections::HashMap::new()),
247 initial_memories: initial,
248 saved_sessions: std::sync::Mutex::new(Vec::new()),
249 }
250 }
251
252 pub fn saved_sessions(&self) -> Vec<SessionData> {
253 self.saved_sessions.lock().unwrap().clone()
254 }
255}
256
257impl Default for InMemoryMemoryStore {
258 fn default() -> Self {
259 Self::new()
260 }
261}
262
263#[async_trait]
264impl MemoryStore for InMemoryMemoryStore {
265 async fn put(&self, agent_id: &str, incoming: MemoryRecord) -> crate::Result<()> {
266 let mut memories = self.memories.lock().unwrap();
267 let kept = memories
268 .entry(agent_id.to_string())
269 .or_insert_with(|| self.initial_memories.clone());
270 if let Some(index) = kept.iter().position(|record| {
271 record.scope == incoming.scope
272 && record.kind == incoming.kind
273 && record.name == incoming.name
274 }) {
275 kept[index] = incoming;
276 } else {
277 kept.push(incoming);
278 }
279 Ok(())
280 }
281
282 async fn get(&self, agent_id: &str, record_id: &str) -> crate::Result<Option<MemoryRecord>> {
283 let mut memories = self.memories.lock().unwrap();
284 Ok(memories
285 .entry(agent_id.to_string())
286 .or_insert_with(|| self.initial_memories.clone())
287 .iter()
288 .find(|record| record.record_id == record_id)
289 .cloned())
290 }
291
292 async fn delete(&self, agent_id: &str, record_id: &str) -> crate::Result<()> {
293 let mut memories = self.memories.lock().unwrap();
294 let records = memories
295 .entry(agent_id.to_string())
296 .or_insert_with(|| self.initial_memories.clone());
297 records.retain(|record| record.record_id != record_id);
298 Ok(())
299 }
300
301 async fn search(
302 &self,
303 agent_id: &str,
304 query: &MemoryQuery,
305 ) -> crate::Result<Vec<MemoryRecall>> {
306 let all = {
307 let mut memories = self.memories.lock().unwrap();
308 memories
309 .entry(agent_id.to_string())
310 .or_insert_with(|| self.initial_memories.clone())
311 .clone()
312 };
313 let query_terms = memory_terms(&query.query);
314 let mut ranked = all
315 .into_iter()
316 .enumerate()
317 .filter(|(_, record)| {
318 record.scope == query.scope
319 && (query.kinds.is_empty() || query.kinds.contains(&record.kind))
320 && query
321 .min_score
322 .is_none_or(|minimum| record.confidence >= minimum)
323 })
324 .filter_map(|(insertion_index, record)| {
325 let searchable =
326 format!("{} {} {}", record.name, record.description, record.content);
327 let candidate_terms = memory_terms(&searchable);
328 let lexical_matches = query_terms
329 .iter()
330 .filter(|term| candidate_terms.contains(*term))
331 .count();
332 if !query_terms.is_empty() && lexical_matches == 0 {
333 return None;
334 }
335 Some((record, lexical_matches, insertion_index))
336 })
337 .collect::<Vec<_>>();
338 ranked.sort_by(|left, right| {
339 right
340 .1
341 .cmp(&left.1)
342 .then_with(|| right.0.updated_at.cmp(&left.0.updated_at))
343 .then_with(|| left.2.cmp(&right.2))
344 });
345 Ok(ranked
346 .into_iter()
347 .take(query.top_k)
348 .map(|(record, _, _)| MemoryRecall {
349 score: record.confidence.clamp(0.0, 1.0),
350 record,
351 why: "deterministic lexical relevance with recency tie-breaking".into(),
352 })
353 .collect())
354 }
355
356 async fn save_session(&self, data: SessionData) -> crate::Result<()> {
357 self.saved_sessions.lock().unwrap().push(data);
358 Ok(())
359 }
360}
361
362fn memory_terms(text: &str) -> std::collections::HashSet<String> {
363 let mut terms = std::collections::HashSet::new();
364 let mut segment = String::new();
365 let flush = |segment: &mut String, terms: &mut std::collections::HashSet<String>| {
366 if segment.is_empty() {
367 return;
368 }
369 let lowered = segment.to_lowercase();
370 terms.insert(lowered.clone());
371 let characters = lowered.chars().collect::<Vec<_>>();
372 if characters.iter().any(|character| is_han(*character)) {
373 for pair in characters.windows(2) {
374 terms.insert(pair.iter().collect());
375 }
376 }
377 segment.clear();
378 };
379 for character in text.chars() {
380 if character.is_alphanumeric() {
381 segment.push(character);
382 } else {
383 flush(&mut segment, &mut terms);
384 }
385 }
386 flush(&mut segment, &mut terms);
387 terms
388}
389
390fn is_han(character: char) -> bool {
391 matches!(character as u32,
392 0x3400..=0x4DBF | 0x4E00..=0x9FFF | 0xF900..=0xFAFF | 0x20000..=0x3134F)
393}
394
395#[cfg(test)]
396mod ranking_tests {
397 use super::{DurableMemory, InMemoryMemoryStore, MemorySearchOptions, MemoryStore};
398 use deepstrike_core::mm::memory::{
399 MemoryAuthor, MemoryKind, MemoryProvenance, MemoryQuery, MemoryRecord, MemoryScope,
400 MemoryTrustLevel,
401 };
402
403 fn entry(text: &str, updated_at: u64) -> MemoryRecord {
404 MemoryRecord {
405 record_id: format!("record-{updated_at}"),
406 scope: MemoryScope::new("tenant-test", "ranking"),
407 name: text.into(),
408 kind: MemoryKind::Project,
409 content: text.into(),
410 description: text.into(),
411 provenance: MemoryProvenance {
412 session_id: None,
413 author: MemoryAuthor::Host,
414 trust: MemoryTrustLevel::HostVerified,
415 evidence_refs: Vec::new(),
416 },
417 created_at: 1,
418 updated_at,
419 last_recalled_at: None,
420 recall_count: 0,
421 confidence: 1.0,
422 links: Vec::new(),
423 pinned: false,
424 ttl_days: None,
425 }
426 }
427
428 #[tokio::test]
429 async fn search_uses_query_and_never_falls_back_to_unrelated_entries() {
430 let store = InMemoryMemoryStore::with_initial_memories(vec![
431 entry("database migration checklist", 1),
432 entry("rust scheduler fairness", 2),
433 entry("newer unrelated note", 3),
434 ]);
435
436 let query = |text: &str| MemoryQuery {
437 scope: MemoryScope::new("tenant-test", "ranking"),
438 query: text.into(),
439 top_k: 5,
440 kinds: Vec::new(),
441 min_score: None,
442 };
443 let hits = store
444 .search("agent", &query("scheduler rust"))
445 .await
446 .unwrap();
447 assert_eq!(hits.len(), 1);
448 assert_eq!(hits[0].record.content, "rust scheduler fairness");
449 assert!(
450 store
451 .search("agent", &query("nonexistent"))
452 .await
453 .unwrap()
454 .is_empty()
455 );
456 }
457
458 #[tokio::test]
459 async fn durable_memory_binds_crud_to_one_agent_scope() {
460 let store: std::sync::Arc<dyn MemoryStore> =
461 std::sync::Arc::new(InMemoryMemoryStore::new());
462 let scope = MemoryScope::new("tenant-test", "public-contract");
463 let memory = DurableMemory::new(store.clone(), "agent-a", scope.clone());
464 let record = MemoryRecord {
465 scope: scope.clone(),
466 ..entry("architecture", 1)
467 };
468
469 memory.put(record.clone()).await.unwrap();
470 assert_eq!(memory.namespace(), "public-contract");
471 assert_eq!(
472 memory.get(&record.record_id).await.unwrap(),
473 Some(record.clone())
474 );
475 assert_eq!(
476 memory
477 .search("architecture", MemorySearchOptions::default())
478 .await
479 .unwrap(),
480 vec![record.clone()]
481 );
482 memory.delete(&record.record_id).await.unwrap();
483 assert_eq!(memory.get(&record.record_id).await.unwrap(), None);
484
485 let foreign = MemoryRecord {
486 scope: MemoryScope::new("tenant-test", "private"),
487 ..entry("foreign", 2)
488 };
489 assert!(memory.put(foreign.clone()).await.is_err());
490 store.put("agent-a", foreign.clone()).await.unwrap();
491 assert_eq!(memory.get(&foreign.record_id).await.unwrap(), None);
492 memory.delete(&foreign.record_id).await.unwrap();
493 assert_eq!(
494 store.get("agent-a", &foreign.record_id).await.unwrap(),
495 Some(foreign)
496 );
497 }
498
499 struct LeakyStore {
500 foreign: MemoryRecord,
501 }
502
503 #[async_trait::async_trait]
504 impl MemoryStore for LeakyStore {
505 async fn put(&self, _agent_id: &str, _record: MemoryRecord) -> crate::Result<()> {
506 Ok(())
507 }
508
509 async fn get(
510 &self,
511 _agent_id: &str,
512 _record_id: &str,
513 ) -> crate::Result<Option<MemoryRecord>> {
514 Ok(None)
515 }
516
517 async fn delete(&self, _agent_id: &str, _record_id: &str) -> crate::Result<()> {
518 Ok(())
519 }
520
521 async fn search(
522 &self,
523 _agent_id: &str,
524 _query: &MemoryQuery,
525 ) -> crate::Result<Vec<deepstrike_core::mm::memory::MemoryRecall>> {
526 Ok(vec![deepstrike_core::mm::memory::MemoryRecall {
527 record: self.foreign.clone(),
528 score: 1.0,
529 why: "broken host store".into(),
530 }])
531 }
532
533 async fn save_session(
534 &self,
535 _data: deepstrike_core::memory::durable::SessionData,
536 ) -> crate::Result<()> {
537 Ok(())
538 }
539 }
540
541 #[tokio::test]
542 async fn durable_memory_filters_cross_scope_host_search_results() {
543 let scope = MemoryScope::new("tenant-test", "public-contract");
544 let foreign = MemoryRecord {
545 scope: MemoryScope::new("tenant-test", "private"),
546 ..entry("foreign", 1)
547 };
548 let store: std::sync::Arc<dyn MemoryStore> = std::sync::Arc::new(LeakyStore { foreign });
549 let memory = DurableMemory::new(store, "agent-a", scope);
550
551 assert!(
552 memory
553 .search("private note", MemorySearchOptions::default())
554 .await
555 .unwrap()
556 .is_empty()
557 );
558 }
559}