Skip to main content

oxi/store/
memory_sqlite.rs

1//! SQLite-backed memory store implementing [`MemoryBackend`].
2//!
3//! Provides persistent storage with WAL journal mode for concurrent access
4//! safety. Embedding storage is reserved for a future cosine-search upgrade;
5//! search currently uses simple SQL `LIKE` matching.
6
7use oxi_agent::tools::{MemoryBackend, MemoryItem, ToolError};
8use rusqlite::{Connection, params};
9use std::path::Path;
10use std::pin::Pin;
11use tokio::sync::Mutex;
12
13/// SQLite-backed memory store.
14///
15/// Implements [`MemoryBackend`] for the `memory_*` agent tools.
16/// Each memory is stored with an auto-generated UUID, kind, content, and subject.
17/// The schema reserves an `embedding` column for future cosine-search support.
18#[derive(Debug)]
19pub struct SqliteMemoryStore {
20    db: Mutex<Connection>,
21}
22
23impl SqliteMemoryStore {
24    /// Open or create a SQLite memory store at `path`.
25    ///
26    /// Uses `:memory:` for an in-memory database. For persistent paths,
27    /// filesystem-aware journal-mode selection is applied (see
28    /// [`oxi_mnemopi::journal::JournalMode`]). On network filesystems the
29    /// engine falls back to `TRUNCATE` mode + per-host DB sibling to avoid
30    /// SIGBUS from mmap'd `-shm`.
31    pub fn open(path: &Path) -> Result<Self, rusqlite::Error> {
32        let is_memory = path == Path::new(":memory:");
33        // Durable primary store: detect journal mode (TRUNCATE on NFS for
34        // SIGBUS safety) but NEVER rewrite the path — user memories must
35        // stay coherent across hosts. See `JournalMode::effective_db_path`.
36        let mode = if is_memory {
37            oxi_mnemopi::journal::JournalMode::Wal
38        } else {
39            oxi_mnemopi::journal::JournalMode::for_db_path(path)
40        };
41        let conn = Connection::open(path)?;
42
43        conn.execute_batch("PRAGMA foreign_keys = ON;")?;
44        conn.execute_batch(&format!(
45            "PRAGMA busy_timeout = {};",
46            mode.busy_timeout_ms()
47        ))?;
48
49        if !is_memory {
50            // Apply the detected (or env-overridden) journal mode. Failures
51            // fall back to SQLite's default `delete` journal — still safe,
52            // just slower than WAL.
53            if let Err(e) = conn.execute_batch(&format!("PRAGMA journal_mode = {};", mode.as_str()))
54            {
55                tracing::warn!(
56                    mode = mode.as_str(),
57                    path = %path.display(),
58                    error = %e,
59                    "failed to set journal_mode; SQLite default will be used"
60                );
61            }
62        }
63
64        conn.execute_batch(
65            "CREATE TABLE IF NOT EXISTS memories (
66                id          TEXT PRIMARY KEY,
67                subject     TEXT NOT NULL,
68                kind        TEXT NOT NULL,
69                content     TEXT NOT NULL,
70                embedding   BLOB,
71                created_at  TEXT NOT NULL DEFAULT (datetime('now')),
72                updated_at  TEXT NOT NULL DEFAULT (datetime('now')),
73                metadata    TEXT
74            );",
75        )?;
76
77        Ok(Self {
78            db: Mutex::new(conn),
79        })
80    }
81}
82
83impl MemoryBackend for SqliteMemoryStore {
84    fn put<'a>(
85        &'a self,
86        content: &'a str,
87        kind: &'a str,
88        subject: &'a str,
89    ) -> Pin<Box<dyn Future<Output = Result<String, ToolError>> + Send + 'a>> {
90        Box::pin(async move {
91            let id = uuid::Uuid::new_v4().to_string();
92            let db = self.db.lock().await;
93            db.execute(
94                "INSERT INTO memories (id, subject, kind, content)
95                 VALUES (?1, ?2, ?3, ?4)",
96                params![id, subject, kind, content],
97            )
98            .map_err(|e| format!("Failed to store memory: {e}"))?;
99            Ok(id)
100        })
101    }
102
103    fn search<'a>(
104        &'a self,
105        query: &'a str,
106        k: usize,
107    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
108        Box::pin(async move {
109            let db = self.db.lock().await;
110            let pattern = format!("%{}%", query.replace('%', "\\%").replace('_', "\\_"));
111            let mut stmt = db
112                .prepare(
113                    "SELECT id, kind, content, subject
114                     FROM memories
115                     WHERE content LIKE ?1 ESCAPE '\\'
116                     ORDER BY length(content) ASC
117                     LIMIT ?2",
118                )
119                .map_err(|e| format!("Failed to prepare search: {e}"))?;
120
121            let results: Vec<MemoryItem> = stmt
122                .query_map(params![pattern, k as i64], |row| {
123                    Ok(MemoryItem {
124                        id: row.get(0)?,
125                        kind: row.get(1)?,
126                        content: row.get(2)?,
127                        subject: row.get(3)?,
128                    })
129                })
130                .map_err(|e| format!("Failed to search memories: {e}"))?
131                .filter_map(|r| r.ok())
132                .collect();
133
134            Ok(results)
135        })
136    }
137
138    fn list<'a>(
139        &'a self,
140        subject: &'a str,
141    ) -> Pin<Box<dyn Future<Output = Result<Vec<MemoryItem>, ToolError>> + Send + 'a>> {
142        Box::pin(async move {
143            let db = self.db.lock().await;
144            let mut stmt = db
145                .prepare(
146                    "SELECT id, kind, content, subject
147                     FROM memories
148                     WHERE subject = ?1
149                     ORDER BY updated_at DESC",
150                )
151                .map_err(|e| format!("Failed to prepare list: {e}"))?;
152
153            let results: Vec<MemoryItem> = stmt
154                .query_map(params![subject], |row| {
155                    Ok(MemoryItem {
156                        id: row.get(0)?,
157                        kind: row.get(1)?,
158                        content: row.get(2)?,
159                        subject: row.get(3)?,
160                    })
161                })
162                .map_err(|e| format!("Failed to list memories: {e}"))?
163                .filter_map(|r| r.ok())
164                .collect();
165
166            Ok(results)
167        })
168    }
169
170    fn delete<'a>(
171        &'a self,
172        id: &'a str,
173    ) -> Pin<Box<dyn Future<Output = Result<(), ToolError>> + Send + 'a>> {
174        Box::pin(async move {
175            let db = self.db.lock().await;
176            db.execute("DELETE FROM memories WHERE id = ?1", params![id])
177                .map_err(|e| format!("Failed to delete memory: {e}"))?;
178            Ok(())
179        })
180    }
181}