// SPDX-License-Identifier: LicenseRef-Commercial // Persistent Agent Memory — project-scoped structured fragments that survive sessions. use rusqlite::params; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct MemoryFragment { pub id: i64, pub project_id: String, pub content: String, pub source: String, pub trust: String, pub confidence: f64, pub created_at: i64, pub ttl_days: i64, pub tags: String, } fn ensure_tables(conn: &rusqlite::Connection) -> Result<(), String> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS pro_memories ( id INTEGER PRIMARY KEY AUTOINCREMENT, project_id TEXT NOT NULL, content TEXT NOT NULL, source TEXT NOT NULL DEFAULT '', trust TEXT NOT NULL DEFAULT 'agent', confidence REAL NOT NULL DEFAULT 1.0, created_at INTEGER NOT NULL, ttl_days INTEGER NOT NULL DEFAULT 90, tags TEXT NOT NULL DEFAULT '' ); CREATE VIRTUAL TABLE IF NOT EXISTS pro_memories_fts USING fts5( content, tags, content=pro_memories, content_rowid=id ); CREATE TRIGGER IF NOT EXISTS pro_memories_ai AFTER INSERT ON pro_memories BEGIN INSERT INTO pro_memories_fts(rowid, content, tags) VALUES (new.id, new.content, new.tags); END; CREATE TRIGGER IF NOT EXISTS pro_memories_ad AFTER DELETE ON pro_memories BEGIN INSERT INTO pro_memories_fts(pro_memories_fts, rowid, content, tags) VALUES ('delete', old.id, old.content, old.tags); END; CREATE TRIGGER IF NOT EXISTS pro_memories_au AFTER UPDATE ON pro_memories BEGIN INSERT INTO pro_memories_fts(pro_memories_fts, rowid, content, tags) VALUES ('delete', old.id, old.content, old.tags); INSERT INTO pro_memories_fts(rowid, content, tags) VALUES (new.id, new.content, new.tags); END;" ).map_err(|e| format!("Failed to create memory tables: {e}")) } fn now_epoch() -> i64 { super::analytics::now_epoch() } fn prune_expired(conn: &rusqlite::Connection) -> Result<(), String> { let now = now_epoch(); conn.execute( "DELETE FROM pro_memories WHERE created_at + (ttl_days * 86400) < ?1", params![now], ).map_err(|e| format!("Prune failed: {e}"))?; Ok(()) } fn row_to_fragment(row: &rusqlite::Row) -> rusqlite::Result { Ok(MemoryFragment { id: row.get("id")?, project_id: row.get("project_id")?, content: row.get("content")?, source: row.get("source")?, trust: row.get("trust")?, confidence: row.get("confidence")?, created_at: row.get("created_at")?, ttl_days: row.get("ttl_days")?, tags: row.get("tags")?, }) } #[tauri::command] pub fn pro_memory_add( project_id: String, content: String, source: Option, tags: Option, ) -> Result { if content.len() > 10000 { return Err("Memory content too long (max 10000 chars)".into()); } let conn = super::open_sessions_db()?; ensure_tables(&conn)?; // Per-project memory cap let count: i64 = conn.query_row( "SELECT COUNT(*) FROM pro_memories WHERE project_id = ?1", params![project_id], |row| row.get(0) ).unwrap_or(0); if count >= 1000 { return Err("Memory limit reached for this project (max 1000 fragments)".into()); } let ts = now_epoch(); let src = source.unwrap_or_default(); let tgs = tags.unwrap_or_default(); conn.execute( "INSERT INTO pro_memories (project_id, content, source, created_at, tags) VALUES (?1, ?2, ?3, ?4, ?5)", params![project_id, content, src, ts, tgs], ).map_err(|e| format!("Failed to add memory: {e}"))?; Ok(conn.last_insert_rowid()) } #[tauri::command] pub fn pro_memory_list(project_id: String, limit: Option) -> Result, String> { let conn = super::open_sessions_db()?; ensure_tables(&conn)?; prune_expired(&conn)?; let lim = limit.unwrap_or(50); let mut stmt = conn.prepare( "SELECT id, project_id, content, source, trust, confidence, created_at, ttl_days, tags FROM pro_memories WHERE project_id = ?1 ORDER BY created_at DESC LIMIT ?2" ).map_err(|e| format!("Query failed: {e}"))?; let rows = stmt.query_map(params![project_id, lim], row_to_fragment) .map_err(|e| format!("Query failed: {e}"))? .collect::, _>>() .map_err(|e| format!("Row read failed: {e}"))?; Ok(rows) } #[tauri::command] pub fn pro_memory_search(project_id: String, query: String) -> Result, String> { let conn = super::open_sessions_db()?; ensure_tables(&conn)?; prune_expired(&conn)?; // Sanitize query to prevent FTS5 operator injection let safe_query = format!("\"{}\"", query.replace('"', "\"\"")); let mut stmt = conn.prepare( "SELECT m.id, m.project_id, m.content, m.source, m.trust, m.confidence, m.created_at, m.ttl_days, m.tags FROM pro_memories m JOIN pro_memories_fts f ON m.id = f.rowid WHERE f.pro_memories_fts MATCH ?1 AND m.project_id = ?2 ORDER BY rank LIMIT 20" ).map_err(|e| format!("Search query failed: {e}"))?; let rows = stmt.query_map(params![safe_query, project_id], row_to_fragment) .map_err(|e| format!("Search failed: {e}"))? .collect::, _>>() .map_err(|e| format!("Row read failed: {e}"))?; Ok(rows) } #[tauri::command] pub fn pro_memory_update( id: i64, content: Option, trust: Option, confidence: Option, ) -> Result<(), String> { let conn = super::open_sessions_db()?; ensure_tables(&conn)?; let tx = conn.unchecked_transaction() .map_err(|e| format!("Transaction failed: {e}"))?; if let Some(c) = content { tx.execute("UPDATE pro_memories SET content = ?2 WHERE id = ?1", params![id, c]) .map_err(|e| format!("Update content failed: {e}"))?; } if let Some(t) = trust { tx.execute("UPDATE pro_memories SET trust = ?2 WHERE id = ?1", params![id, t]) .map_err(|e| format!("Update trust failed: {e}"))?; } if let Some(c) = confidence { tx.execute("UPDATE pro_memories SET confidence = ?2 WHERE id = ?1", params![id, c]) .map_err(|e| format!("Update confidence failed: {e}"))?; } tx.commit().map_err(|e| format!("Commit failed: {e}"))?; Ok(()) } #[tauri::command] pub fn pro_memory_delete(id: i64) -> Result<(), String> { let conn = super::open_sessions_db()?; ensure_tables(&conn)?; conn.execute("DELETE FROM pro_memories WHERE id = ?1", params![id]) .map_err(|e| format!("Delete failed: {e}"))?; Ok(()) } #[tauri::command] pub fn pro_memory_inject(project_id: String, max_tokens: Option) -> Result { let conn = super::open_sessions_db()?; ensure_tables(&conn)?; prune_expired(&conn)?; let max_chars = (max_tokens.unwrap_or(2000) * 3) as usize; // ~3 chars per token heuristic let mut stmt = conn.prepare( "SELECT content, trust, confidence FROM pro_memories WHERE project_id = ?1 ORDER BY confidence DESC, created_at DESC" ).map_err(|e| format!("Query failed: {e}"))?; let entries: Vec<(String, String, f64)> = stmt .query_map(params![project_id], |row| Ok((row.get("content")?, row.get("trust")?, row.get("confidence")?))) .map_err(|e| format!("Query failed: {e}"))? .collect::, _>>() .map_err(|e| format!("Row read failed: {e}"))?; let mut md = String::from("## Project Memory\n\n"); let mut chars = md.len(); for (content, trust, confidence) in &entries { let line = format!("- [{}|{:.1}] {}\n", trust, confidence, content); if chars + line.len() > max_chars { break; } md.push_str(&line); chars += line.len(); } Ok(md) } #[tauri::command] pub fn pro_memory_extract_from_session( project_id: String, session_messages_json: String, ) -> Result, String> { let conn = super::open_sessions_db()?; ensure_tables(&conn)?; let messages: Vec = serde_json::from_str(&session_messages_json) .map_err(|e| format!("Invalid JSON: {e}"))?; let ts = now_epoch(); let mut extracted = Vec::new(); // Patterns to extract: decisions, file references, errors let decision_patterns = ["decision:", "chose ", "decided to ", "instead of "]; let error_patterns = ["error:", "failed:", "Error:", "panic", "FAILED"]; for msg in &messages { let content = msg.get("content").and_then(|c| c.as_str()).unwrap_or(""); // Extract decisions for pattern in &decision_patterns { if content.contains(pattern) { let fragment_content = extract_surrounding(content, pattern, 200); conn.execute( "INSERT INTO pro_memories (project_id, content, source, trust, confidence, created_at, tags) VALUES (?1, ?2, 'auto-extract', 'auto', 0.7, ?3, 'decision')", params![project_id, fragment_content, ts], ).map_err(|e| format!("Insert failed: {e}"))?; let id = conn.last_insert_rowid(); extracted.push(MemoryFragment { id, project_id: project_id.clone(), content: fragment_content, source: "auto-extract".into(), trust: "auto".into(), confidence: 0.7, created_at: ts, ttl_days: 90, tags: "decision".into(), }); break; // One extraction per message } } // Extract errors for pattern in &error_patterns { if content.contains(pattern) { let fragment_content = extract_surrounding(content, pattern, 300); conn.execute( "INSERT INTO pro_memories (project_id, content, source, trust, confidence, created_at, tags) VALUES (?1, ?2, 'auto-extract', 'auto', 0.6, ?3, 'error')", params![project_id, fragment_content, ts], ).map_err(|e| format!("Insert failed: {e}"))?; let id = conn.last_insert_rowid(); extracted.push(MemoryFragment { id, project_id: project_id.clone(), content: fragment_content, source: "auto-extract".into(), trust: "auto".into(), confidence: 0.6, created_at: ts, ttl_days: 90, tags: "error".into(), }); break; } } } Ok(extracted) } /// Extract surrounding text around a pattern match, up to max_chars. fn extract_surrounding(text: &str, pattern: &str, max_chars: usize) -> String { if let Some(pos) = text.find(pattern) { let start = pos.saturating_sub(50); let end = (pos + max_chars).min(text.len()); // Ensure valid UTF-8 boundaries let start = text.floor_char_boundary(start); let end = text.ceil_char_boundary(end); text[start..end].to_string() } else { text.chars().take(max_chars).collect() } } #[cfg(test)] mod tests { use super::*; #[test] fn test_memory_fragment_serializes_camel_case() { let f = MemoryFragment { id: 1, project_id: "proj1".into(), content: "We decided to use SQLite".into(), source: "session-abc".into(), trust: "agent".into(), confidence: 0.9, created_at: 1710000000, ttl_days: 90, tags: "decision,architecture".into(), }; let json = serde_json::to_string(&f).unwrap(); assert!(json.contains("projectId")); assert!(json.contains("createdAt")); assert!(json.contains("ttlDays")); } #[test] fn test_memory_fragment_deserializes() { let json = r#"{"id":1,"projectId":"p","content":"test","source":"s","trust":"human","confidence":1.0,"createdAt":0,"ttlDays":30,"tags":"t"}"#; let f: MemoryFragment = serde_json::from_str(json).unwrap(); assert_eq!(f.project_id, "p"); assert_eq!(f.trust, "human"); } #[test] fn test_extract_surrounding() { let text = "We chose SQLite instead of PostgreSQL for simplicity"; let result = extract_surrounding(text, "chose ", 100); assert!(result.contains("chose SQLite")); } }