1use std::path::Path;
2
3use imp_llm::truncate_chars_with_suffix;
4use rusqlite::{params, Connection, OptionalExtension};
5
6use crate::error::Result;
7use crate::session::{SessionEntry, SessionManager};
8
9pub struct SessionIndex {
11 db: Connection,
12}
13
14#[derive(Debug, Clone)]
16pub struct SessionSearchHit {
17 pub session_id: String,
18 pub cwd: String,
19 pub created_at: u64,
20 pub snippet: String,
21 pub message_count: usize,
22 pub first_message: Option<String>,
23}
24
25impl SessionIndex {
26 pub fn open(path: &Path) -> Result<Self> {
28 if let Some(parent) = path.parent() {
29 std::fs::create_dir_all(parent)?;
30 }
31
32 let db = Connection::open(path)?;
33 db.execute_batch(
34 "CREATE TABLE IF NOT EXISTS sessions (
35 id TEXT PRIMARY KEY,
36 cwd TEXT NOT NULL,
37 created_at INTEGER NOT NULL,
38 message_count INTEGER NOT NULL,
39 first_message TEXT
40 );
41
42 CREATE VIRTUAL TABLE IF NOT EXISTS session_content USING fts5(
43 session_id,
44 content,
45 tokenize='porter unicode61'
46 );",
47 )?;
48 Ok(Self { db })
49 }
50
51 pub fn index_session(&self, session: &SessionManager) -> Result<()> {
56 let session_id = session
57 .path()
58 .and_then(|p| p.file_stem())
59 .map(|s| s.to_string_lossy().to_string())
60 .unwrap_or_else(|| "unknown".to_string());
61
62 let mut cwd = String::new();
63 let mut created_at: u64 = 0;
64 let mut message_count: usize = 0;
65 let mut first_message: Option<String> = None;
66 let mut content_parts: Vec<String> = Vec::new();
67
68 for entry in session.entries() {
69 match entry {
70 SessionEntry::Header {
71 cwd: c,
72 created_at: t,
73 ..
74 } => {
75 cwd = c.clone();
76 created_at = *t;
77 }
78 SessionEntry::Message { message, .. } => {
79 message_count += 1;
80 let text = extract_message_text(message);
81 if !text.is_empty() {
82 if first_message.is_none() {
83 first_message = Some(truncate(&text, 200));
84 }
85 content_parts.push(text);
86 }
87 }
88 SessionEntry::Compaction { summary, .. } => {
89 content_parts.push(summary.clone());
90 }
91 _ => {}
92 }
93 }
94
95 if content_parts.is_empty() {
96 return Ok(());
97 }
98
99 let content = content_parts.join("\n");
100
101 self.db.execute(
103 "INSERT INTO sessions (id, cwd, created_at, message_count, first_message)
104 VALUES (?1, ?2, ?3, ?4, ?5)
105 ON CONFLICT(id) DO UPDATE SET
106 message_count = excluded.message_count,
107 first_message = excluded.first_message",
108 params![
109 session_id,
110 cwd,
111 created_at as i64,
112 message_count as i64,
113 first_message
114 ],
115 )?;
116
117 self.db.execute(
119 "DELETE FROM session_content WHERE session_id = ?1",
120 params![session_id],
121 )?;
122 self.db.execute(
123 "INSERT INTO session_content (session_id, content) VALUES (?1, ?2)",
124 params![session_id, content],
125 )?;
126
127 Ok(())
128 }
129
130 pub fn search(&self, query: &str, limit: usize) -> Result<Vec<SessionSearchHit>> {
132 let mut stmt = self.db.prepare(
133 "SELECT
134 sc.session_id,
135 s.cwd,
136 s.created_at,
137 snippet(session_content, 1, '>>>', '<<<', '...', 40) as snippet,
138 s.message_count,
139 s.first_message
140 FROM session_content sc
141 JOIN sessions s ON s.id = sc.session_id
142 WHERE session_content MATCH ?1
143 ORDER BY rank, s.created_at DESC
144 LIMIT ?2",
145 )?;
146
147 let rows = stmt.query_map(params![query, limit as i64], |row| {
148 Ok(SessionSearchHit {
149 session_id: row.get(0)?,
150 cwd: row.get(1)?,
151 created_at: row.get::<_, i64>(2)? as u64,
152 snippet: row.get(3)?,
153 message_count: row.get::<_, i64>(4)? as usize,
154 first_message: row.get(5)?,
155 })
156 })?;
157
158 let mut results = Vec::new();
159 for row in rows {
160 results.push(row?);
161 }
162 Ok(results)
163 }
164
165 pub fn is_indexed(&self, session_id: &str) -> bool {
167 self.db
168 .query_row(
169 "SELECT 1 FROM sessions WHERE id = ?1",
170 params![session_id],
171 |_| Ok(()),
172 )
173 .optional()
174 .ok()
175 .flatten()
176 .is_some()
177 }
178}
179
180fn extract_message_text(message: &imp_llm::Message) -> String {
182 let blocks = match message {
183 imp_llm::Message::User(u) => &u.content,
184 imp_llm::Message::Assistant(a) => &a.content,
185 imp_llm::Message::ToolResult(_) => return String::new(),
186 };
187
188 blocks
189 .iter()
190 .filter_map(|b| match b {
191 imp_llm::ContentBlock::Text { text } => Some(text.as_str()),
192 _ => None,
193 })
194 .collect::<Vec<_>>()
195 .join(" ")
196}
197
198fn truncate(s: &str, max: usize) -> String {
199 truncate_chars_with_suffix(s, max, "...")
200}
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use crate::session::SessionManager;
206 use tempfile::TempDir;
207
208 fn make_session_with_messages(dir: &std::path::Path, texts: &[&str]) -> SessionManager {
209 let session_dir = dir.join("sessions");
210 let cwd = dir.join("project");
211 let mut mgr = SessionManager::new(&cwd, &session_dir).unwrap();
212
213 for (i, text) in texts.iter().enumerate() {
214 let entry = SessionEntry::Message {
215 id: format!("m{i}"),
216 parent_id: None,
217 message: imp_llm::Message::user(*text),
218 };
219 mgr.append(entry).unwrap();
220
221 let reply = SessionEntry::Message {
223 id: format!("a{i}"),
224 parent_id: None,
225 message: imp_llm::Message::Assistant(imp_llm::AssistantMessage {
226 content: vec![imp_llm::ContentBlock::Text {
227 text: format!("Response to: {text}"),
228 }],
229 usage: None,
230 stop_reason: imp_llm::StopReason::EndTurn,
231 timestamp: 0,
232 }),
233 };
234 mgr.append(reply).unwrap();
235 }
236
237 mgr
238 }
239
240 #[test]
241 fn session_index_create_and_search() {
242 let dir = TempDir::new().unwrap();
243 let db_path = dir.path().join("index.db");
244 let index = SessionIndex::open(&db_path).unwrap();
245
246 let session = make_session_with_messages(
247 dir.path(),
248 &["Help me deploy to kubernetes", "Show me the docker config"],
249 );
250 index.index_session(&session).unwrap();
251
252 let results = index.search("kubernetes", 10).unwrap();
253 assert_eq!(results.len(), 1);
254 assert!(results[0].snippet.contains("kubernetes"));
255 }
256
257 #[test]
258 fn session_index_no_results() {
259 let dir = TempDir::new().unwrap();
260 let db_path = dir.path().join("index.db");
261 let index = SessionIndex::open(&db_path).unwrap();
262
263 let session = make_session_with_messages(dir.path(), &["Hello world"]);
264 index.index_session(&session).unwrap();
265
266 let results = index.search("kubernetes", 10).unwrap();
267 assert!(results.is_empty());
268 }
269
270 #[test]
271 fn session_index_multiple_sessions() {
272 let dir = TempDir::new().unwrap();
273 let db_path = dir.path().join("index.db");
274 let index = SessionIndex::open(&db_path).unwrap();
275
276 let s1 = make_session_with_messages(dir.path(), &["Deploy to kubernetes cluster"]);
277 index.index_session(&s1).unwrap();
278
279 let dir2 = dir.path().join("other");
281 std::fs::create_dir_all(&dir2).unwrap();
282 let s2 = make_session_with_messages(&dir2, &["Fix the kubernetes ingress"]);
283 index.index_session(&s2).unwrap();
284
285 let results = index.search("kubernetes", 10).unwrap();
286 assert_eq!(results.len(), 2);
287 }
288
289 #[test]
290 fn session_index_idempotent() {
291 let dir = TempDir::new().unwrap();
292 let db_path = dir.path().join("index.db");
293 let index = SessionIndex::open(&db_path).unwrap();
294
295 let session = make_session_with_messages(dir.path(), &["test content"]);
296 index.index_session(&session).unwrap();
297 index.index_session(&session).unwrap(); let results = index.search("test", 10).unwrap();
300 assert_eq!(results.len(), 1, "should not duplicate on re-index");
301 }
302
303 #[test]
304 fn session_index_is_indexed() {
305 let dir = TempDir::new().unwrap();
306 let db_path = dir.path().join("index.db");
307 let index = SessionIndex::open(&db_path).unwrap();
308
309 assert!(!index.is_indexed("nonexistent"));
310
311 let session = make_session_with_messages(dir.path(), &["hello"]);
312 index.index_session(&session).unwrap();
313
314 let session_id = session
315 .path()
316 .unwrap()
317 .file_stem()
318 .unwrap()
319 .to_string_lossy()
320 .to_string();
321 assert!(index.is_indexed(&session_id));
322 }
323
324 #[test]
325 fn session_index_fts5_and_or_not() {
326 let dir = TempDir::new().unwrap();
327 let db_path = dir.path().join("index.db");
328 let index = SessionIndex::open(&db_path).unwrap();
329
330 let session = make_session_with_messages(
331 dir.path(),
332 &["Deploy kubernetes cluster", "Configure docker networking"],
333 );
334 index.index_session(&session).unwrap();
335
336 let results = index.search("kubernetes AND cluster", 10).unwrap();
338 assert_eq!(results.len(), 1);
339
340 let results = index.search("kubernetes OR docker", 10).unwrap();
342 assert_eq!(results.len(), 1); let results = index.search("kubernetes NOT docker", 10).unwrap();
346 assert_eq!(results.len(), 0);
349 }
350
351 #[test]
352 fn session_index_snippet_highlights() {
353 let dir = TempDir::new().unwrap();
354 let db_path = dir.path().join("index.db");
355 let index = SessionIndex::open(&db_path).unwrap();
356
357 let session =
358 make_session_with_messages(dir.path(), &["The kubernetes deployment is broken"]);
359 index.index_session(&session).unwrap();
360
361 let results = index.search("kubernetes", 10).unwrap();
362 assert_eq!(results.len(), 1);
363 assert!(
365 results[0].snippet.contains(">>>") && results[0].snippet.contains("<<<"),
366 "snippet should have highlight markers: {}",
367 results[0].snippet
368 );
369 }
370}