Rust · 9012 bytes Raw Blame History
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