Skip to main content

kernex_memory/store/
mod.rs

1//! SQLite-backed persistent memory store.
2//!
3//! Split into focused submodules:
4//! - `conversations` — conversation lifecycle (create, find, close, summaries)
5//! - `messages` — message storage and full-text search
6//! - `facts` — user facts, aliases, and limitations
7//! - `tasks` — scheduled task CRUD and dedup
8//! - `context` — context building and user profile formatting
9//! - `context_helpers` — onboarding stages, system prompt composition, language detection
10
11mod context;
12mod context_helpers;
13mod conversations;
14mod facts;
15mod messages;
16mod outcomes;
17mod sessions;
18mod tasks;
19
20pub use context::{detect_language, format_user_profile};
21pub use tasks::DueTask;
22
23use kernex_core::{config::MemoryConfig, error::KernexError, shellexpand};
24use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
25use sqlx::SqlitePool;
26use std::str::FromStr;
27use tracing::info;
28
29/// How long (in minutes) before a conversation is considered idle.
30const CONVERSATION_TIMEOUT_MINUTES: i64 = 120;
31
32/// Persistent memory store backed by SQLite.
33#[derive(Clone)]
34pub struct Store {
35    pool: SqlitePool,
36    max_context_messages: usize,
37}
38
39impl Store {
40    /// Create a new store, running migrations on first use.
41    pub async fn new(config: &MemoryConfig) -> Result<Self, KernexError> {
42        let db_path = shellexpand(&config.db_path);
43
44        // Ensure parent directory exists.
45        if let Some(parent) = std::path::Path::new(&db_path).parent() {
46            std::fs::create_dir_all(parent)
47                .map_err(|e| KernexError::Store(format!("failed to create data dir: {e}")))?;
48        }
49
50        let opts = SqliteConnectOptions::from_str(&format!("sqlite:{db_path}"))
51            .map_err(|e| KernexError::Store(format!("invalid db path: {e}")))?
52            .create_if_missing(true)
53            .journal_mode(sqlx::sqlite::SqliteJournalMode::Wal);
54
55        let pool = SqlitePoolOptions::new()
56            .max_connections(4)
57            .connect_with(opts)
58            .await
59            .map_err(|e| KernexError::Store(format!("failed to connect to sqlite: {e}")))?;
60
61        Self::run_migrations(&pool).await?;
62
63        info!("Memory store initialized at {db_path}");
64
65        Ok(Self {
66            pool,
67            max_context_messages: config.max_context_messages,
68        })
69    }
70
71    /// Get a reference to the underlying connection pool.
72    pub fn pool(&self) -> &SqlitePool {
73        &self.pool
74    }
75
76    /// Get the database file size in bytes.
77    pub async fn db_size(&self) -> Result<u64, KernexError> {
78        let (page_count,): (i64,) = sqlx::query_as("PRAGMA page_count")
79            .fetch_one(&self.pool)
80            .await
81            .map_err(|e| KernexError::Store(format!("pragma failed: {e}")))?;
82
83        let (page_size,): (i64,) = sqlx::query_as("PRAGMA page_size")
84            .fetch_one(&self.pool)
85            .await
86            .map_err(|e| KernexError::Store(format!("pragma failed: {e}")))?;
87
88        Ok((page_count * page_size) as u64)
89    }
90
91    /// Run SQL migrations, tracking which have already been applied.
92    pub(crate) async fn run_migrations(pool: &SqlitePool) -> Result<(), KernexError> {
93        sqlx::raw_sql(
94            "CREATE TABLE IF NOT EXISTS _migrations (
95                name TEXT PRIMARY KEY,
96                applied_at TEXT NOT NULL DEFAULT (datetime('now'))
97            );",
98        )
99        .execute(pool)
100        .await
101        .map_err(|e| KernexError::Store(format!("failed to create migrations table: {e}")))?;
102
103        // Bootstrap: if _migrations is empty but tables already exist from
104        // a pre-tracking era, mark all existing migrations as applied.
105        let count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM _migrations")
106            .fetch_one(pool)
107            .await
108            .map_err(|e| KernexError::Store(format!("failed to count migrations: {e}")))?;
109
110        if count.0 == 0 {
111            let has_summary: bool = sqlx::query_scalar::<_, String>(
112                "SELECT sql FROM sqlite_master WHERE type='table' AND name='conversations'",
113            )
114            .fetch_optional(pool)
115            .await
116            .ok()
117            .flatten()
118            .map(|sql| sql.contains("summary"))
119            .unwrap_or(false);
120
121            if has_summary {
122                for name in &["001_init", "002_audit_log", "003_memory_enhancement"] {
123                    sqlx::query("INSERT OR IGNORE INTO _migrations (name) VALUES (?)")
124                        .bind(name)
125                        .execute(pool)
126                        .await
127                        .map_err(|e| {
128                            KernexError::Store(format!("failed to bootstrap migration {name}: {e}"))
129                        })?;
130                }
131            }
132        }
133
134        let migrations: &[(&str, &str)] = &[
135            ("001_init", include_str!("../../migrations/001_init.sql")),
136            (
137                "002_audit_log",
138                include_str!("../../migrations/002_audit_log.sql"),
139            ),
140            (
141                "003_memory_enhancement",
142                include_str!("../../migrations/003_memory_enhancement.sql"),
143            ),
144            (
145                "004_fts5_recall",
146                include_str!("../../migrations/004_fts5_recall.sql"),
147            ),
148            (
149                "005_scheduled_tasks",
150                include_str!("../../migrations/005_scheduled_tasks.sql"),
151            ),
152            (
153                "006_limitations",
154                include_str!("../../migrations/006_limitations.sql"),
155            ),
156            (
157                "007_task_type",
158                include_str!("../../migrations/007_task_type.sql"),
159            ),
160            (
161                "008_user_aliases",
162                include_str!("../../migrations/008_user_aliases.sql"),
163            ),
164            (
165                "009_task_retry",
166                include_str!("../../migrations/009_task_retry.sql"),
167            ),
168            (
169                "010_outcomes",
170                include_str!("../../migrations/010_outcomes.sql"),
171            ),
172            (
173                "011_project_learning",
174                include_str!("../../migrations/011_project_learning.sql"),
175            ),
176            (
177                "012_project_sessions",
178                include_str!("../../migrations/012_project_sessions.sql"),
179            ),
180            (
181                "013_multi_lessons",
182                include_str!("../../migrations/013_multi_lessons.sql"),
183            ),
184        ];
185
186        for (name, sql) in migrations {
187            let applied: Option<(String,)> =
188                sqlx::query_as("SELECT name FROM _migrations WHERE name = ?")
189                    .bind(name)
190                    .fetch_optional(pool)
191                    .await
192                    .map_err(|e| {
193                        KernexError::Store(format!("failed to check migration {name}: {e}"))
194                    })?;
195
196            if applied.is_some() {
197                continue;
198            }
199
200            sqlx::raw_sql(sql)
201                .execute(pool)
202                .await
203                .map_err(|e| KernexError::Store(format!("migration {name} failed: {e}")))?;
204
205            sqlx::query("INSERT INTO _migrations (name) VALUES (?)")
206                .bind(name)
207                .execute(pool)
208                .await
209                .map_err(|e| {
210                    KernexError::Store(format!("failed to record migration {name}: {e}"))
211                })?;
212        }
213        Ok(())
214    }
215}
216
217#[cfg(test)]
218mod tests;