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)]
17pub struct SessionMeta {
18 pub source: String,
20
21 pub session: String,
23
24 pub command: String,
26
27 pub timestamp: i64,
29}
30
31#[derive(Debug)]
35pub struct SearchResult {
36 pub id: String,
38
39 pub content: String,
41
42 pub meta: Option<SessionMeta>,
44
45 #[allow(dead_code)] pub similarity: Option<f64>,
48}
49
50pub trait Store {
59 fn index(
63 &mut self,
64 project_id: &str,
65 content: &str,
66 meta: &SessionMeta,
67 ) -> Result<String, Error>;
68
69 fn search(
73 &mut self,
74 project_id: &str,
75 query: &str,
76 limit: usize,
77 ) -> Result<Vec<SearchResult>, Error>;
78
79 fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error>;
83
84 fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error>;
88}
89
90pub struct SqliteStore {
99 conn: Connection,
100}
101
102fn db_path() -> PathBuf {
103 dirs::data_dir()
104 .or_else(dirs::home_dir)
105 .unwrap_or_else(|| PathBuf::from("/tmp"))
106 .join(".oo")
107 .join("oo.db")
108}
109
110fn map_err(e: rusqlite::Error) -> Error {
111 Error::Store(e.to_string())
112}
113
114impl SqliteStore {
115 pub fn open() -> Result<Self, Error> {
119 Self::open_at(&db_path())
120 }
121
122 pub fn open_at(path: &std::path::Path) -> Result<Self, Error> {
126 if let Some(parent) = path.parent() {
127 std::fs::create_dir_all(parent).map_err(|e| Error::Store(e.to_string()))?;
128 }
129 let conn = Connection::open(path).map_err(map_err)?;
130 conn.execute_batch(
131 "CREATE TABLE IF NOT EXISTS entries (
132 id TEXT PRIMARY KEY,
133 project TEXT NOT NULL,
134 content TEXT NOT NULL,
135 metadata TEXT,
136 created INTEGER NOT NULL
137 );
138 CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
139 content,
140 content='entries',
141 content_rowid='rowid'
142 );
143 CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
144 INSERT INTO entries_fts(rowid, content)
145 VALUES (new.rowid, new.content);
146 END;
147 CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
148 INSERT INTO entries_fts(entries_fts, rowid, content)
149 VALUES ('delete', old.rowid, old.content);
150 END;
151 CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
152 INSERT INTO entries_fts(entries_fts, rowid, content)
153 VALUES ('delete', old.rowid, old.content);
154 INSERT INTO entries_fts(rowid, content)
155 VALUES (new.rowid, new.content);
156 END;",
157 )
158 .map_err(map_err)?;
159 Ok(Self { conn })
160 }
161}
162
163impl Store for SqliteStore {
164 fn index(
165 &mut self,
166 project_id: &str,
167 content: &str,
168 meta: &SessionMeta,
169 ) -> Result<String, Error> {
170 let id = uuid::Uuid::new_v4().to_string();
171 let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
172 self.conn
173 .execute(
174 "INSERT INTO entries (id, project, content, metadata, created)
175 VALUES (?1, ?2, ?3, ?4, ?5)",
176 rusqlite::params![id, project_id, content, meta_json, meta.timestamp],
177 )
178 .map_err(map_err)?;
179 Ok(id)
180 }
181
182 fn search(
183 &mut self,
184 project_id: &str,
185 query: &str,
186 limit: usize,
187 ) -> Result<Vec<SearchResult>, Error> {
188 let results = if query.len() >= 2 {
190 let mut stmt = self
191 .conn
192 .prepare(
193 "SELECT e.id, e.content, e.metadata, rank
194 FROM entries_fts f
195 JOIN entries e ON e.rowid = f.rowid
196 WHERE entries_fts MATCH ?1 AND e.project = ?2
197 ORDER BY rank
198 LIMIT ?3",
199 )
200 .map_err(map_err)?;
201
202 let fts_query = query
209 .split_whitespace()
210 .map(|w| format!("\"{}\"", w.replace('"', "")))
211 .collect::<Vec<_>>()
212 .join(" ");
213
214 stmt.query_map(rusqlite::params![fts_query, project_id, limit], |row| {
215 let id: String = row.get(0)?;
216 let content: String = row.get(1)?;
217 let meta_json: Option<String> = row.get(2)?;
218 let rank: f64 = row.get(3)?;
219 Ok(SearchResult {
220 id,
221 content,
222 meta: meta_json.as_deref().and_then(parse_meta),
223 similarity: Some(-rank), })
225 })
226 .map_err(map_err)?
227 .filter_map(|r| r.ok())
228 .collect()
229 } else {
230 let mut stmt = self
231 .conn
232 .prepare(
233 "SELECT id, content, metadata
234 FROM entries
235 WHERE project = ?1 AND content LIKE ?2
236 ORDER BY created DESC
237 LIMIT ?3",
238 )
239 .map_err(map_err)?;
240
241 let like = format!("%{query}%");
242 stmt.query_map(rusqlite::params![project_id, like, limit], |row| {
243 let id: String = row.get(0)?;
244 let content: String = row.get(1)?;
245 let meta_json: Option<String> = row.get(2)?;
246 Ok(SearchResult {
247 id,
248 content,
249 meta: meta_json.as_deref().and_then(parse_meta),
250 similarity: None,
251 })
252 })
253 .map_err(map_err)?
254 .filter_map(|r| r.ok())
255 .collect()
256 };
257
258 Ok(results)
259 }
260
261 fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error> {
262 let ids: Vec<String> = {
264 let mut stmt = self
265 .conn
266 .prepare("SELECT id, metadata FROM entries WHERE project = ?1")
267 .map_err(map_err)?;
268 stmt.query_map(rusqlite::params![project_id], |row| {
269 let id: String = row.get(0)?;
270 let meta_json: Option<String> = row.get(1)?;
271 Ok((id, meta_json))
272 })
273 .map_err(map_err)?
274 .filter_map(|r| r.ok())
275 .filter(|(_, meta_json)| {
276 meta_json
277 .as_deref()
278 .and_then(parse_meta)
279 .is_some_and(|m| m.source == "oo" && m.session == session_id)
280 })
281 .map(|(id, _)| id)
282 .collect()
283 };
284
285 let count = ids.len();
286 for id in &ids {
287 self.conn
288 .execute("DELETE FROM entries WHERE id = ?1", rusqlite::params![id])
289 .map_err(map_err)?;
290 }
291 Ok(count)
292 }
293
294 fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
295 let now = util::now_epoch();
296 let ids: Vec<String> = {
297 let mut stmt = self
298 .conn
299 .prepare("SELECT id, metadata FROM entries WHERE project = ?1")
300 .map_err(map_err)?;
301 stmt.query_map(rusqlite::params![project_id], |row| {
302 let id: String = row.get(0)?;
303 let meta_json: Option<String> = row.get(1)?;
304 Ok((id, meta_json))
305 })
306 .map_err(map_err)?
307 .filter_map(|r| r.ok())
308 .filter(|(_, meta_json)| {
309 meta_json
310 .as_deref()
311 .and_then(parse_meta)
312 .is_some_and(|m| m.source == "oo" && (now - m.timestamp) > max_age_secs)
313 })
314 .map(|(id, _)| id)
315 .collect()
316 };
317
318 let count = ids.len();
319 for id in &ids {
320 self.conn
321 .execute("DELETE FROM entries WHERE id = ?1", rusqlite::params![id])
322 .map_err(map_err)?;
323 }
324 Ok(count)
325 }
326}
327
328#[cfg(feature = "vipune-store")]
337pub struct VipuneStore {
338 store: vipune::MemoryStore,
339}
340
341#[cfg(feature = "vipune-store")]
342impl VipuneStore {
343 pub fn open() -> Result<Self, Error> {
348 let config = vipune::Config::load().map_err(|e| Error::Store(e.to_string()))?;
349 let store =
350 vipune::MemoryStore::new(&config.database_path, &config.embedding_model, config)
351 .map_err(|e| Error::Store(e.to_string()))?;
352 Ok(Self { store })
353 }
354}
355
356#[cfg(feature = "vipune-store")]
357impl Store for VipuneStore {
358 fn index(
359 &mut self,
360 project_id: &str,
361 content: &str,
362 meta: &SessionMeta,
363 ) -> Result<String, Error> {
364 let meta_json = serde_json::to_string(meta).map_err(|e| Error::Store(e.to_string()))?;
365 match self
366 .store
367 .add_with_conflict(project_id, content, Some(&meta_json), true)
368 {
369 Ok(vipune::AddResult::Added { id }) => Ok(id),
370 Ok(vipune::AddResult::Conflicts { .. }) => Ok(String::new()),
371 Err(e) => Err(Error::Store(e.to_string())),
372 }
373 }
374
375 fn search(
376 &mut self,
377 project_id: &str,
378 query: &str,
379 limit: usize,
380 ) -> Result<Vec<SearchResult>, Error> {
381 let memories = self
382 .store
383 .search_hybrid(project_id, query, limit, 0.3)
384 .map_err(|e| Error::Store(e.to_string()))?;
385 Ok(memories
386 .into_iter()
387 .map(|m| SearchResult {
388 id: m.id,
389 meta: m.metadata.as_deref().and_then(parse_meta),
390 content: m.content,
391 similarity: m.similarity,
392 })
393 .collect())
394 }
395
396 fn delete_by_session(&mut self, project_id: &str, session_id: &str) -> Result<usize, Error> {
397 let entries = self
398 .store
399 .list(project_id, 10_000)
400 .map_err(|e| Error::Store(e.to_string()))?;
401 let mut count = 0;
402 for entry in entries {
403 if let Some(meta) = entry.metadata.as_deref().and_then(parse_meta) {
404 if meta.source == "oo" && meta.session == session_id {
405 self.store
406 .delete(&entry.id)
407 .map_err(|e| Error::Store(e.to_string()))?;
408 count += 1;
409 }
410 }
411 }
412 Ok(count)
413 }
414
415 fn cleanup_stale(&mut self, project_id: &str, max_age_secs: i64) -> Result<usize, Error> {
416 let now = util::now_epoch();
417 let entries = self
418 .store
419 .list(project_id, 10_000)
420 .map_err(|e| Error::Store(e.to_string()))?;
421 let mut count = 0;
422 for entry in entries {
423 if let Some(meta) = entry.metadata.as_deref().and_then(parse_meta) {
424 if meta.source == "oo" && (now - meta.timestamp) > max_age_secs {
425 self.store
426 .delete(&entry.id)
427 .map_err(|e| Error::Store(e.to_string()))?;
428 count += 1;
429 }
430 }
431 }
432 Ok(count)
433 }
434}
435
436fn parse_meta(json: &str) -> Option<SessionMeta> {
441 serde_json::from_str(json).ok()
442}
443
444pub fn open() -> Result<Box<dyn Store>, Error> {
446 #[cfg(feature = "vipune-store")]
447 {
448 return Ok(Box::new(VipuneStore::open()?));
449 }
450 #[cfg(not(feature = "vipune-store"))]
451 {
452 Ok(Box::new(SqliteStore::open()?))
453 }
454}
455
456#[cfg(test)]
461#[path = "store_tests.rs"]
462mod tests;