| 1 | //! Read-only accessor for the claude-mem plugin's SQLite database at |
| 2 | //! `~/.claude-mem/claude-mem.db`. |
| 3 | //! |
| 4 | //! We don't use this DB as a discovery source (disk is already |
| 5 | //! complete). We use it for **title enrichment**: the observer agent |
| 6 | //! running as part of claude-mem writes per-prompt summaries to |
| 7 | //! `session_summaries`, and the `request` field of the first prompt |
| 8 | //! in each session is a gorgeous AI-refined title like "Investigate |
| 9 | //! macOS ARM64 PTY test failures — root cause in …". This is vastly |
| 10 | //! better than whatever the user's first raw message happened to be |
| 11 | //! (which is often an IDE injection, a pasted error dump, or a |
| 12 | //! `<local-command-caveat>` block). |
| 13 | //! |
| 14 | //! Graceful degradation: if the DB file is missing, unreadable, or |
| 15 | //! has an incompatible schema, [`load_title_map`] returns an empty |
| 16 | //! map and callers fall through to the in-jsonl heuristic. |
| 17 | |
| 18 | use std::collections::HashMap; |
| 19 | use std::path::{Path, PathBuf}; |
| 20 | |
| 21 | use rusqlite::{Connection, OpenFlags}; |
| 22 | |
| 23 | /// A title hint sourced from claude-mem. Both fields are optional; |
| 24 | /// callers pick whichever is populated. |
| 25 | #[derive(Debug, Clone, Default)] |
| 26 | pub struct ClaudeMemTitle { |
| 27 | /// User-set `custom_title` from `sdk_sessions`. Rarely populated. |
| 28 | pub custom_title: Option<String>, |
| 29 | /// AI-refined first-prompt `request` from `session_summaries`. |
| 30 | pub first_request: Option<String>, |
| 31 | } |
| 32 | |
| 33 | impl ClaudeMemTitle { |
| 34 | /// Pick the best string to display, preferring the explicit |
| 35 | /// user-set title over the AI-generated first request. |
| 36 | pub fn best(&self) -> Option<&str> { |
| 37 | self.custom_title |
| 38 | .as_deref() |
| 39 | .or(self.first_request.as_deref()) |
| 40 | } |
| 41 | } |
| 42 | |
| 43 | /// Default location of the claude-mem database. |
| 44 | pub fn default_db_path() -> Option<PathBuf> { |
| 45 | dirs::home_dir().map(|h| h.join(".claude-mem/claude-mem.db")) |
| 46 | } |
| 47 | |
| 48 | /// Open the DB read-only and return a map from `content_session_id` |
| 49 | /// → [`ClaudeMemTitle`]. Any error at this level (missing file, |
| 50 | /// schema drift, etc.) is logged at `debug!` and converted to an |
| 51 | /// empty map — the caller always gets something valid. |
| 52 | pub fn load_title_map(db_path: &Path) -> HashMap<String, ClaudeMemTitle> { |
| 53 | if !db_path.exists() { |
| 54 | return HashMap::new(); |
| 55 | } |
| 56 | |
| 57 | match load_title_map_inner(db_path) { |
| 58 | Ok(map) => map, |
| 59 | Err(err) => { |
| 60 | tracing::debug!( |
| 61 | path = ?db_path, |
| 62 | error = %err, |
| 63 | "claude-mem title map load failed; falling back to in-jsonl titles" |
| 64 | ); |
| 65 | HashMap::new() |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | |
| 70 | fn load_title_map_inner( |
| 71 | db_path: &Path, |
| 72 | ) -> Result<HashMap<String, ClaudeMemTitle>, rusqlite::Error> { |
| 73 | // SQLITE_OPEN_READ_ONLY guarantees we don't accidentally touch |
| 74 | // the db. The claude-mem plugin may be writing to it concurrently |
| 75 | // — rusqlite's default WAL behavior handles that fine. |
| 76 | let conn = Connection::open_with_flags( |
| 77 | db_path, |
| 78 | OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX, |
| 79 | )?; |
| 80 | |
| 81 | let mut map: HashMap<String, ClaudeMemTitle> = HashMap::new(); |
| 82 | |
| 83 | // Pass 1: custom_title from sdk_sessions. |
| 84 | { |
| 85 | let mut stmt = conn.prepare( |
| 86 | "SELECT content_session_id, custom_title \ |
| 87 | FROM sdk_sessions \ |
| 88 | WHERE content_session_id IS NOT NULL \ |
| 89 | AND custom_title IS NOT NULL \ |
| 90 | AND trim(custom_title) != ''", |
| 91 | )?; |
| 92 | let rows = stmt.query_map([], |row| { |
| 93 | let id: String = row.get(0)?; |
| 94 | let title: String = row.get(1)?; |
| 95 | Ok((id, title)) |
| 96 | })?; |
| 97 | for row in rows { |
| 98 | let (id, title) = row?; |
| 99 | map.entry(id).or_default().custom_title = Some(title); |
| 100 | } |
| 101 | } |
| 102 | |
| 103 | // Pass 2: first-prompt request from session_summaries, joined to |
| 104 | // sdk_sessions to map memory_session_id → content_session_id. |
| 105 | { |
| 106 | let mut stmt = conn.prepare( |
| 107 | "SELECT sdk.content_session_id, ss.request \ |
| 108 | FROM session_summaries ss \ |
| 109 | JOIN sdk_sessions sdk ON sdk.memory_session_id = ss.memory_session_id \ |
| 110 | WHERE sdk.content_session_id IS NOT NULL \ |
| 111 | AND ss.prompt_number = 1 \ |
| 112 | AND ss.request IS NOT NULL \ |
| 113 | AND trim(ss.request) != ''", |
| 114 | )?; |
| 115 | let rows = stmt.query_map([], |row| { |
| 116 | let id: String = row.get(0)?; |
| 117 | let request: String = row.get(1)?; |
| 118 | Ok((id, request)) |
| 119 | })?; |
| 120 | for row in rows { |
| 121 | let (id, request) = row?; |
| 122 | let entry = map.entry(id).or_default(); |
| 123 | // Only fill first_request if we don't already have one |
| 124 | // for this session (shouldn't happen given prompt_number=1 |
| 125 | // is unique per session, but guard anyway). |
| 126 | if entry.first_request.is_none() { |
| 127 | entry.first_request = Some(request); |
| 128 | } |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | Ok(map) |
| 133 | } |
| 134 | |
| 135 | #[cfg(test)] |
| 136 | mod tests { |
| 137 | use super::*; |
| 138 | use tempfile::tempdir; |
| 139 | |
| 140 | fn seed_db(path: &Path) { |
| 141 | let conn = Connection::open(path).unwrap(); |
| 142 | conn.execute_batch( |
| 143 | r#" |
| 144 | CREATE TABLE sdk_sessions ( |
| 145 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 146 | content_session_id TEXT UNIQUE, |
| 147 | memory_session_id TEXT UNIQUE, |
| 148 | project TEXT NOT NULL, |
| 149 | user_prompt TEXT, |
| 150 | started_at TEXT NOT NULL, |
| 151 | started_at_epoch INTEGER NOT NULL, |
| 152 | completed_at TEXT, |
| 153 | completed_at_epoch INTEGER, |
| 154 | status TEXT NOT NULL DEFAULT 'active', |
| 155 | custom_title TEXT |
| 156 | ); |
| 157 | CREATE TABLE session_summaries ( |
| 158 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
| 159 | memory_session_id TEXT NOT NULL, |
| 160 | project TEXT NOT NULL, |
| 161 | request TEXT, |
| 162 | prompt_number INTEGER, |
| 163 | created_at TEXT NOT NULL, |
| 164 | created_at_epoch INTEGER NOT NULL |
| 165 | ); |
| 166 | |
| 167 | INSERT INTO sdk_sessions (content_session_id, memory_session_id, project, started_at, started_at_epoch, status, custom_title) |
| 168 | VALUES |
| 169 | ('cs-1', 'mem-1', 'alpha', '2026-04-10', 1, 'completed', NULL), |
| 170 | ('cs-2', 'mem-2', 'beta', '2026-04-11', 2, 'completed', 'User-set title'), |
| 171 | ('cs-3', 'mem-3', 'gamma', '2026-04-11', 3, 'failed', NULL); |
| 172 | |
| 173 | INSERT INTO session_summaries (memory_session_id, project, request, prompt_number, created_at, created_at_epoch) |
| 174 | VALUES |
| 175 | ('mem-1', 'alpha', 'AI-refined title for session one', 1, '2026-04-10', 1), |
| 176 | ('mem-1', 'alpha', 'Second prompt summary', 2, '2026-04-10', 2), |
| 177 | ('mem-2', 'beta', 'First prompt of session two', 1, '2026-04-11', 3), |
| 178 | ('mem-3', 'gamma', '', 1, '2026-04-11', 4); |
| 179 | "#, |
| 180 | ) |
| 181 | .unwrap(); |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn loads_ai_titles_for_sessions_with_first_prompt() { |
| 186 | let tmp = tempdir().unwrap(); |
| 187 | let db_path = tmp.path().join("claude-mem.db"); |
| 188 | seed_db(&db_path); |
| 189 | |
| 190 | let map = load_title_map(&db_path); |
| 191 | let one = map.get("cs-1").expect("cs-1 should be present"); |
| 192 | assert_eq!( |
| 193 | one.first_request.as_deref(), |
| 194 | Some("AI-refined title for session one"), |
| 195 | ); |
| 196 | assert!(one.custom_title.is_none()); |
| 197 | assert_eq!(one.best(), Some("AI-refined title for session one")); |
| 198 | } |
| 199 | |
| 200 | #[test] |
| 201 | fn custom_title_wins_over_first_request_when_both_present() { |
| 202 | let tmp = tempdir().unwrap(); |
| 203 | let db_path = tmp.path().join("claude-mem.db"); |
| 204 | seed_db(&db_path); |
| 205 | |
| 206 | let map = load_title_map(&db_path); |
| 207 | let two = map.get("cs-2").expect("cs-2 should be present"); |
| 208 | assert_eq!(two.custom_title.as_deref(), Some("User-set title")); |
| 209 | assert_eq!( |
| 210 | two.first_request.as_deref(), |
| 211 | Some("First prompt of session two"), |
| 212 | ); |
| 213 | assert_eq!(two.best(), Some("User-set title")); |
| 214 | } |
| 215 | |
| 216 | #[test] |
| 217 | fn empty_request_is_filtered_out() { |
| 218 | let tmp = tempdir().unwrap(); |
| 219 | let db_path = tmp.path().join("claude-mem.db"); |
| 220 | seed_db(&db_path); |
| 221 | |
| 222 | let map = load_title_map(&db_path); |
| 223 | // cs-3 had an empty request → shouldn't contribute a title. |
| 224 | assert!(!map.contains_key("cs-3")); |
| 225 | } |
| 226 | |
| 227 | #[test] |
| 228 | fn missing_db_returns_empty_map_no_error() { |
| 229 | let tmp = tempdir().unwrap(); |
| 230 | let bogus = tmp.path().join("nope.db"); |
| 231 | let map = load_title_map(&bogus); |
| 232 | assert!(map.is_empty()); |
| 233 | } |
| 234 | |
| 235 | #[test] |
| 236 | fn corrupt_db_returns_empty_map_no_panic() { |
| 237 | let tmp = tempdir().unwrap(); |
| 238 | let bad = tmp.path().join("bad.db"); |
| 239 | std::fs::write(&bad, b"not-a-sqlite-db-just-garbage").unwrap(); |
| 240 | let map = load_title_map(&bad); |
| 241 | assert!(map.is_empty()); |
| 242 | } |
| 243 | } |
| 244 |