1use std::path::PathBuf;
2
3use rusqlite::Connection;
4use serde::{Deserialize, Serialize};
5
6use crate::error::Error;
7use crate::util;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
14pub struct SessionMeta {
15 pub source: String,
16 pub session: String,
17 pub command: String,
18 pub timestamp: i64,
19}
20
21#[derive(Debug)]
22pub struct SearchResult {
23 pub id: String,
24 pub content: String,
25 pub meta: Option<SessionMeta>,
26 #[allow(dead_code)] pub similarity: Option<f64>,
28}
29
30pub trait Store {
35 fn index(
36 &mut self,
37 project_id: &str,
38 content: &str,
39 meta: &SessionMeta,
40 ) -> Result<String, Error>;
41
42 fn search(
43 &mut self,
44 project_id: &str,
45 query: &str,
46 limit: usize,
47 ) -> Result<Vec<SearchResult>, Error>;
48
49 fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error>;
50
51 fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error>;
52}
53
54pub struct SqliteStore {
59 conn: Connection,
60}
61
62fn db_path() -> PathBuf {
63 dirs::data_dir()
64 .or_else(dirs::home_dir)
65 .unwrap_or_else(|| PathBuf::from("/tmp"))
66 .join(".oo")
67 .join("oo.db")
68}
69
70fn map_err(e: rusqlite::Error) -> Error {
71 Error::Store(e.to_string())
72}
73
74impl SqliteStore {
75 pub fn open() -> Result<Self, Error> {
76 Self::open_at(&db_path())
77 }
78
79 pub fn open_at(path: &std::path::Path) -> Result<Self, Error> {
80 if let Some(parent) = path.parent() {
81 std::fs::create_dir_all(parent).map_err(|e| Error::Store(e.to_string()))?;
82 }
83 let conn = Connection::open(path).map_err(map_err)?;
84 conn.execute_batch(
85 "CREATE TABLE IF NOT EXISTS entries (
86 id TEXT PRIMARY KEY,
87 project TEXT NOT NULL,
88 content TEXT NOT NULL,
89 metadata TEXT,
90 created INTEGER NOT NULL
91 );
92 CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
93 content,
94 content='entries',
95 content_rowid='rowid'
96 );
97 CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
98 INSERT INTO entries_fts(rowid, content)
99 VALUES (new.rowid, new.content);
100 END;
101 CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
102 INSERT INTO entries_fts(entries_fts, rowid, content)
103 VALUES ('delete', old.rowid, old.content);
104 END;
105 CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
106 INSERT INTO entries_fts(entries_fts, rowid, content)
107 VALUES ('delete', old.rowid, old.content);
108 INSERT INTO entries_fts(rowid, content)
109 VALUES (new.rowid, new.content);
110 END;",
111 )
112 .map_err(map_err)?;
113 Ok(Self { conn })
114 }
115}
116
117impl Store for SqliteStore {
118 fn index(
119 &mut self,
120 project_id: &str,
121 content: &str,
122 meta: &SessionMeta,
123 ) -> Result<String, Error> {
124 let id = uuid::Uuid::new_v4().to_string();
125 let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
126 self.conn
127 .execute(
128 "INSERT INTO entries (id, project, content, metadata, created)
129 VALUES (?1, ?2, ?3, ?4, ?5)",
130 rusqlite::params![id, project_id, content, meta_json, meta.timestamp],
131 )
132 .map_err(map_err)?;
133 Ok(id)
134 }
135
136 fn search(
137 &mut self,
138 project_id: &str,
139 query: &str,
140 limit: usize,
141 ) -> Result<Vec<SearchResult>, Error> {
142 let results = if query.len() >= 2 {
144 let mut stmt = self
145 .conn
146 .prepare(
147 "SELECT e.id, e.content, e.metadata, rank
148 FROM entries_fts f
149 JOIN entries e ON e.rowid = f.rowid
150 WHERE entries_fts MATCH ?1 AND e.project = ?2
151 ORDER BY rank
152 LIMIT ?3",
153 )
154 .map_err(map_err)?;
155
156 let fts_query = query
163 .split_whitespace()
164 .map(|w| format!("\"{}\"", w.replace('"', "")))
165 .collect::<Vec<_>>()
166 .join(" ");
167
168 stmt.query_map(rusqlite::params![fts_query, project_id, limit], |row| {
169 let id: String = row.get(0)?;
170 let content: String = row.get(1)?;
171 let meta_json: Option<String> = row.get(2)?;
172 let rank: f64 = row.get(3)?;
173 Ok(SearchResult {
174 id,
175 content,
176 meta: meta_json.as_deref().and_then(parse_meta),
177 similarity: Some(-rank), })
179 })
180 .map_err(map_err)?
181 .filter_map(|r| r.ok())
182 .collect()
183 } else {
184 let mut stmt = self
185 .conn
186 .prepare(
187 "SELECT id, content, metadata
188 FROM entries
189 WHERE project = ?1 AND content LIKE ?2
190 ORDER BY created DESC
191 LIMIT ?3",
192 )
193 .map_err(map_err)?;
194
195 let like = format!("%{query}%");
196 stmt.query_map(rusqlite::params![project_id, like, limit], |row| {
197 let id: String = row.get(0)?;
198 let content: String = row.get(1)?;
199 let meta_json: Option<String> = row.get(2)?;
200 Ok(SearchResult {
201 id,
202 content,
203 meta: meta_json.as_deref().and_then(parse_meta),
204 similarity: None,
205 })
206 })
207 .map_err(map_err)?
208 .filter_map(|r| r.ok())
209 .collect()
210 };
211
212 Ok(results)
213 }
214
215 fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error> {
216 let ids: Vec<String> = {
218 let mut stmt = self
219 .conn
220 .prepare("SELECT id, metadata FROM entries WHERE project = ?1")
221 .map_err(map_err)?;
222 stmt.query_map(rusqlite::params![project_id], |row| {
223 let id: String = row.get(0)?;
224 let meta_json: Option<String> = row.get(1)?;
225 Ok((id, meta_json))
226 })
227 .map_err(map_err)?
228 .filter_map(|r| r.ok())
229 .filter(|(_, meta_json)| {
230 meta_json
231 .as_deref()
232 .and_then(parse_meta)
233 .is_some_and(|m| m.source == "oo" && m.session == session_id)
234 })
235 .map(|(id, _)| id)
236 .collect()
237 };
238
239 let count = ids.len();
240 for id in &ids {
241 self.conn
242 .execute("DELETE FROM entries WHERE id = ?1", rusqlite::params![id])
243 .map_err(map_err)?;
244 }
245 Ok(count)
246 }
247
248 fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
249 let now = util::now_epoch();
250 let ids: Vec<String> = {
251 let mut stmt = self
252 .conn
253 .prepare("SELECT id, metadata FROM entries WHERE project = ?1")
254 .map_err(map_err)?;
255 stmt.query_map(rusqlite::params![project_id], |row| {
256 let id: String = row.get(0)?;
257 let meta_json: Option<String> = row.get(1)?;
258 Ok((id, meta_json))
259 })
260 .map_err(map_err)?
261 .filter_map(|r| r.ok())
262 .filter(|(_, meta_json)| {
263 meta_json
264 .as_deref()
265 .and_then(parse_meta)
266 .is_some_and(|m| m.source == "oo" && (now - m.timestamp) > max_age_secs)
267 })
268 .map(|(id, _)| id)
269 .collect()
270 };
271
272 let count = ids.len();
273 for id in &ids {
274 self.conn
275 .execute("DELETE FROM entries WHERE id = ?1", rusqlite::params![id])
276 .map_err(map_err)?;
277 }
278 Ok(count)
279 }
280}
281
282#[cfg(feature = "vipune-store")]
287pub struct VipuneStore {
288 store: vipune::MemoryStore,
289}
290
291#[cfg(feature = "vipune-store")]
292impl VipuneStore {
293 pub fn open() -> Result<Self, Error> {
294 let config = vipune::Config::load().map_err(|e| Error::Store(e.to_string()))?;
295 let store =
296 vipune::MemoryStore::new(&config.database_path, &config.embedding_model, config)
297 .map_err(|e| Error::Store(e.to_string()))?;
298 Ok(Self { store })
299 }
300}
301
302#[cfg(feature = "vipune-store")]
303impl Store for VipuneStore {
304 fn index(
305 &mut self,
306 project_id: &str,
307 content: &str,
308 meta: &SessionMeta,
309 ) -> Result<String, Error> {
310 let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
311 match self
312 .store
313 .add_with_conflict(project_id, content, Some(&meta_json), true)
314 {
315 Ok(vipune::AddResult::Added { id }) => Ok(id),
316 Ok(vipune::AddResult::Conflicts { .. }) => Ok(String::new()),
317 Err(e) => Err(Error::Store(e.to_string())),
318 }
319 }
320
321 fn search(
322 &mut self,
323 project_id: &str,
324 query: &str,
325 limit: usize,
326 ) -> Result<Vec<SearchResult>, Error> {
327 let memories = self
328 .store
329 .search_hybrid(project_id, query, limit, 0.3)
330 .map_err(|e| Error::Store(e.to_string()))?;
331 Ok(memories
332 .into_iter()
333 .map(|m| SearchResult {
334 id: m.id,
335 meta: m.metadata.as_deref().and_then(parse_meta),
336 content: m.content,
337 similarity: m.similarity,
338 })
339 .collect())
340 }
341
342 fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error> {
343 let entries = self
344 .store
345 .list(project_id, 10_000)
346 .map_err(|e| Error::Store(e.to_string()))?;
347 let mut count = 0;
348 for entry in entries {
349 if let Some(meta) = entry.metadata.as_deref().and_then(parse_meta) {
350 if meta.source == "oo" && meta.session == session_id {
351 self.store
352 .delete(&entry.id)
353 .map_err(|e| Error::Store(e.to_string()))?;
354 count += 1;
355 }
356 }
357 }
358 Ok(count)
359 }
360
361 fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
362 let now = util::now_epoch();
363 let entries = self
364 .store
365 .list(project_id, 10_000)
366 .map_err(|e| Error::Store(e.to_string()))?;
367 let mut count = 0;
368 for entry in entries {
369 if let Some(meta) = entry.metadata.as_deref().and_then(parse_meta) {
370 if meta.source == "oo" && (now - meta.timestamp) > max_age_secs {
371 self.store
372 .delete(&entry.id)
373 .map_err(|e| Error::Store(e.to_string()))?;
374 count += 1;
375 }
376 }
377 }
378 Ok(count)
379 }
380}
381
382fn parse_meta(json: &str) -> Option<SessionMeta> {
387 serde_json::from_str(json).ok()
388}
389
390pub fn open() -> Result<Box<dyn Store>, Error> {
392 #[cfg(feature = "vipune-store")]
393 {
394 return Ok(Box::new(VipuneStore::open()?));
395 }
396 #[cfg(not(feature = "vipune-store"))]
397 {
398 Ok(Box::new(SqliteStore::open()?))
399 }
400}
401
402#[cfg(test)]
407#[path = "store_tests.rs"]
408mod tests;