Skip to main content

codemem_storage/
queries.rs

1//! Stats, consolidation, pattern queries, and session management on Storage.
2
3use crate::Storage;
4use codemem_core::{CodememError, ConsolidationLogEntry, Session, StorageStats};
5use rusqlite::params;
6use std::collections::HashMap;
7
8impl Storage {
9    // ── Health / Diagnostics ────────────────────────────────────────────
10
11    /// Run SQLite `PRAGMA integrity_check`. Returns `true` if the database is OK.
12    pub fn integrity_check(&self) -> Result<bool, CodememError> {
13        let conn = self.conn();
14        let result: String = conn
15            .query_row("PRAGMA integrity_check", [], |row| row.get(0))
16            .map_err(|e| CodememError::Storage(e.to_string()))?;
17        Ok(result == "ok")
18    }
19
20    /// Return the current schema version (max applied migration number).
21    pub fn schema_version(&self) -> Result<u32, CodememError> {
22        let conn = self.conn();
23        let version: u32 = conn
24            .query_row(
25                "SELECT COALESCE(MAX(version), 0) FROM schema_version",
26                [],
27                |row| row.get(0),
28            )
29            .map_err(|e| CodememError::Storage(e.to_string()))?;
30        Ok(version)
31    }
32
33    // ── Stats ───────────────────────────────────────────────────────────
34
35    /// Get database statistics.
36    pub fn stats(&self) -> Result<StorageStats, CodememError> {
37        let memory_count = self.memory_count()?;
38        let conn = self.conn();
39
40        let embedding_count: i64 = conn
41            .query_row("SELECT COUNT(*) FROM memory_embeddings", [], |row| {
42                row.get(0)
43            })
44            .map_err(|e| CodememError::Storage(e.to_string()))?;
45
46        let node_count: i64 = conn
47            .query_row("SELECT COUNT(*) FROM graph_nodes", [], |row| row.get(0))
48            .map_err(|e| CodememError::Storage(e.to_string()))?;
49
50        let edge_count: i64 = conn
51            .query_row("SELECT COUNT(*) FROM graph_edges", [], |row| row.get(0))
52            .map_err(|e| CodememError::Storage(e.to_string()))?;
53
54        Ok(StorageStats {
55            memory_count,
56            embedding_count: embedding_count as usize,
57            node_count: node_count as usize,
58            edge_count: edge_count as usize,
59        })
60    }
61
62    // ── Consolidation Log ──────────────────────────────────────────────
63
64    /// Record a consolidation run.
65    pub fn insert_consolidation_log(
66        &self,
67        cycle_type: &str,
68        affected_count: usize,
69    ) -> Result<(), CodememError> {
70        let conn = self.conn();
71        let now = chrono::Utc::now().timestamp();
72        conn.execute(
73            "INSERT INTO consolidation_log (cycle_type, run_at, affected_count) VALUES (?1, ?2, ?3)",
74            params![cycle_type, now, affected_count as i64],
75        )
76        .map_err(|e| CodememError::Storage(e.to_string()))?;
77        Ok(())
78    }
79
80    /// Get the last consolidation run for each cycle type.
81    pub fn last_consolidation_runs(&self) -> Result<Vec<ConsolidationLogEntry>, CodememError> {
82        let conn = self.conn();
83        let mut stmt = conn
84            .prepare(
85                "SELECT cycle_type, run_at, affected_count FROM consolidation_log
86                 WHERE id IN (
87                     SELECT id FROM consolidation_log c2
88                     WHERE c2.cycle_type = consolidation_log.cycle_type
89                     ORDER BY run_at DESC LIMIT 1
90                 )
91                 GROUP BY cycle_type
92                 ORDER BY cycle_type",
93            )
94            .map_err(|e| CodememError::Storage(e.to_string()))?;
95
96        let entries = stmt
97            .query_map([], |row| {
98                Ok(ConsolidationLogEntry {
99                    cycle_type: row.get(0)?,
100                    run_at: row.get(1)?,
101                    affected_count: row.get::<_, i64>(2)? as usize,
102                })
103            })
104            .map_err(|e| CodememError::Storage(e.to_string()))?
105            .collect::<Result<Vec<_>, _>>()
106            .map_err(|e| CodememError::Storage(e.to_string()))?;
107
108        Ok(entries)
109    }
110
111    // ── Pattern Detection Queries ───────────────────────────────────────
112
113    /// Find repeated search patterns (Grep/Glob) by extracting the "pattern" field
114    /// from memory metadata JSON. Returns (pattern, count, memory_ids) tuples where
115    /// count >= min_count, ordered by count descending.
116    pub fn get_repeated_searches(
117        &self,
118        min_count: usize,
119        namespace: Option<&str>,
120    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
121        let conn = self.conn();
122        let sql = if namespace.is_some() {
123            "SELECT json_extract(metadata, '$.pattern') AS pat,
124                    COUNT(*) AS cnt,
125                    GROUP_CONCAT(id, ',') AS ids
126             FROM memories
127             WHERE json_extract(metadata, '$.tool') IN ('Grep', 'Glob')
128               AND pat IS NOT NULL
129               AND namespace = ?1
130             GROUP BY pat
131             HAVING cnt >= ?2
132             ORDER BY cnt DESC"
133        } else {
134            "SELECT json_extract(metadata, '$.pattern') AS pat,
135                    COUNT(*) AS cnt,
136                    GROUP_CONCAT(id, ',') AS ids
137             FROM memories
138             WHERE json_extract(metadata, '$.tool') IN ('Grep', 'Glob')
139               AND pat IS NOT NULL
140             GROUP BY pat
141             HAVING cnt >= ?1
142             ORDER BY cnt DESC"
143        };
144
145        let mut stmt = conn
146            .prepare(sql)
147            .map_err(|e| CodememError::Storage(e.to_string()))?;
148
149        let rows = if let Some(ns) = namespace {
150            stmt.query_map(params![ns, min_count as i64], |row| {
151                Ok((
152                    row.get::<_, String>(0)?,
153                    row.get::<_, i64>(1)?,
154                    row.get::<_, String>(2)?,
155                ))
156            })
157            .map_err(|e| CodememError::Storage(e.to_string()))?
158            .collect::<Result<Vec<_>, _>>()
159            .map_err(|e| CodememError::Storage(e.to_string()))?
160        } else {
161            stmt.query_map(params![min_count as i64], |row| {
162                Ok((
163                    row.get::<_, String>(0)?,
164                    row.get::<_, i64>(1)?,
165                    row.get::<_, String>(2)?,
166                ))
167            })
168            .map_err(|e| CodememError::Storage(e.to_string()))?
169            .collect::<Result<Vec<_>, _>>()
170            .map_err(|e| CodememError::Storage(e.to_string()))?
171        };
172
173        Ok(rows
174            .into_iter()
175            .map(|(pat, cnt, ids_str)| {
176                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
177                (pat, cnt as usize, ids)
178            })
179            .collect())
180    }
181
182    /// Find file hotspots by extracting the "file_path" field from memory metadata.
183    pub fn get_file_hotspots(
184        &self,
185        min_count: usize,
186        namespace: Option<&str>,
187    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
188        let conn = self.conn();
189        let sql = if namespace.is_some() {
190            "SELECT json_extract(metadata, '$.file_path') AS fp,
191                    COUNT(*) AS cnt,
192                    GROUP_CONCAT(id, ',') AS ids
193             FROM memories
194             WHERE fp IS NOT NULL
195               AND namespace = ?1
196             GROUP BY fp
197             HAVING cnt >= ?2
198             ORDER BY cnt DESC"
199        } else {
200            "SELECT json_extract(metadata, '$.file_path') AS fp,
201                    COUNT(*) AS cnt,
202                    GROUP_CONCAT(id, ',') AS ids
203             FROM memories
204             WHERE fp IS NOT NULL
205             GROUP BY fp
206             HAVING cnt >= ?1
207             ORDER BY cnt DESC"
208        };
209
210        let mut stmt = conn
211            .prepare(sql)
212            .map_err(|e| CodememError::Storage(e.to_string()))?;
213
214        let rows = if let Some(ns) = namespace {
215            stmt.query_map(params![ns, min_count as i64], |row| {
216                Ok((
217                    row.get::<_, String>(0)?,
218                    row.get::<_, i64>(1)?,
219                    row.get::<_, String>(2)?,
220                ))
221            })
222            .map_err(|e| CodememError::Storage(e.to_string()))?
223            .collect::<Result<Vec<_>, _>>()
224            .map_err(|e| CodememError::Storage(e.to_string()))?
225        } else {
226            stmt.query_map(params![min_count as i64], |row| {
227                Ok((
228                    row.get::<_, String>(0)?,
229                    row.get::<_, i64>(1)?,
230                    row.get::<_, String>(2)?,
231                ))
232            })
233            .map_err(|e| CodememError::Storage(e.to_string()))?
234            .collect::<Result<Vec<_>, _>>()
235            .map_err(|e| CodememError::Storage(e.to_string()))?
236        };
237
238        Ok(rows
239            .into_iter()
240            .map(|(fp, cnt, ids_str)| {
241                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
242                (fp, cnt as usize, ids)
243            })
244            .collect())
245    }
246
247    /// Get tool usage statistics from memory metadata.
248    pub fn get_tool_usage_stats(
249        &self,
250        namespace: Option<&str>,
251    ) -> Result<HashMap<String, usize>, CodememError> {
252        let conn = self.conn();
253        let sql = if namespace.is_some() {
254            "SELECT json_extract(metadata, '$.tool') AS tool,
255                    COUNT(*) AS cnt
256             FROM memories
257             WHERE tool IS NOT NULL
258               AND namespace = ?1
259             GROUP BY tool
260             ORDER BY cnt DESC"
261        } else {
262            "SELECT json_extract(metadata, '$.tool') AS tool,
263                    COUNT(*) AS cnt
264             FROM memories
265             WHERE tool IS NOT NULL
266             GROUP BY tool
267             ORDER BY cnt DESC"
268        };
269
270        let mut stmt = conn
271            .prepare(sql)
272            .map_err(|e| CodememError::Storage(e.to_string()))?;
273
274        let rows = if let Some(ns) = namespace {
275            stmt.query_map(params![ns], |row| {
276                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
277            })
278            .map_err(|e| CodememError::Storage(e.to_string()))?
279            .collect::<Result<Vec<_>, _>>()
280            .map_err(|e| CodememError::Storage(e.to_string()))?
281        } else {
282            stmt.query_map([], |row| {
283                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
284            })
285            .map_err(|e| CodememError::Storage(e.to_string()))?
286            .collect::<Result<Vec<_>, _>>()
287            .map_err(|e| CodememError::Storage(e.to_string()))?
288        };
289
290        Ok(rows
291            .into_iter()
292            .map(|(tool, cnt)| (tool, cnt as usize))
293            .collect())
294    }
295
296    /// Find decision chains: files with multiple Edit/Write memories over time.
297    pub fn get_decision_chains(
298        &self,
299        min_count: usize,
300        namespace: Option<&str>,
301    ) -> Result<Vec<(String, usize, Vec<String>)>, CodememError> {
302        let conn = self.conn();
303        let sql = if namespace.is_some() {
304            "SELECT json_extract(metadata, '$.file_path') AS fp,
305                    COUNT(*) AS cnt,
306                    GROUP_CONCAT(id, ',') AS ids
307             FROM memories
308             WHERE json_extract(metadata, '$.tool') IN ('Edit', 'Write')
309               AND fp IS NOT NULL
310               AND namespace = ?1
311             GROUP BY fp
312             HAVING cnt >= ?2
313             ORDER BY cnt DESC"
314        } else {
315            "SELECT json_extract(metadata, '$.file_path') AS fp,
316                    COUNT(*) AS cnt,
317                    GROUP_CONCAT(id, ',') AS ids
318             FROM memories
319             WHERE json_extract(metadata, '$.tool') IN ('Edit', 'Write')
320               AND fp IS NOT NULL
321             GROUP BY fp
322             HAVING cnt >= ?1
323             ORDER BY cnt DESC"
324        };
325
326        let mut stmt = conn
327            .prepare(sql)
328            .map_err(|e| CodememError::Storage(e.to_string()))?;
329
330        let rows = if let Some(ns) = namespace {
331            stmt.query_map(params![ns, min_count as i64], |row| {
332                Ok((
333                    row.get::<_, String>(0)?,
334                    row.get::<_, i64>(1)?,
335                    row.get::<_, String>(2)?,
336                ))
337            })
338            .map_err(|e| CodememError::Storage(e.to_string()))?
339            .collect::<Result<Vec<_>, _>>()
340            .map_err(|e| CodememError::Storage(e.to_string()))?
341        } else {
342            stmt.query_map(params![min_count as i64], |row| {
343                Ok((
344                    row.get::<_, String>(0)?,
345                    row.get::<_, i64>(1)?,
346                    row.get::<_, String>(2)?,
347                ))
348            })
349            .map_err(|e| CodememError::Storage(e.to_string()))?
350            .collect::<Result<Vec<_>, _>>()
351            .map_err(|e| CodememError::Storage(e.to_string()))?
352        };
353
354        Ok(rows
355            .into_iter()
356            .map(|(fp, cnt, ids_str)| {
357                let ids: Vec<String> = ids_str.split(',').map(String::from).collect();
358                (fp, cnt as usize, ids)
359            })
360            .collect())
361    }
362
363    // ── Insight / Tag Queries ──────────────────────────────────────────
364
365    /// Count memories whose content matches any of the given keywords (SQL LIKE).
366    pub fn count_memories_matching_keywords(
367        &self,
368        keywords: &[&str],
369        namespace: Option<&str>,
370    ) -> Result<usize, CodememError> {
371        if keywords.is_empty() {
372            return Ok(0);
373        }
374        let conn = self.conn();
375        let like_clauses: Vec<String> = keywords
376            .iter()
377            .enumerate()
378            .map(|(i, _)| format!("content LIKE ?{}", i + 1))
379            .collect();
380        let where_likes = like_clauses.join(" OR ");
381
382        let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) =
383            if let Some(ns) = namespace {
384                let sql = format!(
385                    "SELECT COUNT(*) FROM memories WHERE ({}) AND namespace = ?{}",
386                    where_likes,
387                    keywords.len() + 1,
388                );
389                let mut p: Vec<Box<dyn rusqlite::types::ToSql>> = keywords
390                    .iter()
391                    .map(|k| Box::new(format!("%{k}%")) as Box<dyn rusqlite::types::ToSql>)
392                    .collect();
393                p.push(Box::new(ns.to_string()));
394                (sql, p)
395            } else {
396                let sql = format!("SELECT COUNT(*) FROM memories WHERE ({})", where_likes);
397                let p: Vec<Box<dyn rusqlite::types::ToSql>> = keywords
398                    .iter()
399                    .map(|k| Box::new(format!("%{k}%")) as Box<dyn rusqlite::types::ToSql>)
400                    .collect();
401                (sql, p)
402            };
403
404        let params_refs: Vec<&dyn rusqlite::types::ToSql> =
405            params_vec.iter().map(|b| &**b).collect();
406
407        let count: i64 = conn
408            .query_row(&sql, params_refs.as_slice(), |row| row.get(0))
409            .map_err(|e| CodememError::Storage(e.to_string()))?;
410        Ok(count as usize)
411    }
412
413    /// List memories that contain a specific tag, optionally scoped to a namespace.
414    pub fn list_memories_by_tag(
415        &self,
416        tag: &str,
417        namespace: Option<&str>,
418        limit: usize,
419    ) -> Result<Vec<codemem_core::MemoryNode>, CodememError> {
420        let conn = self.conn();
421        let like_pattern = format!("%\"{tag}\"%");
422
423        let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = if let Some(ns) =
424            namespace
425        {
426            (
427                    "SELECT id, content, memory_type, importance, confidence, access_count, \
428                     content_hash, tags, metadata, namespace, created_at, updated_at, last_accessed_at \
429                     FROM memories WHERE tags LIKE ?1 AND namespace = ?2 \
430                     ORDER BY created_at DESC LIMIT ?3"
431                        .to_string(),
432                    vec![
433                        Box::new(like_pattern) as Box<dyn rusqlite::types::ToSql>,
434                        Box::new(ns.to_string()),
435                        Box::new(limit as i64),
436                    ],
437                )
438        } else {
439            (
440                    "SELECT id, content, memory_type, importance, confidence, access_count, \
441                     content_hash, tags, metadata, namespace, created_at, updated_at, last_accessed_at \
442                     FROM memories WHERE tags LIKE ?1 \
443                     ORDER BY created_at DESC LIMIT ?2"
444                        .to_string(),
445                    vec![
446                        Box::new(like_pattern) as Box<dyn rusqlite::types::ToSql>,
447                        Box::new(limit as i64),
448                    ],
449                )
450        };
451
452        let params_refs: Vec<&dyn rusqlite::types::ToSql> =
453            params_vec.iter().map(|b| &**b).collect();
454
455        let mut stmt = conn
456            .prepare(&sql)
457            .map_err(|e| CodememError::Storage(e.to_string()))?;
458
459        let rows = stmt
460            .query_map(params_refs.as_slice(), |row| {
461                let created_ts: i64 = row.get(10)?;
462                let updated_ts: i64 = row.get(11)?;
463                let accessed_ts: i64 = row.get(12)?;
464                let tags_json: String = row.get(7)?;
465                let metadata_json: String = row.get(8)?;
466                let memory_type_str: String = row.get(2)?;
467
468                Ok(codemem_core::MemoryNode {
469                    id: row.get(0)?,
470                    content: row.get(1)?,
471                    memory_type: memory_type_str
472                        .parse()
473                        .unwrap_or(codemem_core::MemoryType::Context),
474                    importance: row.get(3)?,
475                    confidence: row.get(4)?,
476                    access_count: row.get::<_, i64>(5).unwrap_or(0) as u32,
477                    content_hash: row.get(6)?,
478                    tags: serde_json::from_str(&tags_json).unwrap_or_default(),
479                    metadata: serde_json::from_str(&metadata_json).unwrap_or_default(),
480                    namespace: row.get(9)?,
481                    created_at: chrono::DateTime::from_timestamp(created_ts, 0)
482                        .unwrap_or_default()
483                        .with_timezone(&chrono::Utc),
484                    updated_at: chrono::DateTime::from_timestamp(updated_ts, 0)
485                        .unwrap_or_default()
486                        .with_timezone(&chrono::Utc),
487                    last_accessed_at: chrono::DateTime::from_timestamp(accessed_ts, 0)
488                        .unwrap_or_default()
489                        .with_timezone(&chrono::Utc),
490                })
491            })
492            .map_err(|e| CodememError::Storage(e.to_string()))?
493            .collect::<Result<Vec<_>, _>>()
494            .map_err(|e| CodememError::Storage(e.to_string()))?;
495
496        Ok(rows)
497    }
498
499    // ── Session Management ─────────────────────────────────────────────
500
501    /// Start a new session.
502    pub fn start_session(&self, id: &str, namespace: Option<&str>) -> Result<(), CodememError> {
503        let conn = self.conn();
504        let now = chrono::Utc::now().timestamp();
505        conn.execute(
506            "INSERT OR IGNORE INTO sessions (id, namespace, started_at) VALUES (?1, ?2, ?3)",
507            params![id, namespace, now],
508        )
509        .map_err(|e| CodememError::Storage(e.to_string()))?;
510        Ok(())
511    }
512
513    /// End a session by setting ended_at and optionally a summary.
514    pub fn end_session(&self, id: &str, summary: Option<&str>) -> Result<(), CodememError> {
515        let conn = self.conn();
516        let now = chrono::Utc::now().timestamp();
517        conn.execute(
518            "UPDATE sessions SET ended_at = ?1, summary = ?2 WHERE id = ?3",
519            params![now, summary, id],
520        )
521        .map_err(|e| CodememError::Storage(e.to_string()))?;
522        Ok(())
523    }
524
525    /// List sessions, optionally filtered by namespace.
526    pub fn list_sessions(&self, namespace: Option<&str>) -> Result<Vec<Session>, CodememError> {
527        self.list_sessions_with_limit(namespace, usize::MAX)
528    }
529
530    // ── Session Activity Tracking ─────────────────────────────────
531
532    /// Record a session activity event.
533    pub fn record_session_activity(
534        &self,
535        session_id: &str,
536        tool_name: &str,
537        file_path: Option<&str>,
538        directory: Option<&str>,
539        pattern: Option<&str>,
540    ) -> Result<(), CodememError> {
541        let conn = self.conn();
542        let now = chrono::Utc::now().timestamp();
543        conn.execute(
544            "INSERT INTO session_activity (session_id, tool_name, file_path, directory, pattern, created_at)
545             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
546            params![session_id, tool_name, file_path, directory, pattern, now],
547        )
548        .map_err(|e| CodememError::Storage(e.to_string()))?;
549        Ok(())
550    }
551
552    /// Get a summary of session activity counts.
553    pub fn get_session_activity_summary(
554        &self,
555        session_id: &str,
556    ) -> Result<codemem_core::SessionActivitySummary, CodememError> {
557        let conn = self.conn();
558
559        let files_read: i64 = conn
560            .query_row(
561                "SELECT COUNT(DISTINCT file_path) FROM session_activity
562                 WHERE session_id = ?1 AND tool_name = 'Read' AND file_path IS NOT NULL",
563                params![session_id],
564                |row| row.get(0),
565            )
566            .map_err(|e| CodememError::Storage(e.to_string()))?;
567
568        let files_edited: i64 = conn
569            .query_row(
570                "SELECT COUNT(DISTINCT file_path) FROM session_activity
571                 WHERE session_id = ?1 AND tool_name IN ('Edit', 'Write') AND file_path IS NOT NULL",
572                params![session_id],
573                |row| row.get(0),
574            )
575            .map_err(|e| CodememError::Storage(e.to_string()))?;
576
577        let searches: i64 = conn
578            .query_row(
579                "SELECT COUNT(*) FROM session_activity
580                 WHERE session_id = ?1 AND tool_name IN ('Grep', 'Glob')",
581                params![session_id],
582                |row| row.get(0),
583            )
584            .map_err(|e| CodememError::Storage(e.to_string()))?;
585
586        let total_actions: i64 = conn
587            .query_row(
588                "SELECT COUNT(*) FROM session_activity WHERE session_id = ?1",
589                params![session_id],
590                |row| row.get(0),
591            )
592            .map_err(|e| CodememError::Storage(e.to_string()))?;
593
594        Ok(codemem_core::SessionActivitySummary {
595            files_read: files_read as usize,
596            files_edited: files_edited as usize,
597            searches: searches as usize,
598            total_actions: total_actions as usize,
599        })
600    }
601
602    /// Get the most active directories in a session.
603    pub fn get_session_hot_directories(
604        &self,
605        session_id: &str,
606        limit: usize,
607    ) -> Result<Vec<(String, usize)>, CodememError> {
608        let conn = self.conn();
609        let mut stmt = conn
610            .prepare(
611                "SELECT directory, COUNT(*) AS cnt FROM session_activity
612                 WHERE session_id = ?1 AND directory IS NOT NULL
613                 GROUP BY directory ORDER BY cnt DESC LIMIT ?2",
614            )
615            .map_err(|e| CodememError::Storage(e.to_string()))?;
616
617        let rows = stmt
618            .query_map(params![session_id, limit as i64], |row| {
619                Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?))
620            })
621            .map_err(|e| CodememError::Storage(e.to_string()))?
622            .collect::<Result<Vec<_>, _>>()
623            .map_err(|e| CodememError::Storage(e.to_string()))?;
624
625        Ok(rows
626            .into_iter()
627            .map(|(dir, cnt)| (dir, cnt as usize))
628            .collect())
629    }
630
631    /// Check whether an auto-insight dedup tag exists for a session.
632    pub fn has_auto_insight(
633        &self,
634        session_id: &str,
635        dedup_tag: &str,
636    ) -> Result<bool, CodememError> {
637        let conn = self.conn();
638        let like_session = format!("%\"session_id\":\"{session_id}\"%");
639        let like_dedup = format!("%\"auto_insight_tag\":\"{dedup_tag}\"%");
640        let count: i64 = conn
641            .query_row(
642                "SELECT COUNT(*) FROM memories
643                 WHERE metadata LIKE ?1 AND metadata LIKE ?2",
644                params![like_session, like_dedup],
645                |row| row.get(0),
646            )
647            .map_err(|e| CodememError::Storage(e.to_string()))?;
648        Ok(count > 0)
649    }
650
651    /// Count Read events in a directory during a session.
652    pub fn count_directory_reads(
653        &self,
654        session_id: &str,
655        directory: &str,
656    ) -> Result<usize, CodememError> {
657        let conn = self.conn();
658        let count: i64 = conn
659            .query_row(
660                "SELECT COUNT(*) FROM session_activity
661                 WHERE session_id = ?1 AND tool_name = 'Read' AND directory = ?2",
662                params![session_id, directory],
663                |row| row.get(0),
664            )
665            .map_err(|e| CodememError::Storage(e.to_string()))?;
666        Ok(count as usize)
667    }
668
669    /// Check if a file was read in the current session.
670    pub fn was_file_read_in_session(
671        &self,
672        session_id: &str,
673        file_path: &str,
674    ) -> Result<bool, CodememError> {
675        let conn = self.conn();
676        let count: i64 = conn
677            .query_row(
678                "SELECT COUNT(*) FROM session_activity
679                 WHERE session_id = ?1 AND tool_name = 'Read' AND file_path = ?2",
680                params![session_id, file_path],
681                |row| row.get(0),
682            )
683            .map_err(|e| CodememError::Storage(e.to_string()))?;
684        Ok(count > 0)
685    }
686
687    /// Count how many times a search pattern was used in a session.
688    pub fn count_search_pattern_in_session(
689        &self,
690        session_id: &str,
691        pattern: &str,
692    ) -> Result<usize, CodememError> {
693        let conn = self.conn();
694        let count: i64 = conn
695            .query_row(
696                "SELECT COUNT(*) FROM session_activity
697                 WHERE session_id = ?1 AND tool_name IN ('Grep', 'Glob') AND pattern = ?2",
698                params![session_id, pattern],
699                |row| row.get(0),
700            )
701            .map_err(|e| CodememError::Storage(e.to_string()))?;
702        Ok(count as usize)
703    }
704
705    /// List sessions with a limit.
706    pub(crate) fn list_sessions_with_limit(
707        &self,
708        namespace: Option<&str>,
709        limit: usize,
710    ) -> Result<Vec<Session>, CodememError> {
711        let conn = self.conn();
712        let sql_with_ns = "SELECT id, namespace, started_at, ended_at, memory_count, summary FROM sessions WHERE namespace = ?1 ORDER BY started_at DESC LIMIT ?2";
713        let sql_all = "SELECT id, namespace, started_at, ended_at, memory_count, summary FROM sessions ORDER BY started_at DESC LIMIT ?1";
714
715        let map_row = |row: &rusqlite::Row<'_>| -> rusqlite::Result<Session> {
716            let started_ts: i64 = row.get(2)?;
717            let ended_ts: Option<i64> = row.get(3)?;
718            Ok(Session {
719                id: row.get(0)?,
720                namespace: row.get(1)?,
721                started_at: chrono::DateTime::from_timestamp(started_ts, 0)
722                    .unwrap_or_default()
723                    .with_timezone(&chrono::Utc),
724                ended_at: ended_ts.and_then(|ts| {
725                    chrono::DateTime::from_timestamp(ts, 0).map(|dt| dt.with_timezone(&chrono::Utc))
726                }),
727                memory_count: row.get::<_, i64>(4).unwrap_or(0) as u32,
728                summary: row.get(5)?,
729            })
730        };
731
732        if let Some(ns) = namespace {
733            let mut stmt = conn
734                .prepare(sql_with_ns)
735                .map_err(|e| CodememError::Storage(e.to_string()))?;
736            let rows = stmt
737                .query_map(params![ns, limit as i64], map_row)
738                .map_err(|e| CodememError::Storage(e.to_string()))?;
739            rows.collect::<Result<Vec<_>, _>>()
740                .map_err(|e| CodememError::Storage(e.to_string()))
741        } else {
742            let mut stmt = conn
743                .prepare(sql_all)
744                .map_err(|e| CodememError::Storage(e.to_string()))?;
745            let rows = stmt
746                .query_map(params![limit as i64], map_row)
747                .map_err(|e| CodememError::Storage(e.to_string()))?;
748            rows.collect::<Result<Vec<_>, _>>()
749                .map_err(|e| CodememError::Storage(e.to_string()))
750        }
751    }
752    // ── Graph Cleanup ───────────────────────────────────────────────
753
754    /// Delete all graph nodes, their edges, and their embeddings where the
755    /// node ID starts with the given prefix. Returns count of nodes deleted.
756    pub fn delete_graph_nodes_by_prefix(&self, prefix: &str) -> Result<usize, CodememError> {
757        let conn = self.conn();
758        let like_pattern = format!("{prefix}%");
759
760        // Delete edges where src or dst matches prefix
761        conn.execute(
762            "DELETE FROM graph_edges WHERE src LIKE ?1 OR dst LIKE ?1",
763            params![like_pattern],
764        )
765        .map_err(|e| CodememError::Storage(e.to_string()))?;
766
767        // Delete embeddings for matching nodes
768        conn.execute(
769            "DELETE FROM memory_embeddings WHERE memory_id LIKE ?1",
770            params![like_pattern],
771        )
772        .map_err(|e| CodememError::Storage(e.to_string()))?;
773
774        // Delete the nodes themselves
775        let rows = conn
776            .execute(
777                "DELETE FROM graph_nodes WHERE id LIKE ?1",
778                params![like_pattern],
779            )
780            .map_err(|e| CodememError::Storage(e.to_string()))?;
781
782        Ok(rows)
783    }
784}
785
786#[cfg(test)]
787#[path = "tests/queries_tests.rs"]
788mod tests;