kernex_memory/store/
mod.rs1mod 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
29const CONVERSATION_TIMEOUT_MINUTES: i64 = 120;
31
32#[derive(Clone)]
34pub struct Store {
35 pool: SqlitePool,
36 max_context_messages: usize,
37}
38
39impl Store {
40 pub async fn new(config: &MemoryConfig) -> Result<Self, KernexError> {
42 let db_path = shellexpand(&config.db_path);
43
44 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 pub fn pool(&self) -> &SqlitePool {
73 &self.pool
74 }
75
76 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 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 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;