From f3f740a8fed5b4a029ab5a885dac6252ac1606dd Mon Sep 17 00:00:00 2001 From: Hibryda Date: Wed, 11 Mar 2026 04:09:29 +0100 Subject: [PATCH] feat(memora): add Memora adapter with read-only SQLite backend --- v2/src-tauri/src/lib.rs | 40 ++++ v2/src-tauri/src/memora.rs | 333 +++++++++++++++++++++++++++ v2/src/App.svelte | 5 + v2/src/lib/adapters/memora-bridge.ts | 122 ++++++++++ 4 files changed, 500 insertions(+) create mode 100644 v2/src-tauri/src/memora.rs create mode 100644 v2/src/lib/adapters/memora-bridge.ts diff --git a/v2/src-tauri/src/lib.rs b/v2/src-tauri/src/lib.rs index 99cace6..97225b6 100644 --- a/v2/src-tauri/src/lib.rs +++ b/v2/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ mod ctx; mod event_sink; mod fs_watcher; mod groups; +mod memora; mod pty; mod remote; mod sidecar; @@ -28,6 +29,7 @@ struct AppState { file_watcher: Arc, fs_watcher: Arc, ctx_db: Arc, + memora_db: Arc, remote_manager: Arc, _telemetry: telemetry::TelemetryGuard, } @@ -244,6 +246,38 @@ fn ctx_search(state: State<'_, AppState>, query: String) -> Result) -> bool { + state.memora_db.is_available() +} + +#[tauri::command] +fn memora_list( + state: State<'_, AppState>, + tags: Option>, + limit: Option, + offset: Option, +) -> Result { + state.memora_db.list(tags, limit.unwrap_or(50), offset.unwrap_or(0)) +} + +#[tauri::command] +fn memora_search( + state: State<'_, AppState>, + query: String, + tags: Option>, + limit: Option, +) -> Result { + state.memora_db.search(&query, tags, limit.unwrap_or(50)) +} + +#[tauri::command] +fn memora_get(state: State<'_, AppState>, id: i64) -> Result, String> { + state.memora_db.get(id) +} + // --- Claude profile commands (switcher-claude integration) --- #[derive(serde::Serialize)] @@ -827,6 +861,10 @@ pub fn run() { ctx_get_shared, ctx_get_summaries, ctx_search, + memora_available, + memora_list, + memora_search, + memora_get, remote_list, remote_add, remote_remove, @@ -909,6 +947,7 @@ pub fn run() { let file_watcher = Arc::new(FileWatcherManager::new()); let fs_watcher = Arc::new(ProjectFsWatcher::new()); let ctx_db = Arc::new(CtxDb::new()); + let memora_db = Arc::new(memora::MemoraDb::new()); let remote_manager = Arc::new(RemoteManager::new()); // Start local sidecar @@ -924,6 +963,7 @@ pub fn run() { file_watcher, fs_watcher, ctx_db, + memora_db, remote_manager, _telemetry: telemetry_guard, }); diff --git a/v2/src-tauri/src/memora.rs b/v2/src-tauri/src/memora.rs new file mode 100644 index 0000000..57fb167 --- /dev/null +++ b/v2/src-tauri/src/memora.rs @@ -0,0 +1,333 @@ +// memora — Read-only access to the Memora memory database +// Database: ~/.local/share/memora/memories.db (managed by Memora MCP server) + +use rusqlite::{Connection, params}; +use serde::Serialize; +use std::sync::Mutex; + +#[derive(Debug, Clone, Serialize)] +pub struct MemoraNode { + pub id: i64, + pub content: String, + pub tags: Vec, + pub metadata: Option, + pub created_at: Option, + pub updated_at: Option, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MemoraSearchResult { + pub nodes: Vec, + pub total: i64, +} + +pub struct MemoraDb { + conn: Mutex>, +} + +impl MemoraDb { + fn db_path() -> std::path::PathBuf { + dirs::data_dir() + .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join(".local/share")) + .join("memora") + .join("memories.db") + } + + pub fn new() -> Self { + let db_path = Self::db_path(); + + let conn = if db_path.exists() { + Connection::open_with_flags( + &db_path, + rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY | rusqlite::OpenFlags::SQLITE_OPEN_NO_MUTEX, + ).ok() + } else { + None + }; + + Self { conn: Mutex::new(conn) } + } + + /// Check if the database connection is available. + pub fn is_available(&self) -> bool { + let lock = self.conn.lock().unwrap_or_else(|e| e.into_inner()); + lock.is_some() + } + + fn parse_row(row: &rusqlite::Row) -> rusqlite::Result { + let tags_raw: String = row.get(2)?; + let tags: Vec = serde_json::from_str(&tags_raw).unwrap_or_default(); + + let meta_raw: Option = row.get(3)?; + let metadata = meta_raw.and_then(|m| serde_json::from_str(&m).ok()); + + Ok(MemoraNode { + id: row.get(0)?, + content: row.get(1)?, + tags, + metadata, + created_at: row.get(4)?, + updated_at: row.get(5)?, + }) + } + + pub fn list( + &self, + tags: Option>, + limit: i64, + offset: i64, + ) -> Result { + let lock = self.conn.lock().map_err(|_| "memora database lock poisoned".to_string())?; + let conn = lock.as_ref().ok_or("memora database not found")?; + + if let Some(ref tag_list) = tags { + if !tag_list.is_empty() { + return self.list_by_tags(conn, tag_list, limit, offset); + } + } + + let total: i64 = conn + .query_row("SELECT COUNT(*) FROM memories", [], |r| r.get(0)) + .map_err(|e| format!("memora count failed: {e}"))?; + + let mut stmt = conn + .prepare( + "SELECT id, content, tags, metadata, created_at, updated_at + FROM memories ORDER BY id DESC LIMIT ?1 OFFSET ?2", + ) + .map_err(|e| format!("memora query failed: {e}"))?; + + let nodes = stmt + .query_map(params![limit, offset], Self::parse_row) + .map_err(|e| format!("memora query failed: {e}"))? + .collect::, _>>() + .map_err(|e| format!("memora row read failed: {e}"))?; + + Ok(MemoraSearchResult { nodes, total }) + } + + fn list_by_tags( + &self, + conn: &Connection, + tags: &[String], + limit: i64, + offset: i64, + ) -> Result { + // Filter memories whose JSON tags array contains ANY of the given tags. + // Uses json_each() to expand the tags array and match against the filter list. + let placeholders: Vec = tags.iter().enumerate().map(|(i, _)| format!("?{}", i + 1)).collect(); + let in_clause = placeholders.join(", "); + + let count_sql = format!( + "SELECT COUNT(DISTINCT m.id) FROM memories m, json_each(m.tags) j WHERE j.value IN ({in_clause})" + ); + let query_sql = format!( + "SELECT DISTINCT m.id, m.content, m.tags, m.metadata, m.created_at, m.updated_at + FROM memories m, json_each(m.tags) j + WHERE j.value IN ({in_clause}) + ORDER BY m.id DESC LIMIT ?{} OFFSET ?{}", + tags.len() + 1, + tags.len() + 2, + ); + + let tag_params: Vec<&dyn rusqlite::ToSql> = tags.iter().map(|t| t as &dyn rusqlite::ToSql).collect(); + + let count_params = tag_params.clone(); + let total: i64 = conn + .query_row(&count_sql, count_params.as_slice(), |r| r.get(0)) + .map_err(|e| format!("memora count failed: {e}"))?; + + let mut query_params = tag_params; + query_params.push(&limit); + query_params.push(&offset); + + let mut stmt = conn + .prepare(&query_sql) + .map_err(|e| format!("memora query failed: {e}"))?; + + let nodes = stmt + .query_map(query_params.as_slice(), Self::parse_row) + .map_err(|e| format!("memora query failed: {e}"))? + .collect::, _>>() + .map_err(|e| format!("memora row read failed: {e}"))?; + + Ok(MemoraSearchResult { nodes, total }) + } + + pub fn search( + &self, + query: &str, + tags: Option>, + limit: i64, + ) -> Result { + let lock = self.conn.lock().map_err(|_| "memora database lock poisoned".to_string())?; + let conn = lock.as_ref().ok_or("memora database not found")?; + + // Use FTS5 for text search with optional tag filter + let fts_query = query.to_string(); + + if let Some(ref tag_list) = tags { + if !tag_list.is_empty() { + return self.search_with_tags(conn, &fts_query, tag_list, limit); + } + } + + let mut stmt = conn + .prepare( + "SELECT m.id, m.content, m.tags, m.metadata, m.created_at, m.updated_at + FROM memories_fts f + JOIN memories m ON m.id = f.rowid + WHERE memories_fts MATCH ?1 + ORDER BY rank + LIMIT ?2", + ) + .map_err(|e| format!("memora search failed: {e}"))?; + + let nodes = stmt + .query_map(params![fts_query, limit], Self::parse_row) + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("fts5") || msg.contains("syntax") { + format!("Invalid search query: {e}") + } else { + format!("memora search failed: {e}") + } + })? + .collect::, _>>() + .map_err(|e| format!("memora row read failed: {e}"))?; + + let total = nodes.len() as i64; + Ok(MemoraSearchResult { nodes, total }) + } + + fn search_with_tags( + &self, + conn: &Connection, + query: &str, + tags: &[String], + limit: i64, + ) -> Result { + let placeholders: Vec = tags.iter().enumerate().map(|(i, _)| format!("?{}", i + 3)).collect(); + let in_clause = placeholders.join(", "); + + let sql = format!( + "SELECT DISTINCT m.id, m.content, m.tags, m.metadata, m.created_at, m.updated_at + FROM memories_fts f + JOIN memories m ON m.id = f.rowid + JOIN json_each(m.tags) j ON j.value IN ({in_clause}) + WHERE memories_fts MATCH ?1 + ORDER BY rank + LIMIT ?2" + ); + + let mut params: Vec> = Vec::new(); + params.push(Box::new(query.to_string())); + params.push(Box::new(limit)); + for tag in tags { + params.push(Box::new(tag.clone())); + } + let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect(); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("memora search failed: {e}"))?; + + let nodes = stmt + .query_map(param_refs.as_slice(), Self::parse_row) + .map_err(|e| { + let msg = e.to_string(); + if msg.contains("fts5") || msg.contains("syntax") { + format!("Invalid search query: {e}") + } else { + format!("memora search failed: {e}") + } + })? + .collect::, _>>() + .map_err(|e| format!("memora row read failed: {e}"))?; + + let total = nodes.len() as i64; + Ok(MemoraSearchResult { nodes, total }) + } + + pub fn get(&self, id: i64) -> Result, String> { + let lock = self.conn.lock().map_err(|_| "memora database lock poisoned".to_string())?; + let conn = lock.as_ref().ok_or("memora database not found")?; + + let mut stmt = conn + .prepare( + "SELECT id, content, tags, metadata, created_at, updated_at + FROM memories WHERE id = ?1", + ) + .map_err(|e| format!("memora query failed: {e}"))?; + + let mut rows = stmt + .query_map(params![id], Self::parse_row) + .map_err(|e| format!("memora query failed: {e}"))?; + + match rows.next() { + Some(Ok(node)) => Ok(Some(node)), + Some(Err(e)) => Err(format!("memora row read failed: {e}")), + None => Ok(None), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_missing_db() -> MemoraDb { + MemoraDb { conn: Mutex::new(None) } + } + + #[test] + fn test_new_does_not_panic() { + let _db = MemoraDb::new(); + } + + #[test] + fn test_missing_db_not_available() { + let db = make_missing_db(); + assert!(!db.is_available()); + } + + #[test] + fn test_list_missing_db_returns_error() { + let db = make_missing_db(); + let result = db.list(None, 50, 0); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "memora database not found"); + } + + #[test] + fn test_search_missing_db_returns_error() { + let db = make_missing_db(); + let result = db.search("test", None, 50); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "memora database not found"); + } + + #[test] + fn test_get_missing_db_returns_error() { + let db = make_missing_db(); + let result = db.get(1); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "memora database not found"); + } + + #[test] + fn test_list_with_tags_missing_db_returns_error() { + let db = make_missing_db(); + let result = db.list(Some(vec!["bterminal".to_string()]), 50, 0); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "memora database not found"); + } + + #[test] + fn test_search_with_tags_missing_db_returns_error() { + let db = make_missing_db(); + let result = db.search("test", Some(vec!["bterminal".to_string()]), 50); + assert!(result.is_err()); + assert_eq!(result.unwrap_err(), "memora database not found"); + } +} diff --git a/v2/src/App.svelte b/v2/src/App.svelte index b3a1b51..011141d 100644 --- a/v2/src/App.svelte +++ b/v2/src/App.svelte @@ -9,6 +9,8 @@ import { CLAUDE_PROVIDER } from './lib/providers/claude'; import { CODEX_PROVIDER } from './lib/providers/codex'; import { OLLAMA_PROVIDER } from './lib/providers/ollama'; + import { registerMemoryAdapter } from './lib/adapters/memory-adapter'; + import { MemoraAdapter } from './lib/adapters/memora-bridge'; import { loadWorkspace, getActiveTab, setActiveTab, setActiveProject, getEnabledProjects } from './lib/stores/workspace.svelte'; // Workspace components @@ -72,6 +74,9 @@ registerProvider(CLAUDE_PROVIDER); registerProvider(CODEX_PROVIDER); registerProvider(OLLAMA_PROVIDER); + const memora = new MemoraAdapter(); + registerMemoryAdapter(memora); + memora.checkAvailability(); startAgentDispatcher(); startHealthTick(); diff --git a/v2/src/lib/adapters/memora-bridge.ts b/v2/src/lib/adapters/memora-bridge.ts new file mode 100644 index 0000000..c206c73 --- /dev/null +++ b/v2/src/lib/adapters/memora-bridge.ts @@ -0,0 +1,122 @@ +/** + * Memora IPC bridge — read-only access to the Memora memory database. + * Wraps Tauri commands and provides a MemoryAdapter implementation. + */ + +import { invoke } from '@tauri-apps/api/core'; +import type { MemoryAdapter, MemoryNode, MemorySearchResult } from './memory-adapter'; + +// --- Raw IPC types (match Rust structs) --- + +interface MemoraNode { + id: number; + content: string; + tags: string[]; + metadata?: Record; + created_at?: string; + updated_at?: string; +} + +interface MemoraSearchResult { + nodes: MemoraNode[]; + total: number; +} + +// --- IPC wrappers --- + +export async function memoraAvailable(): Promise { + return invoke('memora_available'); +} + +export async function memoraList(options?: { + tags?: string[]; + limit?: number; + offset?: number; +}): Promise { + return invoke('memora_list', { + tags: options?.tags ?? null, + limit: options?.limit ?? 50, + offset: options?.offset ?? 0, + }); +} + +export async function memoraSearch( + query: string, + options?: { tags?: string[]; limit?: number }, +): Promise { + return invoke('memora_search', { + query, + tags: options?.tags ?? null, + limit: options?.limit ?? 50, + }); +} + +export async function memoraGet(id: number): Promise { + return invoke('memora_get', { id }); +} + +// --- MemoryAdapter implementation --- + +function toMemoryNode(n: MemoraNode): MemoryNode { + return { + id: n.id, + content: n.content, + tags: n.tags, + metadata: n.metadata, + created_at: n.created_at, + updated_at: n.updated_at, + }; +} + +function toSearchResult(r: MemoraSearchResult): MemorySearchResult { + return { + nodes: r.nodes.map(toMemoryNode), + total: r.total, + }; +} + +export class MemoraAdapter implements MemoryAdapter { + readonly name = 'memora'; + private _available: boolean | null = null; + + get available(): boolean { + // Optimistic: assume available until first check proves otherwise. + // Actual availability is checked lazily on first operation. + return this._available ?? true; + } + + async checkAvailability(): Promise { + this._available = await memoraAvailable(); + return this._available; + } + + async list(options?: { + tags?: string[]; + limit?: number; + offset?: number; + }): Promise { + const result = await memoraList(options); + this._available = true; + return toSearchResult(result); + } + + async search( + query: string, + options?: { tags?: string[]; limit?: number }, + ): Promise { + const result = await memoraSearch(query, options); + this._available = true; + return toSearchResult(result); + } + + async get(id: string | number): Promise { + const numId = typeof id === 'string' ? parseInt(id, 10) : id; + if (isNaN(numId)) return null; + const node = await memoraGet(numId); + if (node) { + this._available = true; + return toMemoryNode(node); + } + return null; + } +}