Skip to main content

deepstrike_sdk/
memory.rs

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/// Durable-memory host storage. Runner writes through `put` only after the kernel's
9/// `WriteMemory` gate accepts the record.
10#[async_trait]
11pub trait MemoryStore: Send + Sync {
12    async fn put(&self, agent_id: &str, record: MemoryRecord) -> crate::Result<()>;
13
14    /// Return one record by its agent-local id, or `None` when it does not exist.
15    async fn get(&self, agent_id: &str, record_id: &str) -> crate::Result<Option<MemoryRecord>>;
16
17    /// Delete one record by its agent-local id. Missing records are a successful no-op.
18    async fn delete(&self, agent_id: &str, record_id: &str) -> crate::Result<()>;
19
20    /// Semantic search over the agent's long-term memories.
21    /// Called on demand during a session when the LLM invokes the `memory` meta-tool.
22    async fn search(&self, agent_id: &str, query: &MemoryQuery)
23    -> crate::Result<Vec<MemoryRecall>>;
24
25    /// Persist a completed session before the runner's one extraction pass.
26    async fn save_session(
27        &self,
28        data: deepstrike_core::memory::durable::SessionData,
29    ) -> crate::Result<()>;
30}
31
32/// Search options for an agent-bound [`DurableMemory`] descriptor.
33#[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
40/// Public durable memory bound to one agent and scope.
41///
42/// This is separate from [`WorkingMemory`], which is an in-process scratch pad, and from
43/// [`MemoryStore`], which remains host-owned storage for runners and public descriptors.
44pub 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/// In-process scratch pad for within-run state.
211#[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
228/// `InMemoryMemoryStore` — a lightweight `MemoryStore` backed by per-agent in-memory maps.
229///
230/// Rust port of node/src/memory/in-memory-store.ts. Use for benchmarks, unit tests, and local
231/// development where persistent memory isn't needed. `search()` is a deterministic reference
232/// ranker: distinct lexical overlap first, metadata recency second, insertion order last.
233pub 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}