Skip to main content

oxios_kernel/memory/
store.rs

1//! Memory store operations: save/load, index management, search.
2//!
3//! Integrates HNSW index (usearch) for fast approximate nearest neighbor search
4//! alongside the existing file-based state store for persistence.
5
6use std::collections::HashMap;
7use std::path::PathBuf;
8use std::sync::atomic::{AtomicU64, Ordering};
9
10use anyhow::Result;
11use chrono::{DateTime, Utc};
12use parking_lot::RwLock;
13use serde::{Deserialize, Serialize};
14
15use crate::embedding::EmbeddingVector;
16
17use super::hnsw::HnswIndex;
18use super::normalizer::l2_normalize_f32;
19use super::{content_hash, dedup_by_id, extract_keywords, MemoryEntry, MemoryManager, MemoryType};
20
21// ---------------------------------------------------------------------------
22// VectorIndexSnapshot
23// ---------------------------------------------------------------------------
24
25/// Snapshot of the vector index for persistence.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27struct VectorIndexSnapshot {
28    /// Snapshot creation timestamp.
29    created_at: DateTime<Utc>,
30    /// Number of entries in the snapshot.
31    entry_count: usize,
32    /// Map of entry ID to embedding vector.
33    entries: HashMap<String, EmbeddingVector>,
34}
35
36// ---------------------------------------------------------------------------
37// Store & search operations
38// ---------------------------------------------------------------------------
39
40impl MemoryManager {
41    /// Returns total entries across all memory types (from disk).
42    pub async fn total_entries(&self) -> usize {
43        let mut total = 0;
44        for mt in [
45            MemoryType::Conversation,
46            MemoryType::Session,
47            MemoryType::Fact,
48            MemoryType::Episode,
49            MemoryType::Knowledge,
50        ] {
51            if let Ok(entries) = self.list(mt, usize::MAX).await {
52                total += entries.len();
53            }
54        }
55        total
56    }
57
58    /// Rebuild the vector index from all stored memories.
59    ///
60    /// Call once at startup to populate the in-memory index from
61    /// persisted memory entries.
62    pub async fn rebuild_index(&self) -> Result<()> {
63        // Collect all entries outside the lock
64        let mut entries_to_index: Vec<(String, EmbeddingVector)> = Vec::new();
65
66        for mt in &[
67            MemoryType::Conversation,
68            MemoryType::Session,
69            MemoryType::Fact,
70            MemoryType::Episode,
71            MemoryType::Knowledge,
72        ] {
73            if let Ok(names) = self.state_store.list_category(mt.category()).await {
74                for name in names {
75                    if let Ok(Some(entry)) = self
76                        .state_store
77                        .load_json::<MemoryEntry>(mt.category(), &name)
78                        .await
79                    {
80                        let vector = self.embedding.embed(&entry.content).await?;
81                        entries_to_index.push((entry.id.clone(), vector));
82                    }
83                }
84            }
85        }
86
87        // Now acquire the lock only for the write
88        {
89            let mut index = self.vector_index.write();
90            index.clear();
91            for (id, vector) in entries_to_index {
92                index.insert(id, vector);
93            }
94        }
95
96        tracing::info!(
97            entries = self.vector_index.read().len(),
98            "Memory vector index rebuilt"
99        );
100        Ok(())
101    }
102
103    /// Save the current vector index to disk as a snapshot.
104    pub async fn save_index_snapshot(&self) -> Result<()> {
105        let snapshot = {
106            let index = self.vector_index.read();
107            VectorIndexSnapshot {
108                created_at: chrono::Utc::now(),
109                entry_count: index.len(),
110                entries: index.clone(),
111            }
112        };
113
114        self.state_store
115            .save_json("memory", "vector_index_snapshot", &snapshot)
116            .await?;
117
118        self.git_commit("memory/vector_index_snapshot.json", "memory: snapshot save");
119
120        tracing::debug!(
121            entries = snapshot.entry_count,
122            "Vector index snapshot saved"
123        );
124        Ok(())
125    }
126
127    /// Load a previously saved vector index snapshot from disk.
128    pub async fn load_index_snapshot(&self) -> Result<usize> {
129        let snapshot: Option<VectorIndexSnapshot> = self
130            .state_store
131            .load_json("memory", "vector_index_snapshot")
132            .await?;
133
134        match snapshot {
135            Some(snap) => {
136                let count = snap.entry_count;
137                let mut index = self.vector_index.write();
138                *index = snap.entries;
139                tracing::info!(entries = count, "Vector index snapshot loaded");
140                Ok(count)
141            }
142            None => {
143                tracing::debug!("No vector index snapshot found");
144                Ok(0)
145            }
146        }
147    }
148
149    /// Store a memory entry. Returns the entry ID.
150    ///
151    /// Also computes and stores the entry's text vector in the in-memory
152    /// index for future semantic search.
153    pub async fn remember(&self, entry: MemoryEntry) -> Result<String> {
154        let id = entry.id.clone();
155        let vector = self.embedding.embed(&entry.content).await?;
156        let category = entry.memory_type.category();
157        self.state_store.save_json(category, &id, &entry).await?;
158
159        self.git_commit(
160            &format!("{}/{}.json", category, id),
161            &format!("memory: store {}", id),
162        );
163
164        // Update vector index
165        {
166            let mut index = self.vector_index.write();
167            index.insert(id.clone(), vector.clone());
168        }
169
170        // Update HNSW index if attached
171        if let Some(f32_vec) = vector.to_f32_dense() {
172            let hnsw = self.hnsw_index.read();
173            if let Some(ref hnsw) = *hnsw {
174                if let Err(e) = hnsw.add_entry(&id, &f32_vec) {
175                    tracing::warn!(id = %id, error = %e, "Failed to update HNSW index on remember");
176                }
177            }
178        }
179
180        tracing::debug!(id = %id, ty = entry.memory_type.label(), "Memory stored");
181        Ok(id)
182    }
183
184    /// Retrieve a single memory by ID.
185    pub async fn get(&self, id: &str, memory_type: MemoryType) -> Result<Option<MemoryEntry>> {
186        self.state_store.load_json(memory_type.category(), id).await
187    }
188
189    /// Delete a memory entry.
190    pub async fn forget(&self, id: &str, memory_type: MemoryType) -> Result<bool> {
191        let result = self
192            .state_store
193            .delete_file(memory_type.category(), id)
194            .await?;
195
196        // Remove from HNSW index if attached
197        {
198            let hnsw = self.hnsw_index.read();
199            if let Some(ref hnsw) = *hnsw {
200                if let Err(e) = hnsw.remove_entry(id) {
201                    tracing::warn!(id = %id, error = %e, "Failed to remove from HNSW index on forget");
202                }
203            }
204        }
205
206        Ok(result)
207    }
208
209    /// List memories of a given type, most recent first.
210    pub async fn list(&self, memory_type: MemoryType, limit: usize) -> Result<Vec<MemoryEntry>> {
211        let category = memory_type.category();
212        let names = self.state_store.list_category(category).await?;
213        let mut entries = Vec::new();
214        for name in names.into_iter().take(limit * 2) {
215            if let Ok(Some(entry)) = self
216                .state_store
217                .load_json::<MemoryEntry>(category, &name)
218                .await
219            {
220                entries.push(entry);
221            }
222        }
223        // Sort by created_at descending (most recent first)
224        entries.sort_by_key(|b| std::cmp::Reverse(b.created_at));
225        entries.truncate(limit);
226        Ok(entries)
227    }
228
229    /// Search memories by semantic similarity (vector search).
230    ///
231    /// Falls back to keyword search when the vector index is empty or
232    /// yields no results above the similarity threshold.
233    pub async fn search(
234        &self,
235        query: &str,
236        memory_type: Option<MemoryType>,
237        limit: usize,
238    ) -> Result<Vec<MemoryEntry>> {
239        let query_vector = self.embedding.embed(query).await?;
240
241        // Scope the read lock: compute scores, then drop before any await.
242        let scored: Vec<(String, f64)> = {
243            let index = self.vector_index.read();
244            let mut scored: Vec<(String, f64)> = index
245                .iter()
246                .map(|(id, vector)| {
247                    let score = query_vector.cosine_similarity(vector);
248                    (id.clone(), score)
249                })
250                .filter(|(_, score)| *score > 0.1)
251                .collect();
252            scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
253            scored.truncate(limit);
254            scored
255        }; // lock dropped here, before any .await
256
257        // If index was empty, scored will be empty — fall back immediately
258        if scored.is_empty() {
259            return self.keyword_search(query, memory_type, limit).await;
260        }
261
262        // Determine which memory types to search
263        let all_types: &[MemoryType] = &[
264            MemoryType::Conversation,
265            MemoryType::Session,
266            MemoryType::Fact,
267            MemoryType::Episode,
268            MemoryType::Knowledge,
269        ];
270        let types: &[MemoryType] = match memory_type {
271            Some(ref t) => std::slice::from_ref(t),
272            None => all_types,
273        };
274
275        // Load entries from state store (no lock held)
276        let mut results = Vec::new();
277        for (id, score) in scored {
278            for mt in types {
279                if let Ok(Some(mut entry)) = self
280                    .state_store
281                    .load_json::<MemoryEntry>(mt.category(), &id)
282                    .await
283                {
284                    entry.access_count += 1;
285                    entry.accessed_at = chrono::Utc::now();
286                    tracing::debug!(id = %id, score, "Vector search hit");
287                    results.push(entry);
288                    break;
289                }
290            }
291        }
292
293        // Fall back to keyword search if no results
294        if results.is_empty() {
295            return self.keyword_search(query, memory_type, limit).await;
296        }
297
298        Ok(results)
299    }
300
301    /// Keyword-based search (original algorithm, used as fallback).
302    async fn keyword_search(
303        &self,
304        query: &str,
305        memory_type: Option<MemoryType>,
306        limit: usize,
307    ) -> Result<Vec<MemoryEntry>> {
308        let keywords = extract_keywords(query);
309        let types = match memory_type {
310            Some(t) => vec![t],
311            None => vec![
312                MemoryType::Conversation,
313                MemoryType::Fact,
314                MemoryType::Episode,
315                MemoryType::Knowledge,
316            ],
317        };
318
319        let mut results = Vec::new();
320        for ty in &types {
321            let entries = self.list(*ty, limit * 2).await?;
322            for entry in entries {
323                let matches = keywords.iter().any(|k| {
324                    let k_lower = k.to_lowercase();
325                    entry.content.to_lowercase().contains(&k_lower)
326                        || entry
327                            .tags
328                            .iter()
329                            .any(|t| t.to_lowercase().contains(&k_lower))
330                });
331                if matches {
332                    results.push(entry);
333                }
334            }
335        }
336
337        results.sort_by(|a, b| {
338            b.importance
339                .partial_cmp(&a.importance)
340                .unwrap_or(std::cmp::Ordering::Equal)
341        });
342        results.truncate(limit);
343        Ok(results)
344    }
345
346    /// Recall relevant memories for a new session.
347    ///
348    /// Combines recent conversation summaries, session summaries,
349    /// and keyword-matched facts/episodes.
350    pub async fn recall(&self, query: &str) -> Result<Vec<MemoryEntry>> {
351        let limit = self.max_recall;
352
353        // 1. Recent conversation summaries (always include)
354        let recent = self
355            .list(MemoryType::Conversation, 3)
356            .await
357            .unwrap_or_default();
358
359        // 2. Recent session summaries
360        let sessions = self.list(MemoryType::Session, 2).await.unwrap_or_default();
361
362        // 3. Keyword-matched facts and episodes
363        let relevant = self.search(query, None, limit).await.unwrap_or_default();
364
365        // 4. Combine and deduplicate
366        let mut combined = recent;
367        combined.extend(sessions);
368        combined.extend(relevant);
369        dedup_by_id(&mut combined);
370        combined.truncate(limit);
371        Ok(combined)
372    }
373
374    /// Blend recalled memories into the system prompt.
375    pub fn blend_into_prompt(&self, memories: &[MemoryEntry], system_prompt: &str) -> String {
376        if memories.is_empty() {
377            return system_prompt.to_string();
378        }
379
380        let memory_block = memories
381            .iter()
382            .map(|m| format!("- [{}] {}", m.memory_type.label(), m.content))
383            .collect::<Vec<_>>()
384            .join("\n");
385
386        format!("{system_prompt}\n\n## Relevant Memory\n\n{memory_block}")
387    }
388
389    /// Create a session summary memory entry from a completed session.
390    ///
391    /// This does NOT use LLM — it records key metadata from the session
392    /// as a structured memory entry for future reference.
393    pub async fn summarize_session(
394        &self,
395        session: &crate::state_store::Session,
396    ) -> Result<Option<String>> {
397        if session.user_messages.is_empty() {
398            return Ok(None);
399        }
400
401        // Build a summary from the session metadata
402        let mut summary_parts = Vec::new();
403
404        // Include the first user message as context
405        if let Some(first_msg) = session.user_messages.first() {
406            summary_parts.push(format!("User: {}", first_msg.content));
407        }
408
409        // Include the last agent response
410        if let Some(last_response) = session.agent_responses.last() {
411            let truncated = if last_response.content.len() > 500 {
412                format!("{}...", &last_response.content[..500])
413            } else {
414                last_response.content.clone()
415            };
416            summary_parts.push(format!("Agent: {}", truncated));
417        }
418
419        // Include metadata
420        if let Some(ref seed_id) = session.active_seed_id {
421            summary_parts.push(format!("Seed: {}", seed_id));
422        }
423        if let Some(ref persona_id) = session.active_persona_id {
424            summary_parts.push(format!("Persona: {}", persona_id));
425        }
426
427        let content = summary_parts.join("\n");
428        let entry = MemoryEntry {
429            id: format!(
430                "session-{}-{}",
431                session.id.0,
432                chrono::Utc::now().timestamp()
433            ),
434            memory_type: MemoryType::Session,
435            content,
436            source: "session_summary".to_string(),
437            session_id: Some(session.id.0.clone()),
438            tags: vec![],
439            importance: 0.6,
440            created_at: chrono::Utc::now(),
441            accessed_at: chrono::Utc::now(),
442            access_count: 0,
443        };
444
445        let id = self.remember(entry).await?;
446        Ok(Some(id))
447    }
448
449    /// Check if a memory entry with identical content already exists.
450    ///
451    /// Uses a fast hash comparison against the in-memory vector index.
452    pub async fn is_duplicate(&self, content: &str) -> bool {
453        let hash = content_hash(content);
454
455        // Check semantic similarity via vector index first (fast)
456        let query_vector = match self.embedding.embed(content).await {
457            Ok(v) => v,
458            Err(_) => return false,
459        };
460        let similar = {
461            let index = self.vector_index.read();
462            index
463                .iter()
464                .any(|(_, vector)| query_vector.cosine_similarity(vector) > 0.95)
465        };
466        if similar {
467            return true;
468        }
469
470        // Then check exact content hash across all types
471        for mt in &[
472            MemoryType::Conversation,
473            MemoryType::Session,
474            MemoryType::Fact,
475            MemoryType::Episode,
476            MemoryType::Knowledge,
477        ] {
478            if let Ok(entries) = self.list(*mt, 1000).await {
479                for entry in entries {
480                    if content_hash(&entry.content) == hash {
481                        return true;
482                    }
483                }
484            }
485        }
486        false
487    }
488
489    /// Store a memory entry only if no duplicate content exists.
490    ///
491    /// Returns the entry ID if stored, or `None` if duplicate.
492    pub async fn remember_unique(&self, entry: MemoryEntry) -> Result<Option<String>> {
493        if self.is_duplicate(&entry.content).await {
494            tracing::debug!(id = %entry.id, "Skipping duplicate memory");
495            return Ok(None);
496        }
497        let id = self.remember(entry).await?;
498        Ok(Some(id))
499    }
500}
501
502// ---------------------------------------------------------------------------
503// HNSW-augmented operations
504// ---------------------------------------------------------------------------
505
506/// Result of a semantic search hit.
507#[derive(Debug, Clone, Serialize, Deserialize)]
508pub struct SemanticHit {
509    /// Memory entry.
510    pub entry: MemoryEntry,
511    /// Cosine distance (0.0 = identical).
512    pub distance: f32,
513    /// Cosine similarity (1.0 = identical).
514    pub similarity: f32,
515}
516
517/// HNSW index manager for memory entries.
518///
519/// Maintains a mapping from u64 keys to String IDs, and the HNSW index
520/// itself. Thread-safe via `RwLock`.
521pub struct HnswMemoryIndex {
522    /// The HNSW index.
523    index: RwLock<HnswIndex>,
524    /// Map: u64 key → String memory ID.
525    key_to_id: RwLock<HashMap<u64, String>>,
526    /// Map: String memory ID → u64 key.
527    id_to_key: RwLock<HashMap<String, u64>>,
528    /// Next key counter.
529    next_key: AtomicU64,
530    /// Base path for index persistence.
531    persist_path: Option<PathBuf>,
532}
533
534impl std::fmt::Debug for HnswMemoryIndex {
535    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
536        f.debug_struct("HnswMemoryIndex")
537            .field("size", &self.len())
538            .field("dimensions", &self.index.read().dimensions())
539            .finish()
540    }
541}
542
543impl HnswMemoryIndex {
544    /// Create a new HNSW memory index.
545    ///
546    /// # Arguments
547    /// * `dimensions` — Embedding vector dimensions.
548    /// * `capacity` — Initial capacity hint.
549    /// * `persist_path` — Optional directory for index file persistence.
550    pub fn new(dimensions: usize, capacity: usize, persist_path: Option<PathBuf>) -> Result<Self> {
551        let index = HnswIndex::new(dimensions, capacity)?;
552        Ok(Self {
553            index: RwLock::new(index),
554            key_to_id: RwLock::new(HashMap::new()),
555            id_to_key: RwLock::new(HashMap::new()),
556            next_key: AtomicU64::new(1), // 0 is used by usearch as sentinel
557            persist_path,
558        })
559    }
560
561    /// Try to restore from disk, fall back to new index.
562    pub fn restore_or_new(
563        dimensions: usize,
564        capacity: usize,
565        persist_path: Option<PathBuf>,
566    ) -> Result<Self> {
567        if let Some(ref path) = persist_path {
568            let index_path = path.join("memory.usearch");
569            let mapping_path = path.join("key_map.json");
570
571            if index_path.exists() && mapping_path.exists() {
572                tracing::info!(path = %index_path.display(), "Restoring HNSW index from disk");
573
574                if let Ok(index) = HnswIndex::load(&index_path) {
575                    if let Ok(data) = std::fs::read_to_string(&mapping_path) {
576                        if let Ok((k2i, i2k)) = serde_json::from_str::<(
577                            HashMap<u64, String>,
578                            HashMap<String, u64>,
579                        )>(&data)
580                        {
581                            let max_key = k2i.keys().max().copied().unwrap_or(0);
582                            return Ok(Self {
583                                index: RwLock::new(index),
584                                key_to_id: RwLock::new(k2i),
585                                id_to_key: RwLock::new(i2k),
586                                next_key: AtomicU64::new(max_key + 1),
587                                persist_path,
588                            });
589                        }
590                    }
591                }
592
593                tracing::warn!("Failed to restore HNSW index, creating new one");
594            }
595        }
596
597        Self::new(dimensions, capacity, persist_path)
598    }
599
600    /// Get or create a u64 key for a String ID.
601    fn get_or_create_key(&self, id: &str) -> u64 {
602        // Fast path: check read lock
603        {
604            let i2k = self.id_to_key.read();
605            if let Some(&key) = i2k.get(id) {
606                return key;
607            }
608        }
609
610        // Slow path: write lock
611        let mut i2k = self.id_to_key.write();
612        let mut k2i = self.key_to_id.write();
613
614        // Double-check after acquiring write lock
615        if let Some(&key) = i2k.get(id) {
616            return key;
617        }
618
619        let key = self.next_key.fetch_add(1, Ordering::Relaxed);
620        i2k.insert(id.to_string(), key);
621        k2i.insert(key, id.to_string());
622        key
623    }
624
625    /// Add an entry to the HNSW index.
626    pub fn add_entry(&self, id: &str, vector: &[f32]) -> Result<()> {
627        let key = self.get_or_create_key(id);
628        let mut normalized = vector.to_vec();
629        l2_normalize_f32(&mut normalized);
630        self.index.write().add(key, &normalized)?;
631        Ok(())
632    }
633
634    /// Remove an entry from the index.
635    pub fn remove_entry(&self, id: &str) -> Result<()> {
636        let key = {
637            let i2k = self.id_to_key.read();
638            i2k.get(id).copied()
639        };
640        if let Some(key) = key {
641            self.index.write().remove(key)?;
642            let mut k2i = self.key_to_id.write();
643            let mut i2k = self.id_to_key.write();
644            k2i.remove(&key);
645            i2k.remove(id);
646        }
647        Ok(())
648    }
649
650    /// Search for k nearest neighbors.
651    ///
652    /// Returns (String ID, distance) pairs.
653    pub fn search(&self, query: &[f32], k: usize) -> Result<Vec<(String, f32)>> {
654        let mut normalized = query.to_vec();
655        l2_normalize_f32(&mut normalized);
656
657        let raw = self.index.read().search(&normalized, k)?;
658        let k2i = self.key_to_id.read();
659
660        let results = raw
661            .into_iter()
662            .filter_map(|(key, dist)| k2i.get(&key).map(|id| (id.clone(), dist)))
663            .collect();
664
665        Ok(results)
666    }
667
668    /// Number of entries in the index.
669    pub fn len(&self) -> usize {
670        self.index.read().len()
671    }
672
673    /// Whether the index is empty.
674    pub fn is_empty(&self) -> bool {
675        self.index.read().is_empty()
676    }
677
678    /// Save the index and key mappings to disk.
679    pub fn persist(&self) -> Result<()> {
680        if let Some(ref path) = self.persist_path {
681            std::fs::create_dir_all(path)?;
682
683            let index_path = path.join("memory.usearch");
684            let mapping_path = path.join("key_map.json");
685
686            // Save index
687            self.index.read().save(&index_path)?;
688
689            // Save key mappings
690            let k2i = self.key_to_id.read();
691            let i2k = self.id_to_key.read();
692            let data = serde_json::to_string(&(k2i.clone(), &*i2k))?;
693            std::fs::write(&mapping_path, data)?;
694
695            tracing::debug!(path = %path.display(), entries = self.len(), "HNSW index persisted");
696        }
697        Ok(())
698    }
699}
700
701// ---------------------------------------------------------------------------
702// Semantic search on MemoryManager
703// ---------------------------------------------------------------------------
704
705impl MemoryManager {
706    /// Semantic search using HNSW index.
707    ///
708    /// Unlike `search()` which uses brute-force cosine similarity over the
709    /// in-memory HashMap, `semantic_search()` uses the HNSW approximate
710    /// nearest neighbor index for sub-linear time complexity.
711    ///
712    /// This is the preferred search method when the HNSW index is available
713    /// and populated with dense vectors.
714    ///
715    /// # Arguments
716    /// * `query` — Search query text.
717    /// * `memory_type` — Optional filter by memory type.
718    /// * `limit` — Maximum results to return.
719    /// * `hnsw_index` — The HNSW index to search against.
720    ///
721    /// # Returns
722    /// A list of `SemanticHit` with entry and similarity score.
723    pub async fn semantic_search(
724        &self,
725        query: &str,
726        memory_type: Option<MemoryType>,
727        limit: usize,
728        hnsw_index: &HnswMemoryIndex,
729    ) -> Result<Vec<SemanticHit>> {
730        // Skip if index is empty
731        if hnsw_index.is_empty() {
732            tracing::debug!("HNSW index empty, falling back to keyword search");
733            return self
734                .keyword_search(query, memory_type, limit)
735                .await
736                .map(|entries| {
737                    entries
738                        .into_iter()
739                        .map(|entry| SemanticHit {
740                            entry,
741                            distance: 0.0,
742                            similarity: 0.0,
743                        })
744                        .collect()
745                });
746        }
747
748        // Generate embedding for query
749        let query_vector = self.embedding.embed(query).await?;
750        let query_f32 = match query_vector.to_f32_dense() {
751            Some(v) => v,
752            None => {
753                tracing::debug!("Query embedding is sparse, falling back to keyword search");
754                return self
755                    .keyword_search(query, memory_type, limit)
756                    .await
757                    .map(|entries| {
758                        entries
759                            .into_iter()
760                            .map(|entry| SemanticHit {
761                                entry,
762                                distance: 0.0,
763                                similarity: 0.0,
764                            })
765                            .collect()
766                    });
767            }
768        };
769
770        // Search HNSW index
771        let raw_hits = hnsw_index.search(&query_f32, limit * 2)?;
772
773        // Determine which memory types to search
774        let all_types: &[MemoryType] = &[
775            MemoryType::Conversation,
776            MemoryType::Session,
777            MemoryType::Fact,
778            MemoryType::Episode,
779            MemoryType::Knowledge,
780        ];
781        let types: &[MemoryType] = match memory_type {
782            Some(ref t) => std::slice::from_ref(t),
783            None => all_types,
784        };
785
786        // Load entries and build results
787        let mut results = Vec::new();
788        for (id, distance) in raw_hits {
789            for mt in types {
790                if let Ok(Some(mut entry)) = self
791                    .state_store
792                    .load_json::<MemoryEntry>(mt.category(), &id)
793                    .await
794                {
795                    // Update access stats
796                    entry.access_count += 1;
797                    entry.accessed_at = chrono::Utc::now();
798
799                    let similarity = 1.0 - distance;
800                    results.push(SemanticHit {
801                        entry,
802                        distance,
803                        similarity,
804                    });
805                    break;
806                }
807            }
808            if results.len() >= limit {
809                break;
810            }
811        }
812
813        // Sort by similarity descending
814        results.sort_by(|a, b| {
815            b.similarity
816                .partial_cmp(&a.similarity)
817                .unwrap_or(std::cmp::Ordering::Equal)
818        });
819
820        tracing::debug!(
821            query = %query,
822            hits = results.len(),
823            "Semantic search complete"
824        );
825
826        // Fall back if no results
827        if results.is_empty() {
828            return self
829                .keyword_search(query, memory_type, limit)
830                .await
831                .map(|entries| {
832                    entries
833                        .into_iter()
834                        .map(|entry| SemanticHit {
835                            entry,
836                            distance: 0.0,
837                            similarity: 0.0,
838                        })
839                        .collect()
840                });
841        }
842
843        Ok(results)
844    }
845
846    /// Rebuild the HNSW index from all stored memories.
847    ///
848    /// Call this at startup or after bulk operations.
849    pub async fn rebuild_hnsw_index(&self, hnsw_index: &HnswMemoryIndex) -> Result<usize> {
850        let mut count = 0;
851
852        for mt in &[
853            MemoryType::Conversation,
854            MemoryType::Session,
855            MemoryType::Fact,
856            MemoryType::Episode,
857            MemoryType::Knowledge,
858        ] {
859            if let Ok(names) = self.state_store.list_category(mt.category()).await {
860                for name in names {
861                    if let Ok(Some(entry)) = self
862                        .state_store
863                        .load_json::<MemoryEntry>(mt.category(), &name)
864                        .await
865                    {
866                        let vector = self.embedding.embed(&entry.content).await?;
867                        if let Some(f32_vec) = vector.to_f32_dense() {
868                            if let Err(e) = hnsw_index.add_entry(&entry.id, &f32_vec) {
869                                tracing::warn!(
870                                    id = %entry.id,
871                                    error = %e,
872                                    "Failed to add entry to HNSW index"
873                                );
874                                continue;
875                            }
876                            count += 1;
877                        }
878                    }
879                }
880            }
881        }
882
883        tracing::info!(entries = count, "HNSW index rebuilt");
884        Ok(count)
885    }
886}