Skip to main content

microclaw_engine/internal/storage/db/
memory.rs

1use super::*;
2
3#[derive(Debug, Clone)]
4pub struct Memory {
5    pub id: i64,
6    pub chat_id: Option<i64>,
7    pub content: String,
8    pub category: String,
9    pub created_at: String,
10    pub updated_at: String,
11    pub embedding_model: Option<String>,
12    pub confidence: f64,
13    pub source: String,
14    pub last_seen_at: String,
15    pub is_archived: bool,
16    pub archived_at: Option<String>,
17    /// Optional RFC3339 timestamp at which this memory expires. NULL means
18    /// the memory is durable; expired rows are filtered from retrieval and
19    /// pruned by the reflector.
20    pub expires_at: Option<String>,
21}
22
23/// A single triple in the temporal knowledge graph.
24#[derive(Debug, Clone)]
25pub struct KgTriple {
26    pub id: i64,
27    pub subject: String,
28    pub predicate: String,
29    pub object: String,
30    pub chat_id: Option<i64>,
31    pub valid_from: String,
32    pub valid_to: Option<String>,
33    pub confidence: f64,
34    pub source: String,
35    pub source_memory_id: Option<i64>,
36    pub created_at: String,
37}
38
39#[derive(Debug, Clone)]
40pub struct MemoryObservabilitySummary {
41    pub total: i64,
42    pub active: i64,
43    pub archived: i64,
44    pub low_confidence: i64,
45    pub avg_confidence: f64,
46    pub reflector_runs_24h: i64,
47    pub reflector_inserted_24h: i64,
48    pub reflector_updated_24h: i64,
49    pub reflector_skipped_24h: i64,
50    pub injection_events_24h: i64,
51    pub injection_selected_24h: i64,
52    pub injection_candidates_24h: i64,
53}
54
55#[derive(Debug, Clone)]
56pub struct MemoryReflectorRun {
57    pub id: i64,
58    pub chat_id: i64,
59    pub started_at: String,
60    pub finished_at: String,
61    pub extracted_count: i64,
62    pub inserted_count: i64,
63    pub updated_count: i64,
64    pub skipped_count: i64,
65    pub dedup_method: String,
66    pub parse_ok: bool,
67    pub error_text: Option<String>,
68}
69
70#[derive(Debug, Clone)]
71pub struct MemoryInjectionLog {
72    pub id: i64,
73    pub chat_id: i64,
74    pub created_at: String,
75    pub retrieval_method: String,
76    pub candidate_count: i64,
77    pub selected_count: i64,
78    pub omitted_count: i64,
79    pub tokens_est: i64,
80}
81
82impl Database {
83    /// Clear memory state for a chat without deleting chat/session/message history.
84    /// This removes structured memories and reflector bookkeeping for the chat.
85    pub fn clear_chat_memory(&self, chat_id: i64) -> Result<bool, MicroClawError> {
86        let conn = self.lock_conn();
87        let tx = conn.unchecked_transaction()?;
88        let mut affected = 0usize;
89        affected += erase_chat_learning(&tx, chat_id)?;
90        affected += tx.execute(
91            "DELETE FROM memory_reflector_state WHERE chat_id = ?1",
92            params![chat_id],
93        )?;
94        affected += tx.execute(
95            "DELETE FROM memory_reflector_runs WHERE chat_id = ?1",
96            params![chat_id],
97        )?;
98        affected += tx.execute(
99            "DELETE FROM memory_injection_logs WHERE chat_id = ?1",
100            params![chat_id],
101        )?;
102        affected += tx.execute(
103            "DELETE FROM memory_supersede_edges
104             WHERE from_memory_id IN (SELECT id FROM memories WHERE chat_id = ?1)
105                OR to_memory_id IN (SELECT id FROM memories WHERE chat_id = ?1)",
106            params![chat_id],
107        )?;
108        affected += tx.execute("DELETE FROM memories WHERE chat_id = ?1", params![chat_id])?;
109        tx.commit()?;
110        Ok(affected > 0)
111    }
112
113    pub fn get_reflector_cursor(&self, chat_id: i64) -> Result<Option<String>, MicroClawError> {
114        let conn = self.lock_conn();
115        let result = conn.query_row(
116            "SELECT last_reflected_ts FROM memory_reflector_state WHERE chat_id = ?1",
117            params![chat_id],
118            |row| row.get::<_, String>(0),
119        );
120        match result {
121            Ok(ts) => Ok(Some(ts)),
122            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
123            Err(e) => Err(e.into()),
124        }
125    }
126
127    pub fn set_reflector_cursor(
128        &self,
129        chat_id: i64,
130        last_reflected_ts: &str,
131    ) -> Result<(), MicroClawError> {
132        let conn = self.lock_conn();
133        let now = chrono::Utc::now().to_rfc3339();
134        conn.execute(
135            "INSERT INTO memory_reflector_state (chat_id, last_reflected_ts, updated_at)
136             VALUES (?1, ?2, ?3)
137             ON CONFLICT(chat_id) DO UPDATE SET
138                last_reflected_ts = excluded.last_reflected_ts,
139                updated_at = excluded.updated_at",
140            params![chat_id, last_reflected_ts, now],
141        )?;
142        Ok(())
143    }
144
145    pub fn insert_memory(
146        &self,
147        chat_id: Option<i64>,
148        content: &str,
149        category: &str,
150    ) -> Result<i64, MicroClawError> {
151        self.insert_memory_with_metadata(chat_id, content, category, "tool", 0.80)
152    }
153
154    pub fn insert_memory_with_metadata(
155        &self,
156        chat_id: Option<i64>,
157        content: &str,
158        category: &str,
159        source: &str,
160        confidence: f64,
161    ) -> Result<i64, MicroClawError> {
162        let conn = self.lock_conn();
163        let now = chrono::Utc::now().to_rfc3339();
164        let (chat_channel, external_chat_id) = if let Some(cid) = chat_id {
165            conn.query_row(
166                "SELECT channel, external_chat_id FROM chats WHERE chat_id = ?1",
167                params![cid],
168                |row| {
169                    Ok((
170                        row.get::<_, Option<String>>(0)?,
171                        row.get::<_, Option<String>>(1)?,
172                    ))
173                },
174            )
175            .optional()?
176            .unwrap_or((None, None))
177        } else {
178            (None, None)
179        };
180        conn.execute(
181            "INSERT INTO memories (
182                chat_id, content, category, created_at, updated_at, embedding_model,
183                confidence, source, last_seen_at, is_archived, archived_at,
184                chat_channel, external_chat_id
185            ) VALUES (?1, ?2, ?3, ?4, ?4, NULL, ?5, ?6, ?4, 0, NULL, ?7, ?8)",
186            params![
187                chat_id,
188                content,
189                category,
190                now,
191                confidence.clamp(0.0, 1.0),
192                source,
193                chat_channel,
194                external_chat_id
195            ],
196        )?;
197        Ok(conn.last_insert_rowid())
198    }
199
200    pub fn get_memories_for_context(
201        &self,
202        chat_id: i64,
203        limit: usize,
204    ) -> Result<Vec<Memory>, MicroClawError> {
205        let conn = self.lock_conn();
206        let now = chrono::Utc::now().to_rfc3339();
207        let mut stmt = conn.prepare(
208            "SELECT id, chat_id, content, category, created_at, updated_at, embedding_model,
209                    confidence, source, last_seen_at, is_archived, archived_at, expires_at
210             FROM memories
211             WHERE (chat_id = ?1 OR chat_id IS NULL)
212               AND is_archived = 0
213               AND confidence >= 0.45
214               AND (expires_at IS NULL OR expires_at > ?3)
215             ORDER BY updated_at DESC
216             LIMIT ?2",
217        )?;
218        let memories = stmt
219            .query_map(params![chat_id, limit as i64, now], |row| {
220                Ok(Memory {
221                    id: row.get(0)?,
222                    chat_id: row.get(1)?,
223                    content: row.get(2)?,
224                    category: row.get(3)?,
225                    created_at: row.get(4)?,
226                    updated_at: row.get(5)?,
227                    embedding_model: row.get(6)?,
228                    confidence: row.get(7)?,
229                    source: row.get(8)?,
230                    last_seen_at: row.get(9)?,
231                    is_archived: row.get::<_, i64>(10)? != 0,
232                    archived_at: row.get(11)?,
233                    expires_at: row.get(12)?,
234                })
235            })?
236            .collect::<Result<Vec<_>, _>>()?;
237        Ok(memories)
238    }
239
240    pub fn get_all_memories_for_chat(
241        &self,
242        chat_id: Option<i64>,
243    ) -> Result<Vec<Memory>, MicroClawError> {
244        let conn = self.lock_conn();
245        let mut stmt = conn.prepare(
246            "SELECT id, chat_id, content, category, created_at, updated_at, embedding_model,
247                    confidence, source, last_seen_at, is_archived, archived_at, expires_at
248             FROM memories
249             WHERE (chat_id = ?1 OR (?1 IS NULL AND chat_id IS NULL))",
250        )?;
251        let memories = stmt
252            .query_map(params![chat_id], |row| {
253                Ok(Memory {
254                    id: row.get(0)?,
255                    chat_id: row.get(1)?,
256                    content: row.get(2)?,
257                    category: row.get(3)?,
258                    created_at: row.get(4)?,
259                    updated_at: row.get(5)?,
260                    embedding_model: row.get(6)?,
261                    confidence: row.get(7)?,
262                    source: row.get(8)?,
263                    last_seen_at: row.get(9)?,
264                    is_archived: row.get::<_, i64>(10)? != 0,
265                    archived_at: row.get(11)?,
266                    expires_at: row.get(12)?,
267                })
268            })?
269            .collect::<Result<Vec<_>, _>>()?;
270        Ok(memories)
271    }
272
273    /// Keyword search in memories visible to chat_id (own + global).
274    pub fn search_memories(
275        &self,
276        chat_id: i64,
277        query: &str,
278        limit: usize,
279    ) -> Result<Vec<Memory>, MicroClawError> {
280        self.search_memories_with_options(chat_id, query, limit, false, true)
281    }
282
283    pub fn search_memories_with_options(
284        &self,
285        chat_id: i64,
286        query: &str,
287        limit: usize,
288        include_archived: bool,
289        broad_recall: bool,
290    ) -> Result<Vec<Memory>, MicroClawError> {
291        let conn = self.lock_conn();
292        let pattern = format!("%{}%", query.to_lowercase());
293        let now = chrono::Utc::now().to_rfc3339();
294        let mut sql = String::from(
295            "SELECT id, chat_id, content, category, created_at, updated_at, embedding_model,
296                    confidence, source, last_seen_at, is_archived, archived_at, expires_at
297             FROM memories
298             WHERE (chat_id = ?1 OR chat_id IS NULL)
299               AND LOWER(content) LIKE ?2
300               AND (expires_at IS NULL OR expires_at > ?4)",
301        );
302        if !include_archived {
303            sql.push_str(" AND is_archived = 0");
304        }
305        if !broad_recall {
306            sql.push_str(" AND confidence >= 0.45");
307        }
308        sql.push_str(" ORDER BY confidence DESC, updated_at DESC LIMIT ?3");
309        let mut stmt = conn.prepare(&sql)?;
310        let memories = stmt
311            .query_map(params![chat_id, pattern, limit as i64, now], |row| {
312                Ok(Memory {
313                    id: row.get(0)?,
314                    chat_id: row.get(1)?,
315                    content: row.get(2)?,
316                    category: row.get(3)?,
317                    created_at: row.get(4)?,
318                    updated_at: row.get(5)?,
319                    embedding_model: row.get(6)?,
320                    confidence: row.get(7)?,
321                    source: row.get(8)?,
322                    last_seen_at: row.get(9)?,
323                    is_archived: row.get::<_, i64>(10)? != 0,
324                    archived_at: row.get(11)?,
325                    expires_at: row.get(12)?,
326                })
327            })?
328            .collect::<Result<Vec<_>, _>>()?;
329        Ok(memories)
330    }
331
332    /// Delete a memory row by id. Returns true if a row was deleted.
333    pub fn delete_memory(&self, id: i64) -> Result<bool, MicroClawError> {
334        let conn = self.lock_conn();
335        let rows = conn.execute("DELETE FROM memories WHERE id = ?1", params![id])?;
336        Ok(rows > 0)
337    }
338
339    /// Set or clear the `expires_at` of a memory. Pass `None` to clear.
340    pub fn set_memory_expires_at(
341        &self,
342        id: i64,
343        expires_at: Option<&str>,
344    ) -> Result<bool, MicroClawError> {
345        let conn = self.lock_conn();
346        let rows = conn.execute(
347            "UPDATE memories SET expires_at = ?1 WHERE id = ?2",
348            params![expires_at, id],
349        )?;
350        Ok(rows > 0)
351    }
352
353    /// Hard-delete memories whose `expires_at` is at or before `now`.
354    /// Returns the number of rows deleted. Called from the reflector on its
355    /// scheduled tick.
356    pub fn prune_expired_memories(&self, now: &str) -> Result<usize, MicroClawError> {
357        let conn = self.lock_conn();
358        let n = conn.execute(
359            "DELETE FROM memories WHERE expires_at IS NOT NULL AND expires_at <= ?1",
360            params![now],
361        )?;
362        Ok(n)
363    }
364
365    /// Update content and category of an existing memory. Returns true if found.
366    pub fn update_memory_content(
367        &self,
368        id: i64,
369        content: &str,
370        category: &str,
371    ) -> Result<bool, MicroClawError> {
372        let conn = self.lock_conn();
373        let now = chrono::Utc::now().to_rfc3339();
374        let rows = conn.execute(
375            "UPDATE memories
376             SET content = ?1,
377                 category = ?2,
378                 updated_at = ?3,
379                 embedding_model = NULL,
380                 last_seen_at = ?3,
381                 is_archived = 0,
382                 archived_at = NULL
383             WHERE id = ?4",
384            params![content, category, now, id],
385        )?;
386        Ok(rows > 0)
387    }
388
389    #[allow(clippy::too_many_arguments)]
390    pub fn update_memory_with_metadata(
391        &self,
392        id: i64,
393        content: &str,
394        category: &str,
395        confidence: f64,
396        source: &str,
397    ) -> Result<bool, MicroClawError> {
398        let conn = self.lock_conn();
399        let now = chrono::Utc::now().to_rfc3339();
400        let rows = conn.execute(
401            "UPDATE memories
402             SET content = ?1,
403                 category = ?2,
404                 updated_at = ?3,
405                 embedding_model = NULL,
406                 confidence = ?4,
407                 source = ?5,
408                 last_seen_at = ?3,
409                 is_archived = 0,
410                 archived_at = NULL
411             WHERE id = ?6",
412            params![
413                content,
414                category,
415                now,
416                confidence.clamp(0.0, 1.0),
417                source,
418                id
419            ],
420        )?;
421        Ok(rows > 0)
422    }
423
424    pub fn update_memory_embedding_model(
425        &self,
426        id: i64,
427        model: &str,
428    ) -> Result<bool, MicroClawError> {
429        let conn = self.lock_conn();
430        let rows = conn.execute(
431            "UPDATE memories SET embedding_model = ?1 WHERE id = ?2",
432            params![model, id],
433        )?;
434        Ok(rows > 0)
435    }
436
437    pub fn get_memories_without_embedding(
438        &self,
439        chat_id: Option<i64>,
440        limit: usize,
441    ) -> Result<Vec<Memory>, MicroClawError> {
442        let conn = self.lock_conn();
443        let mut query = String::from(
444            "SELECT id, chat_id, content, category, created_at, updated_at, embedding_model
445             , confidence, source, last_seen_at, is_archived, archived_at, expires_at
446             FROM memories
447             WHERE embedding_model IS NULL
448               AND is_archived = 0",
449        );
450        if chat_id.is_some() {
451            query.push_str(" AND chat_id = ?1");
452        }
453        query.push_str(" ORDER BY updated_at DESC LIMIT ");
454        query.push_str(&limit.to_string());
455
456        let mut stmt = conn.prepare(&query)?;
457        let mapper = |row: &rusqlite::Row<'_>| {
458            Ok(Memory {
459                id: row.get(0)?,
460                chat_id: row.get(1)?,
461                content: row.get(2)?,
462                category: row.get(3)?,
463                created_at: row.get(4)?,
464                updated_at: row.get(5)?,
465                embedding_model: row.get(6)?,
466                confidence: row.get(7)?,
467                source: row.get(8)?,
468                last_seen_at: row.get(9)?,
469                is_archived: row.get::<_, i64>(10)? != 0,
470                archived_at: row.get(11)?,
471                expires_at: row.get(12)?,
472            })
473        };
474
475        let rows = if let Some(cid) = chat_id {
476            stmt.query_map(params![cid], mapper)?
477        } else {
478            stmt.query_map([], mapper)?
479        };
480        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
481    }
482
483    #[cfg(feature = "sqlite-vec")]
484    pub fn prepare_vector_index(&self, dimension: usize) -> Result<(), MicroClawError> {
485        let conn = self.lock_conn();
486        let dimension = dimension.max(1);
487        conn.execute(
488            "CREATE TABLE IF NOT EXISTS db_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
489            [],
490        )?;
491
492        let current_dim: Option<String> = conn
493            .query_row(
494                "SELECT value FROM db_meta WHERE key = 'embedding_dim'",
495                [],
496                |row| row.get(0),
497            )
498            .optional()?;
499        if let Some(existing) = current_dim {
500            if existing != dimension.to_string() {
501                conn.execute("DROP TABLE IF EXISTS memories_vec", [])?;
502                conn.execute("UPDATE memories SET embedding_model = NULL", [])?;
503            }
504        }
505
506        conn.execute(
507            &format!(
508                "CREATE VIRTUAL TABLE IF NOT EXISTS memories_vec USING vec0(
509                    embedding float[{dimension}] distance_metric=cosine
510                )"
511            ),
512            [],
513        )?;
514        conn.execute(
515            "INSERT INTO db_meta(key, value) VALUES('embedding_dim', ?1)
516             ON CONFLICT(key) DO UPDATE SET value = excluded.value",
517            params![dimension.to_string()],
518        )?;
519        Ok(())
520    }
521
522    #[cfg(feature = "sqlite-vec")]
523    pub fn upsert_memory_vec(
524        &self,
525        memory_id: i64,
526        embedding: &[f32],
527    ) -> Result<(), MicroClawError> {
528        let conn = self.lock_conn();
529        let vector_json = serde_json::to_string(embedding)?;
530        conn.execute(
531            "INSERT OR REPLACE INTO memories_vec(rowid, embedding) VALUES(?1, vec_f32(?2))",
532            params![memory_id, vector_json],
533        )?;
534        Ok(())
535    }
536
537    pub fn get_all_active_memories(&self) -> Result<Vec<(i64, String)>, MicroClawError> {
538        let conn = self.lock_conn();
539        let mut stmt =
540            conn.prepare("SELECT id, content FROM memories WHERE is_archived = 0 ORDER BY id")?;
541        let rows = stmt.query_map([], |row| {
542            Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
543        })?;
544        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
545    }
546
547    #[cfg(feature = "sqlite-vec")]
548    pub fn knn_memories(
549        &self,
550        chat_id: i64,
551        query_vec: &[f32],
552        k: usize,
553    ) -> Result<Vec<(i64, f32)>, MicroClawError> {
554        let conn = self.lock_conn();
555        let vector_json = serde_json::to_string(query_vec)?;
556        let mut stmt = conn.prepare(
557            "SELECT m.id, v.distance
558             FROM (
559                SELECT rowid, distance
560                FROM memories_vec
561                WHERE embedding MATCH vec_f32(?1) AND k = ?2
562             ) v
563             JOIN memories m ON m.id = v.rowid
564             WHERE (m.chat_id = ?3 OR m.chat_id IS NULL)
565             ORDER BY v.distance ASC",
566        )?;
567        let rows = stmt.query_map(params![vector_json, k as i64, chat_id], |row| {
568            Ok((row.get::<_, i64>(0)?, row.get::<_, f32>(1)?))
569        })?;
570        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
571    }
572
573    /// Get a single memory by id.
574    pub fn get_memory_by_id(&self, id: i64) -> Result<Option<Memory>, MicroClawError> {
575        let conn = self.lock_conn();
576        let result = conn.query_row(
577            "SELECT id, chat_id, content, category, created_at, updated_at, embedding_model,
578                    confidence, source, last_seen_at, is_archived, archived_at, expires_at
579             FROM memories WHERE id = ?1",
580            params![id],
581            |row| {
582                Ok(Memory {
583                    id: row.get(0)?,
584                    chat_id: row.get(1)?,
585                    content: row.get(2)?,
586                    category: row.get(3)?,
587                    created_at: row.get(4)?,
588                    updated_at: row.get(5)?,
589                    embedding_model: row.get(6)?,
590                    confidence: row.get(7)?,
591                    source: row.get(8)?,
592                    last_seen_at: row.get(9)?,
593                    is_archived: row.get::<_, i64>(10)? != 0,
594                    archived_at: row.get(11)?,
595                    expires_at: row.get(12)?,
596                })
597            },
598        );
599        match result {
600            Ok(m) => Ok(Some(m)),
601            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
602            Err(e) => Err(e.into()),
603        }
604    }
605
606    pub fn touch_memory_last_seen(
607        &self,
608        id: i64,
609        confidence_floor: Option<f64>,
610    ) -> Result<bool, MicroClawError> {
611        let conn = self.lock_conn();
612        let now = chrono::Utc::now().to_rfc3339();
613        let rows = if let Some(floor) = confidence_floor {
614            conn.execute(
615                "UPDATE memories
616                 SET last_seen_at = ?1,
617                     confidence = MAX(confidence, ?2)
618                 WHERE id = ?3",
619                params![now, floor.clamp(0.0, 1.0), id],
620            )?
621        } else {
622            conn.execute(
623                "UPDATE memories SET last_seen_at = ?1 WHERE id = ?2",
624                params![now, id],
625            )?
626        };
627        Ok(rows > 0)
628    }
629
630    pub fn archive_memory(&self, id: i64) -> Result<bool, MicroClawError> {
631        let conn = self.lock_conn();
632        let now = chrono::Utc::now().to_rfc3339();
633        let rows = conn.execute(
634            "UPDATE memories
635             SET is_archived = 1, archived_at = ?1, updated_at = ?1
636             WHERE id = ?2",
637            params![now, id],
638        )?;
639        Ok(rows > 0)
640    }
641
642    pub fn archive_stale_memories(&self, stale_days: i64) -> Result<usize, MicroClawError> {
643        let conn = self.lock_conn();
644        let cutoff = (chrono::Utc::now() - chrono::Duration::days(stale_days.max(1))).to_rfc3339();
645        let now = chrono::Utc::now().to_rfc3339();
646        let rows = conn.execute(
647            "UPDATE memories
648             SET is_archived = 1, archived_at = ?1, updated_at = ?1
649             WHERE is_archived = 0
650               AND confidence < 0.35
651               AND COALESCE(last_seen_at, updated_at, created_at) < ?2",
652            params![now, cutoff],
653        )?;
654        Ok(rows)
655    }
656
657    /// Archive the lowest-confidence, least-recently-seen memories for a chat
658    /// (or global if `chat_id` is None) when the count exceeds `max_entries`.
659    /// Returns the number of memories archived.
660    pub fn archive_excess_memories(
661        &self,
662        chat_id: Option<i64>,
663        max_entries: usize,
664    ) -> Result<usize, MicroClawError> {
665        let conn = self.lock_conn();
666        let count: usize = if let Some(cid) = chat_id {
667            conn.query_row(
668                "SELECT COUNT(*) FROM memories WHERE is_archived = 0 AND chat_id = ?1",
669                params![cid],
670                |row| row.get(0),
671            )?
672        } else {
673            conn.query_row(
674                "SELECT COUNT(*) FROM memories WHERE is_archived = 0 AND chat_id IS NULL",
675                [],
676                |row| row.get(0),
677            )?
678        };
679        if count <= max_entries {
680            return Ok(0);
681        }
682        let excess = count - max_entries;
683        let now = chrono::Utc::now().to_rfc3339();
684        let rows = if let Some(cid) = chat_id {
685            conn.execute(
686                "UPDATE memories SET is_archived = 1, archived_at = ?1, updated_at = ?1
687                 WHERE is_archived = 0 AND chat_id = ?2
688                   AND id IN (
689                     SELECT id FROM memories
690                     WHERE is_archived = 0 AND chat_id = ?2
691                     ORDER BY confidence ASC, COALESCE(last_seen_at, updated_at, created_at) ASC
692                     LIMIT ?3
693                   )",
694                params![now, cid, excess],
695            )?
696        } else {
697            conn.execute(
698                "UPDATE memories SET is_archived = 1, archived_at = ?1, updated_at = ?1
699                 WHERE is_archived = 0 AND chat_id IS NULL
700                   AND id IN (
701                     SELECT id FROM memories
702                     WHERE is_archived = 0 AND chat_id IS NULL
703                     ORDER BY confidence ASC, COALESCE(last_seen_at, updated_at, created_at) ASC
704                     LIMIT ?2
705                   )",
706                params![now, excess],
707            )?
708        };
709        Ok(rows)
710    }
711
712    pub fn supersede_memory(
713        &self,
714        from_memory_id: i64,
715        new_content: &str,
716        category: &str,
717        source: &str,
718        confidence: f64,
719        reason: Option<&str>,
720    ) -> Result<i64, MicroClawError> {
721        let conn = self.lock_conn();
722        let tx = conn.unchecked_transaction()?;
723        let (chat_id, chat_channel, external_chat_id): (
724            Option<i64>,
725            Option<String>,
726            Option<String>,
727        ) = tx.query_row(
728            "SELECT chat_id, chat_channel, external_chat_id FROM memories WHERE id = ?1",
729            params![from_memory_id],
730            |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
731        )?;
732
733        let now = chrono::Utc::now().to_rfc3339();
734        tx.execute(
735            "INSERT INTO memories (
736                chat_id, content, category, created_at, updated_at, embedding_model,
737                confidence, source, last_seen_at, is_archived, archived_at, chat_channel, external_chat_id
738            ) VALUES (?1, ?2, ?3, ?4, ?4, NULL, ?5, ?6, ?4, 0, NULL, ?7, ?8)",
739            params![
740                chat_id,
741                new_content,
742                category,
743                now,
744                confidence.clamp(0.0, 1.0),
745                source,
746                chat_channel,
747                external_chat_id
748            ],
749        )?;
750        let to_memory_id = tx.last_insert_rowid();
751
752        tx.execute(
753            "UPDATE memories
754             SET is_archived = 1, archived_at = ?1, updated_at = ?1
755             WHERE id = ?2",
756            params![now, from_memory_id],
757        )?;
758        tx.execute(
759            "INSERT INTO memory_supersede_edges(from_memory_id, to_memory_id, reason, created_at)
760             VALUES(?1, ?2, ?3, ?4)",
761            params![from_memory_id, to_memory_id, reason, now],
762        )?;
763        tx.commit()?;
764        Ok(to_memory_id)
765    }
766
767    // ── Knowledge Graph operations ──
768
769    /// Insert a new knowledge graph triple.
770    #[allow(clippy::too_many_arguments)]
771    pub fn kg_insert_triple(
772        &self,
773        subject: &str,
774        predicate: &str,
775        object: &str,
776        chat_id: Option<i64>,
777        valid_from: &str,
778        confidence: f64,
779        source: &str,
780        source_memory_id: Option<i64>,
781    ) -> Result<i64, MicroClawError> {
782        let conn = self.lock_conn();
783        let now = chrono::Utc::now().to_rfc3339();
784        conn.execute(
785            "INSERT INTO knowledge_graph (subject, predicate, object, chat_id, valid_from, valid_to, confidence, source, source_memory_id, created_at)
786             VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, ?7, ?8, ?9)",
787            params![
788                subject,
789                predicate,
790                object,
791                chat_id,
792                valid_from,
793                confidence.clamp(0.0, 1.0),
794                source,
795                source_memory_id,
796                now
797            ],
798        )?;
799        Ok(conn.last_insert_rowid())
800    }
801
802    /// Invalidate a triple by setting its valid_to timestamp.
803    pub fn kg_invalidate_triple(&self, id: i64, valid_to: &str) -> Result<bool, MicroClawError> {
804        let conn = self.lock_conn();
805        let rows = conn.execute(
806            "UPDATE knowledge_graph SET valid_to = ?1 WHERE id = ?2 AND valid_to IS NULL",
807            params![valid_to, id],
808        )?;
809        Ok(rows > 0)
810    }
811
812    /// Query triples by subject, optionally filtered by a point-in-time (as_of).
813    pub fn kg_query_subject(
814        &self,
815        subject: &str,
816        chat_id: Option<i64>,
817        as_of: Option<&str>,
818    ) -> Result<Vec<KgTriple>, MicroClawError> {
819        let conn = self.lock_conn();
820        let map_row = |row: &rusqlite::Row| -> rusqlite::Result<KgTriple> {
821            Ok(KgTriple {
822                id: row.get(0)?,
823                subject: row.get(1)?,
824                predicate: row.get(2)?,
825                object: row.get(3)?,
826                chat_id: row.get(4)?,
827                valid_from: row.get(5)?,
828                valid_to: row.get(6)?,
829                confidence: row.get(7)?,
830                source: row.get(8)?,
831                source_memory_id: row.get(9)?,
832                created_at: row.get(10)?,
833            })
834        };
835        if let Some(ts) = as_of {
836            let mut stmt = conn.prepare(
837                "SELECT id, subject, predicate, object, chat_id, valid_from, valid_to, confidence, source, source_memory_id, created_at
838                 FROM knowledge_graph
839                 WHERE LOWER(subject) = LOWER(?1)
840                   AND (?2 IS NULL OR chat_id = ?2 OR chat_id IS NULL)
841                   AND valid_from <= ?3
842                   AND (valid_to IS NULL OR valid_to > ?3)
843                 ORDER BY valid_from DESC",
844            )?;
845            let rows = stmt
846                .query_map(params![subject, chat_id, ts], map_row)?
847                .collect::<Result<Vec<_>, _>>()?;
848            Ok(rows)
849        } else {
850            let mut stmt = conn.prepare(
851                "SELECT id, subject, predicate, object, chat_id, valid_from, valid_to, confidence, source, source_memory_id, created_at
852                 FROM knowledge_graph
853                 WHERE LOWER(subject) = LOWER(?1)
854                   AND (?2 IS NULL OR chat_id = ?2 OR chat_id IS NULL)
855                   AND valid_to IS NULL
856                 ORDER BY valid_from DESC",
857            )?;
858            let rows = stmt
859                .query_map(params![subject, chat_id], map_row)?
860                .collect::<Result<Vec<_>, _>>()?;
861            Ok(rows)
862        }
863    }
864
865    /// Query triples by object (reverse lookup).
866    pub fn kg_query_object(
867        &self,
868        object: &str,
869        chat_id: Option<i64>,
870    ) -> Result<Vec<KgTriple>, MicroClawError> {
871        let conn = self.lock_conn();
872        let mut stmt = conn.prepare(
873            "SELECT id, subject, predicate, object, chat_id, valid_from, valid_to, confidence, source, source_memory_id, created_at
874             FROM knowledge_graph
875             WHERE LOWER(object) = LOWER(?1)
876               AND (?2 IS NULL OR chat_id = ?2 OR chat_id IS NULL)
877               AND valid_to IS NULL
878             ORDER BY valid_from DESC",
879        )?;
880        let rows = stmt
881            .query_map(params![object, chat_id], |row| {
882                Ok(KgTriple {
883                    id: row.get(0)?,
884                    subject: row.get(1)?,
885                    predicate: row.get(2)?,
886                    object: row.get(3)?,
887                    chat_id: row.get(4)?,
888                    valid_from: row.get(5)?,
889                    valid_to: row.get(6)?,
890                    confidence: row.get(7)?,
891                    source: row.get(8)?,
892                    source_memory_id: row.get(9)?,
893                    created_at: row.get(10)?,
894                })
895            })?
896            .collect::<Result<Vec<_>, _>>()?;
897        Ok(rows)
898    }
899
900    /// Distinct active entities (subjects and objects) for a chat, used to find
901    /// which graph nodes a query mentions so retrieval can be seeded from them.
902    pub fn kg_distinct_entities(
903        &self,
904        chat_id: Option<i64>,
905        limit: usize,
906    ) -> Result<Vec<String>, MicroClawError> {
907        let conn = self.lock_conn();
908        let mut stmt = conn.prepare(
909            "SELECT entity FROM (
910                 SELECT subject AS entity FROM knowledge_graph
911                 WHERE (?1 IS NULL OR chat_id = ?1 OR chat_id IS NULL) AND valid_to IS NULL
912                 UNION
913                 SELECT object AS entity FROM knowledge_graph
914                 WHERE (?1 IS NULL OR chat_id = ?1 OR chat_id IS NULL) AND valid_to IS NULL
915             )
916             ORDER BY LENGTH(entity) DESC
917             LIMIT ?2",
918        )?;
919        let rows = stmt
920            .query_map(params![chat_id, limit as i64], |row| {
921                row.get::<_, String>(0)
922            })?
923            .collect::<Result<Vec<_>, _>>()?;
924        Ok(rows)
925    }
926
927    /// Bounded breadth-first expansion of the knowledge graph from `seed`
928    /// entities, returning the active triples reachable within `max_hops`. This
929    /// is the graph-augmented retrieval primitive: seed from entities a query
930    /// mentions, then pull in directly-connected facts (and their neighbours)
931    /// so multi-hop context surfaces without the agent having to query the graph
932    /// by hand. Bounded by `total_limit` triples and a per-hop frontier cap, so
933    /// it stays cheap even on large graphs.
934    pub fn kg_neighborhood(
935        &self,
936        chat_id: Option<i64>,
937        seeds: &[String],
938        max_hops: usize,
939        total_limit: usize,
940    ) -> Result<Vec<KgTriple>, MicroClawError> {
941        if seeds.is_empty() || total_limit == 0 || max_hops == 0 {
942            return Ok(Vec::new());
943        }
944        const FRONTIER_CAP: usize = 32;
945        const PER_NODE_LIMIT: i64 = 8;
946        let conn = self.lock_conn();
947        let mut stmt = conn.prepare(
948            "SELECT id, subject, predicate, object, chat_id, valid_from, valid_to, confidence, source, source_memory_id, created_at
949             FROM knowledge_graph
950             WHERE (LOWER(subject) = LOWER(?1) OR LOWER(object) = LOWER(?1))
951               AND (?2 IS NULL OR chat_id = ?2 OR chat_id IS NULL)
952               AND valid_to IS NULL
953             ORDER BY confidence DESC
954             LIMIT ?3",
955        )?;
956        let map_row = |row: &rusqlite::Row| -> rusqlite::Result<KgTriple> {
957            Ok(KgTriple {
958                id: row.get(0)?,
959                subject: row.get(1)?,
960                predicate: row.get(2)?,
961                object: row.get(3)?,
962                chat_id: row.get(4)?,
963                valid_from: row.get(5)?,
964                valid_to: row.get(6)?,
965                confidence: row.get(7)?,
966                source: row.get(8)?,
967                source_memory_id: row.get(9)?,
968                created_at: row.get(10)?,
969            })
970        };
971
972        let mut visited_entities: std::collections::HashSet<String> =
973            std::collections::HashSet::new();
974        let mut seen_triples: std::collections::HashSet<i64> = std::collections::HashSet::new();
975        let mut frontier: Vec<String> = Vec::new();
976        for s in seeds {
977            let lc = s.to_lowercase();
978            if visited_entities.insert(lc) {
979                frontier.push(s.clone());
980            }
981        }
982        let mut out: Vec<KgTriple> = Vec::new();
983
984        for _hop in 0..max_hops {
985            if out.len() >= total_limit || frontier.is_empty() {
986                break;
987            }
988            frontier.truncate(FRONTIER_CAP);
989            let mut next: Vec<String> = Vec::new();
990            for entity in &frontier {
991                let rows = stmt
992                    .query_map(params![entity, chat_id, PER_NODE_LIMIT], map_row)?
993                    .collect::<Result<Vec<_>, _>>()?;
994                for t in rows {
995                    if !seen_triples.insert(t.id) {
996                        continue;
997                    }
998                    for endpoint in [&t.subject, &t.object] {
999                        let lc = endpoint.to_lowercase();
1000                        if visited_entities.insert(lc) {
1001                            next.push(endpoint.clone());
1002                        }
1003                    }
1004                    out.push(t);
1005                    if out.len() >= total_limit {
1006                        break;
1007                    }
1008                }
1009                if out.len() >= total_limit {
1010                    break;
1011                }
1012            }
1013            frontier = next;
1014        }
1015
1016        out.sort_by(|a, b| {
1017            b.confidence
1018                .partial_cmp(&a.confidence)
1019                .unwrap_or(std::cmp::Ordering::Equal)
1020        });
1021        out.truncate(total_limit);
1022        Ok(out)
1023    }
1024
1025    /// Get a timeline of all triples for a subject, including invalidated ones.
1026    pub fn kg_timeline(
1027        &self,
1028        subject: &str,
1029        chat_id: Option<i64>,
1030    ) -> Result<Vec<KgTriple>, MicroClawError> {
1031        let conn = self.lock_conn();
1032        let mut stmt = conn.prepare(
1033            "SELECT id, subject, predicate, object, chat_id, valid_from, valid_to, confidence, source, source_memory_id, created_at
1034             FROM knowledge_graph
1035             WHERE LOWER(subject) = LOWER(?1)
1036               AND (?2 IS NULL OR chat_id = ?2 OR chat_id IS NULL)
1037             ORDER BY valid_from ASC",
1038        )?;
1039        let rows = stmt
1040            .query_map(params![subject, chat_id], |row| {
1041                Ok(KgTriple {
1042                    id: row.get(0)?,
1043                    subject: row.get(1)?,
1044                    predicate: row.get(2)?,
1045                    object: row.get(3)?,
1046                    chat_id: row.get(4)?,
1047                    valid_from: row.get(5)?,
1048                    valid_to: row.get(6)?,
1049                    confidence: row.get(7)?,
1050                    source: row.get(8)?,
1051                    source_memory_id: row.get(9)?,
1052                    created_at: row.get(10)?,
1053                })
1054            })?
1055            .collect::<Result<Vec<_>, _>>()?;
1056        Ok(rows)
1057    }
1058
1059    /// Get knowledge graph stats (total triples, active, invalidated).
1060    pub fn kg_stats(&self, chat_id: Option<i64>) -> Result<(usize, usize, usize), MicroClawError> {
1061        let conn = self.lock_conn();
1062        let total: usize = conn.query_row(
1063            "SELECT COUNT(*) FROM knowledge_graph WHERE (?1 IS NULL OR chat_id = ?1 OR chat_id IS NULL)",
1064            params![chat_id],
1065            |row| row.get(0),
1066        )?;
1067        let active: usize = conn.query_row(
1068            "SELECT COUNT(*) FROM knowledge_graph WHERE valid_to IS NULL AND (?1 IS NULL OR chat_id = ?1 OR chat_id IS NULL)",
1069            params![chat_id],
1070            |row| row.get(0),
1071        )?;
1072        Ok((total, active, total.saturating_sub(active)))
1073    }
1074
1075    /// Delete oldest invalidated triples when count exceeds max_triples for a chat.
1076    /// If still over limit after pruning invalidated, also deletes oldest active triples by confidence.
1077    pub fn kg_prune_excess(
1078        &self,
1079        chat_id: i64,
1080        max_triples: usize,
1081    ) -> Result<usize, MicroClawError> {
1082        let conn = self.lock_conn();
1083        let count: usize = conn.query_row(
1084            "SELECT COUNT(*) FROM knowledge_graph WHERE chat_id = ?1",
1085            params![chat_id],
1086            |row| row.get(0),
1087        )?;
1088        if count <= max_triples {
1089            return Ok(0);
1090        }
1091        let excess = count - max_triples;
1092        // First: delete invalidated triples (oldest first)
1093        let deleted_invalidated = conn.execute(
1094            "DELETE FROM knowledge_graph WHERE id IN (
1095                SELECT id FROM knowledge_graph
1096                WHERE chat_id = ?1 AND valid_to IS NOT NULL
1097                ORDER BY created_at ASC
1098                LIMIT ?2
1099            )",
1100            params![chat_id, excess],
1101        )?;
1102        let remaining_excess = excess.saturating_sub(deleted_invalidated);
1103        if remaining_excess == 0 {
1104            return Ok(deleted_invalidated);
1105        }
1106        // Still over: delete lowest-confidence active triples
1107        let deleted_active = conn.execute(
1108            "DELETE FROM knowledge_graph WHERE id IN (
1109                SELECT id FROM knowledge_graph
1110                WHERE chat_id = ?1 AND valid_to IS NULL
1111                ORDER BY confidence ASC, created_at ASC
1112                LIMIT ?2
1113            )",
1114            params![chat_id, remaining_excess],
1115        )?;
1116        Ok(deleted_invalidated + deleted_active)
1117    }
1118
1119    #[allow(clippy::too_many_arguments)]
1120    pub fn log_reflector_run(
1121        &self,
1122        chat_id: i64,
1123        started_at: &str,
1124        finished_at: &str,
1125        extracted_count: usize,
1126        inserted_count: usize,
1127        updated_count: usize,
1128        skipped_count: usize,
1129        dedup_method: &str,
1130        parse_ok: bool,
1131        error_text: Option<&str>,
1132    ) -> Result<i64, MicroClawError> {
1133        let conn = self.lock_conn();
1134        conn.execute(
1135            "INSERT INTO memory_reflector_runs (
1136                chat_id, started_at, finished_at, extracted_count, inserted_count, updated_count, skipped_count, dedup_method, parse_ok, error_text
1137             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
1138            params![
1139                chat_id,
1140                started_at,
1141                finished_at,
1142                extracted_count as i64,
1143                inserted_count as i64,
1144                updated_count as i64,
1145                skipped_count as i64,
1146                dedup_method,
1147                if parse_ok { 1 } else { 0 },
1148                error_text
1149            ],
1150        )?;
1151        Ok(conn.last_insert_rowid())
1152    }
1153
1154    pub fn log_memory_injection(
1155        &self,
1156        chat_id: i64,
1157        retrieval_method: &str,
1158        candidate_count: usize,
1159        selected_count: usize,
1160        omitted_count: usize,
1161        tokens_est: usize,
1162    ) -> Result<i64, MicroClawError> {
1163        let conn = self.lock_conn();
1164        let now = chrono::Utc::now().to_rfc3339();
1165        conn.execute(
1166            "INSERT INTO memory_injection_logs (
1167                chat_id, created_at, retrieval_method, candidate_count, selected_count, omitted_count, tokens_est
1168             ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1169            params![
1170                chat_id,
1171                now,
1172                retrieval_method,
1173                candidate_count as i64,
1174                selected_count as i64,
1175                omitted_count as i64,
1176                tokens_est as i64
1177            ],
1178        )?;
1179        Ok(conn.last_insert_rowid())
1180    }
1181
1182    pub fn get_memory_observability_summary(
1183        &self,
1184        chat_id: Option<i64>,
1185    ) -> Result<MemoryObservabilitySummary, MicroClawError> {
1186        let conn = self.lock_conn();
1187        let since_24h = (chrono::Utc::now() - chrono::Duration::hours(24)).to_rfc3339();
1188
1189        let (total, active, archived, low_confidence, avg_confidence) = if let Some(cid) = chat_id {
1190            conn.query_row(
1191                "SELECT
1192                    COUNT(*),
1193                    COALESCE(SUM(CASE WHEN is_archived = 0 THEN 1 ELSE 0 END), 0),
1194                    COALESCE(SUM(CASE WHEN is_archived != 0 THEN 1 ELSE 0 END), 0),
1195                    COALESCE(SUM(CASE WHEN confidence < 0.45 THEN 1 ELSE 0 END), 0),
1196                    COALESCE(AVG(confidence), 0.0)
1197                 FROM memories
1198                 WHERE chat_id = ?1 OR chat_id IS NULL",
1199                params![cid],
1200                |row| {
1201                    Ok((
1202                        row.get::<_, i64>(0)?,
1203                        row.get::<_, i64>(1)?,
1204                        row.get::<_, i64>(2)?,
1205                        row.get::<_, i64>(3)?,
1206                        row.get::<_, f64>(4)?,
1207                    ))
1208                },
1209            )?
1210        } else {
1211            conn.query_row(
1212                "SELECT
1213                    COUNT(*),
1214                    COALESCE(SUM(CASE WHEN is_archived = 0 THEN 1 ELSE 0 END), 0),
1215                    COALESCE(SUM(CASE WHEN is_archived != 0 THEN 1 ELSE 0 END), 0),
1216                    COALESCE(SUM(CASE WHEN confidence < 0.45 THEN 1 ELSE 0 END), 0),
1217                    COALESCE(AVG(confidence), 0.0)
1218                 FROM memories",
1219                [],
1220                |row| {
1221                    Ok((
1222                        row.get::<_, i64>(0)?,
1223                        row.get::<_, i64>(1)?,
1224                        row.get::<_, i64>(2)?,
1225                        row.get::<_, i64>(3)?,
1226                        row.get::<_, f64>(4)?,
1227                    ))
1228                },
1229            )?
1230        };
1231
1232        let (
1233            reflector_runs_24h,
1234            reflector_inserted_24h,
1235            reflector_updated_24h,
1236            reflector_skipped_24h,
1237        ) = if let Some(cid) = chat_id {
1238            conn.query_row(
1239                "SELECT
1240                        COUNT(*),
1241                        COALESCE(SUM(inserted_count), 0),
1242                        COALESCE(SUM(updated_count), 0),
1243                        COALESCE(SUM(skipped_count), 0)
1244                     FROM memory_reflector_runs
1245                     WHERE chat_id = ?1 AND unixepoch(started_at) >= unixepoch(?2)",
1246                params![cid, &since_24h],
1247                |row| {
1248                    Ok((
1249                        row.get::<_, i64>(0)?,
1250                        row.get::<_, i64>(1)?,
1251                        row.get::<_, i64>(2)?,
1252                        row.get::<_, i64>(3)?,
1253                    ))
1254                },
1255            )?
1256        } else {
1257            conn.query_row(
1258                "SELECT
1259                        COUNT(*),
1260                        COALESCE(SUM(inserted_count), 0),
1261                        COALESCE(SUM(updated_count), 0),
1262                        COALESCE(SUM(skipped_count), 0)
1263                     FROM memory_reflector_runs
1264                     WHERE unixepoch(started_at) >= unixepoch(?1)",
1265                params![&since_24h],
1266                |row| {
1267                    Ok((
1268                        row.get::<_, i64>(0)?,
1269                        row.get::<_, i64>(1)?,
1270                        row.get::<_, i64>(2)?,
1271                        row.get::<_, i64>(3)?,
1272                    ))
1273                },
1274            )?
1275        };
1276
1277        let (injection_events_24h, injection_selected_24h, injection_candidates_24h) =
1278            if let Some(cid) = chat_id {
1279                conn.query_row(
1280                    "SELECT
1281                        COUNT(*),
1282                        COALESCE(SUM(selected_count), 0),
1283                        COALESCE(SUM(candidate_count), 0)
1284                     FROM memory_injection_logs
1285                     WHERE chat_id = ?1 AND unixepoch(created_at) >= unixepoch(?2)",
1286                    params![cid, &since_24h],
1287                    |row| {
1288                        Ok((
1289                            row.get::<_, i64>(0)?,
1290                            row.get::<_, i64>(1)?,
1291                            row.get::<_, i64>(2)?,
1292                        ))
1293                    },
1294                )?
1295            } else {
1296                conn.query_row(
1297                    "SELECT
1298                        COUNT(*),
1299                        COALESCE(SUM(selected_count), 0),
1300                        COALESCE(SUM(candidate_count), 0)
1301                     FROM memory_injection_logs
1302                     WHERE unixepoch(created_at) >= unixepoch(?1)",
1303                    params![&since_24h],
1304                    |row| {
1305                        Ok((
1306                            row.get::<_, i64>(0)?,
1307                            row.get::<_, i64>(1)?,
1308                            row.get::<_, i64>(2)?,
1309                        ))
1310                    },
1311                )?
1312            };
1313
1314        Ok(MemoryObservabilitySummary {
1315            total,
1316            active,
1317            archived,
1318            low_confidence,
1319            avg_confidence,
1320            reflector_runs_24h,
1321            reflector_inserted_24h,
1322            reflector_updated_24h,
1323            reflector_skipped_24h,
1324            injection_events_24h,
1325            injection_selected_24h,
1326            injection_candidates_24h,
1327        })
1328    }
1329
1330    pub fn get_memory_reflector_runs(
1331        &self,
1332        chat_id: Option<i64>,
1333        since: Option<&str>,
1334        limit: usize,
1335        offset: usize,
1336    ) -> Result<Vec<MemoryReflectorRun>, MicroClawError> {
1337        let conn = self.lock_conn();
1338        let mut query = String::from(
1339            "SELECT id, chat_id, started_at, finished_at, extracted_count, inserted_count, updated_count, skipped_count, dedup_method, parse_ok, error_text
1340             FROM memory_reflector_runs",
1341        );
1342        let mut where_parts: Vec<&str> = Vec::new();
1343        if chat_id.is_some() {
1344            where_parts.push("chat_id = ?1");
1345        }
1346        if since.is_some() {
1347            where_parts.push(if chat_id.is_some() {
1348                "unixepoch(started_at) >= unixepoch(?2)"
1349            } else {
1350                "unixepoch(started_at) >= unixepoch(?1)"
1351            });
1352        }
1353        if !where_parts.is_empty() {
1354            query.push_str(" WHERE ");
1355            query.push_str(&where_parts.join(" AND "));
1356        }
1357        query.push_str(" ORDER BY unixepoch(started_at) ASC LIMIT ");
1358        query.push_str(&limit.max(1).to_string());
1359        query.push_str(" OFFSET ");
1360        query.push_str(&offset.to_string());
1361
1362        let mut stmt = conn.prepare(&query)?;
1363        let mapper = |row: &rusqlite::Row<'_>| {
1364            Ok(MemoryReflectorRun {
1365                id: row.get(0)?,
1366                chat_id: row.get(1)?,
1367                started_at: row.get(2)?,
1368                finished_at: row.get(3)?,
1369                extracted_count: row.get(4)?,
1370                inserted_count: row.get(5)?,
1371                updated_count: row.get(6)?,
1372                skipped_count: row.get(7)?,
1373                dedup_method: row.get(8)?,
1374                parse_ok: row.get::<_, i64>(9)? != 0,
1375                error_text: row.get(10)?,
1376            })
1377        };
1378        let rows = match (chat_id, since) {
1379            (Some(cid), Some(ts)) => stmt.query_map(params![cid, ts], mapper)?,
1380            (Some(cid), None) => stmt.query_map(params![cid], mapper)?,
1381            (None, Some(ts)) => stmt.query_map(params![ts], mapper)?,
1382            (None, None) => stmt.query_map([], mapper)?,
1383        };
1384        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
1385    }
1386
1387    pub fn get_memory_injection_logs(
1388        &self,
1389        chat_id: Option<i64>,
1390        since: Option<&str>,
1391        limit: usize,
1392        offset: usize,
1393    ) -> Result<Vec<MemoryInjectionLog>, MicroClawError> {
1394        let conn = self.lock_conn();
1395        let mut query = String::from(
1396            "SELECT id, chat_id, created_at, retrieval_method, candidate_count, selected_count, omitted_count, tokens_est
1397             FROM memory_injection_logs",
1398        );
1399        let mut where_parts: Vec<&str> = Vec::new();
1400        if chat_id.is_some() {
1401            where_parts.push("chat_id = ?1");
1402        }
1403        if since.is_some() {
1404            where_parts.push(if chat_id.is_some() {
1405                "unixepoch(created_at) >= unixepoch(?2)"
1406            } else {
1407                "unixepoch(created_at) >= unixepoch(?1)"
1408            });
1409        }
1410        if !where_parts.is_empty() {
1411            query.push_str(" WHERE ");
1412            query.push_str(&where_parts.join(" AND "));
1413        }
1414        query.push_str(" ORDER BY unixepoch(created_at) ASC LIMIT ");
1415        query.push_str(&limit.max(1).to_string());
1416        query.push_str(" OFFSET ");
1417        query.push_str(&offset.to_string());
1418
1419        let mut stmt = conn.prepare(&query)?;
1420        let mapper = |row: &rusqlite::Row<'_>| {
1421            Ok(MemoryInjectionLog {
1422                id: row.get(0)?,
1423                chat_id: row.get(1)?,
1424                created_at: row.get(2)?,
1425                retrieval_method: row.get(3)?,
1426                candidate_count: row.get(4)?,
1427                selected_count: row.get(5)?,
1428                omitted_count: row.get(6)?,
1429                tokens_est: row.get(7)?,
1430            })
1431        };
1432        let rows = match (chat_id, since) {
1433            (Some(cid), Some(ts)) => stmt.query_map(params![cid, ts], mapper)?,
1434            (Some(cid), None) => stmt.query_map(params![cid], mapper)?,
1435            (None, Some(ts)) => stmt.query_map(params![ts], mapper)?,
1436            (None, None) => stmt.query_map([], mapper)?,
1437        };
1438        rows.collect::<Result<Vec<_>, _>>().map_err(Into::into)
1439    }
1440}
1441
1442#[cfg(test)]
1443mod tests {
1444    use super::*;
1445    use crate::internal::storage::db::test_support::*;
1446
1447    #[test]
1448    fn test_reflector_cursor_roundtrip() {
1449        let (db, dir) = test_db();
1450        assert!(db.get_reflector_cursor(100).unwrap().is_none());
1451
1452        db.set_reflector_cursor(100, "2024-01-01T00:00:03Z")
1453            .unwrap();
1454        assert_eq!(
1455            db.get_reflector_cursor(100).unwrap().as_deref(),
1456            Some("2024-01-01T00:00:03Z")
1457        );
1458
1459        db.set_reflector_cursor(100, "2024-01-01T00:00:05Z")
1460            .unwrap();
1461        assert_eq!(
1462            db.get_reflector_cursor(100).unwrap().as_deref(),
1463            Some("2024-01-01T00:00:05Z")
1464        );
1465
1466        cleanup(&dir);
1467    }
1468
1469    #[test]
1470    fn test_kg_neighborhood_bounded_multihop_expansion() {
1471        let (db, dir) = test_db();
1472        let chat = Some(7i64);
1473        let vf = "2026-01-01T00:00:00Z";
1474        // Alice -works_at-> Acme -located_in-> Berlin ; plus a noise edge.
1475        db.kg_insert_triple("Alice", "works_at", "Acme", chat, vf, 0.9, "test", None)
1476            .unwrap();
1477        db.kg_insert_triple("Acme", "located_in", "Berlin", chat, vf, 0.8, "test", None)
1478            .unwrap();
1479        db.kg_insert_triple(
1480            "Berlin",
1481            "capital_of",
1482            "Germany",
1483            chat,
1484            vf,
1485            0.7,
1486            "test",
1487            None,
1488        )
1489        .unwrap();
1490        db.kg_insert_triple("Zoe", "likes", "Tea", chat, vf, 0.6, "test", None)
1491            .unwrap();
1492
1493        // Distinct entities should include all nodes, longest-first.
1494        let ents = db.kg_distinct_entities(chat, 100).unwrap();
1495        assert!(ents.iter().any(|e| e == "Acme"));
1496        assert!(ents.iter().any(|e| e == "Germany"));
1497
1498        // 1 hop from Alice reaches the works_at edge but not located_in.
1499        let one = db
1500            .kg_neighborhood(chat, &["Alice".to_string()], 1, 10)
1501            .unwrap();
1502        assert!(one.iter().any(|t| t.predicate == "works_at"));
1503        assert!(!one.iter().any(|t| t.predicate == "located_in"));
1504
1505        // 2 hops from Alice pulls in Acme's edges (multi-hop), but never Zoe's.
1506        let two = db
1507            .kg_neighborhood(chat, &["Alice".to_string()], 2, 10)
1508            .unwrap();
1509        assert!(two.iter().any(|t| t.predicate == "located_in"));
1510        assert!(!two.iter().any(|t| t.subject == "Zoe"));
1511
1512        // total_limit is respected.
1513        let capped = db
1514            .kg_neighborhood(chat, &["Alice".to_string()], 3, 1)
1515            .unwrap();
1516        assert_eq!(capped.len(), 1);
1517
1518        // Empty seeds → empty result, no panic.
1519        assert!(db.kg_neighborhood(chat, &[], 2, 10).unwrap().is_empty());
1520        cleanup(&dir);
1521    }
1522
1523    #[test]
1524    fn test_insert_and_get_memories_for_context() {
1525        let (db, dir) = test_db();
1526        db.insert_memory(Some(100), "User is a Rust developer", "PROFILE")
1527            .unwrap();
1528        db.insert_memory(Some(100), "User lives in Tokyo", "PROFILE")
1529            .unwrap();
1530        db.insert_memory(None, "Global fact", "KNOWLEDGE").unwrap();
1531        db.insert_memory(Some(200), "Other chat memory", "EVENT")
1532            .unwrap();
1533
1534        // chat 100 should see its own + global, not chat 200
1535        let mems = db.get_memories_for_context(100, 10).unwrap();
1536        assert_eq!(mems.len(), 3);
1537        let contents: Vec<&str> = mems.iter().map(|m| m.content.as_str()).collect();
1538        assert!(contents.contains(&"User is a Rust developer"));
1539        assert!(contents.contains(&"User lives in Tokyo"));
1540        assert!(contents.contains(&"Global fact"));
1541        assert!(!contents.contains(&"Other chat memory"));
1542
1543        cleanup(&dir);
1544    }
1545
1546    #[test]
1547    fn test_get_memories_for_context_limit() {
1548        let (db, dir) = test_db();
1549        for i in 0..5 {
1550            db.insert_memory(Some(100), &format!("memory {i}"), "KNOWLEDGE")
1551                .unwrap();
1552        }
1553        let mems = db.get_memories_for_context(100, 3).unwrap();
1554        assert_eq!(mems.len(), 3);
1555        cleanup(&dir);
1556    }
1557
1558    #[test]
1559    fn test_get_all_memories_for_chat() {
1560        let (db, dir) = test_db();
1561        db.insert_memory(Some(100), "chat 100 mem", "PROFILE")
1562            .unwrap();
1563        db.insert_memory(Some(100), "chat 100 mem 2", "EVENT")
1564            .unwrap();
1565        db.insert_memory(Some(200), "chat 200 mem", "PROFILE")
1566            .unwrap();
1567        db.insert_memory(None, "global mem", "KNOWLEDGE").unwrap();
1568
1569        let mems = db.get_all_memories_for_chat(Some(100)).unwrap();
1570        assert_eq!(mems.len(), 2);
1571
1572        let global = db.get_all_memories_for_chat(None).unwrap();
1573        assert_eq!(global.len(), 1);
1574        assert_eq!(global[0].content, "global mem");
1575
1576        cleanup(&dir);
1577    }
1578
1579    #[test]
1580    fn test_search_memories() {
1581        let (db, dir) = test_db();
1582        db.insert_memory(Some(100), "User is a Rust developer", "PROFILE")
1583            .unwrap();
1584        db.insert_memory(Some(100), "User loves coffee", "PROFILE")
1585            .unwrap();
1586        db.insert_memory(None, "Rust is fast and safe", "KNOWLEDGE")
1587            .unwrap();
1588
1589        let results = db.search_memories(100, "rust", 10).unwrap();
1590        assert_eq!(results.len(), 2); // own + global both match "rust"
1591
1592        let results = db.search_memories(100, "coffee", 10).unwrap();
1593        assert_eq!(results.len(), 1);
1594
1595        let results = db.search_memories(100, "nonexistent_xyz", 10).unwrap();
1596        assert!(results.is_empty());
1597
1598        cleanup(&dir);
1599    }
1600
1601    #[test]
1602    fn test_archive_memory_hides_from_search_and_context() {
1603        let (db, dir) = test_db();
1604        let id = db
1605            .insert_memory(Some(100), "User prefers concise summaries", "PROFILE")
1606            .unwrap();
1607        assert!(db.archive_memory(id).unwrap());
1608
1609        let mem = db.get_memory_by_id(id).unwrap().unwrap();
1610        assert!(mem.is_archived);
1611        assert!(mem.archived_at.is_some());
1612
1613        let search = db.search_memories(100, "concise", 10).unwrap();
1614        assert!(search.is_empty());
1615        let context = db.get_memories_for_context(100, 10).unwrap();
1616        assert!(context.is_empty());
1617
1618        cleanup(&dir);
1619    }
1620
1621    #[test]
1622    fn test_memory_observability_summary_rollup() {
1623        let (db, dir) = test_db();
1624        let started_at_dt = chrono::Utc::now() - chrono::Duration::minutes(1);
1625        let started_at = started_at_dt.to_rfc3339();
1626        let finished_at = (started_at_dt + chrono::Duration::seconds(1)).to_rfc3339();
1627        db.insert_memory_with_metadata(Some(100), "prod db on 5433", "KNOWLEDGE", "explicit", 0.95)
1628            .unwrap();
1629        let stale_id = db
1630            .insert_memory_with_metadata(Some(100), "temporary thought", "EVENT", "reflector", 0.20)
1631            .unwrap();
1632        db.archive_memory(stale_id).unwrap();
1633        db.log_reflector_run(
1634            100,
1635            &started_at,
1636            &finished_at,
1637            3,
1638            1,
1639            1,
1640            1,
1641            "jaccard",
1642            true,
1643            None,
1644        )
1645        .unwrap();
1646        db.log_memory_injection(100, "keyword", 5, 2, 3, 80)
1647            .unwrap();
1648
1649        let summary = db.get_memory_observability_summary(Some(100)).unwrap();
1650        assert!(summary.total >= 2);
1651        assert!(summary.active >= 1);
1652        assert!(summary.archived >= 1);
1653        assert!(summary.reflector_runs_24h >= 1);
1654        assert!(summary.injection_events_24h >= 1);
1655        assert!(summary.injection_candidates_24h >= summary.injection_selected_24h);
1656
1657        cleanup(&dir);
1658    }
1659
1660    #[test]
1661    fn test_supersede_memory_creates_edge_and_archives_old() {
1662        let (db, dir) = test_db();
1663        let old_id = db
1664            .insert_memory_with_metadata(
1665                Some(100),
1666                "prod db port is 5433",
1667                "KNOWLEDGE",
1668                "explicit",
1669                0.95,
1670            )
1671            .unwrap();
1672        let new_id = db
1673            .supersede_memory(
1674                old_id,
1675                "prod db port is 6432",
1676                "KNOWLEDGE",
1677                "explicit_conflict",
1678                0.96,
1679                Some("port_update"),
1680            )
1681            .unwrap();
1682        assert!(new_id > old_id);
1683        let old = db.get_memory_by_id(old_id).unwrap().unwrap();
1684        let newm = db.get_memory_by_id(new_id).unwrap().unwrap();
1685        assert!(old.is_archived);
1686        assert_eq!(newm.content, "prod db port is 6432");
1687
1688        let conn = db.lock_conn();
1689        let edge_count: i64 = conn
1690            .query_row(
1691                "SELECT COUNT(*) FROM memory_supersede_edges WHERE from_memory_id = ?1 AND to_memory_id = ?2",
1692                params![old_id, new_id],
1693                |row| row.get(0),
1694            )
1695            .unwrap();
1696        assert_eq!(edge_count, 1);
1697        drop(conn);
1698        cleanup(&dir);
1699    }
1700
1701    #[test]
1702    fn test_delete_memory() {
1703        let (db, dir) = test_db();
1704        let id = db
1705            .insert_memory(Some(100), "to be deleted", "EVENT")
1706            .unwrap();
1707
1708        assert!(db.delete_memory(id).unwrap());
1709        assert!(!db.delete_memory(id).unwrap()); // already gone
1710        assert!(db.get_memory_by_id(id).unwrap().is_none());
1711
1712        cleanup(&dir);
1713    }
1714
1715    #[test]
1716    fn test_update_memory_content() {
1717        let (db, dir) = test_db();
1718        let id = db
1719            .insert_memory(Some(100), "User lives in Tokyo", "PROFILE")
1720            .unwrap();
1721
1722        assert!(db
1723            .update_memory_content(id, "User lives in Osaka", "PROFILE")
1724            .unwrap());
1725
1726        let mem = db.get_memory_by_id(id).unwrap().unwrap();
1727        assert_eq!(mem.content, "User lives in Osaka");
1728        assert_eq!(mem.category, "PROFILE");
1729
1730        // Non-existent id
1731        assert!(!db.update_memory_content(9999, "x", "PROFILE").unwrap());
1732
1733        cleanup(&dir);
1734    }
1735
1736    #[test]
1737    fn test_get_memory_by_id() {
1738        let (db, dir) = test_db();
1739        let id = db
1740            .insert_memory(Some(100), "test memory", "KNOWLEDGE")
1741            .unwrap();
1742
1743        let mem = db.get_memory_by_id(id).unwrap().unwrap();
1744        assert_eq!(mem.id, id);
1745        assert_eq!(mem.content, "test memory");
1746        assert_eq!(mem.category, "KNOWLEDGE");
1747
1748        assert!(db.get_memory_by_id(9999).unwrap().is_none());
1749
1750        cleanup(&dir);
1751    }
1752
1753    #[test]
1754    fn test_update_memory_embedding_model_and_query_missing() {
1755        let (db, dir) = test_db();
1756        let id1 = db
1757            .insert_memory(Some(100), "memory one", "KNOWLEDGE")
1758            .unwrap();
1759        let id2 = db
1760            .insert_memory(Some(100), "memory two", "KNOWLEDGE")
1761            .unwrap();
1762
1763        let missing_before = db.get_memories_without_embedding(Some(100), 10).unwrap();
1764        assert_eq!(missing_before.len(), 2);
1765
1766        assert!(db
1767            .update_memory_embedding_model(id1, "text-embedding-3-small")
1768            .unwrap());
1769
1770        let mem1 = db.get_memory_by_id(id1).unwrap().unwrap();
1771        assert_eq!(
1772            mem1.embedding_model.as_deref(),
1773            Some("text-embedding-3-small")
1774        );
1775        let mem2 = db.get_memory_by_id(id2).unwrap().unwrap();
1776        assert!(mem2.embedding_model.is_none());
1777
1778        let missing_after = db.get_memories_without_embedding(Some(100), 10).unwrap();
1779        assert_eq!(missing_after.len(), 1);
1780        assert_eq!(missing_after[0].id, id2);
1781
1782        cleanup(&dir);
1783    }
1784
1785    #[test]
1786    fn test_memory_ttl_filters_and_prunes() {
1787        let (db, dir) = test_db();
1788        let durable = db
1789            .insert_memory(Some(7), "durable fact", "KNOWLEDGE")
1790            .unwrap();
1791        let expiring = db
1792            .insert_memory(Some(7), "transient fact", "KNOWLEDGE")
1793            .unwrap();
1794        let past = (chrono::Utc::now() - chrono::Duration::seconds(1)).to_rfc3339();
1795        let future = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
1796
1797        // Future expiry — still surfaced in retrieval.
1798        db.set_memory_expires_at(durable, Some(&future)).unwrap();
1799        // Past expiry — gets filtered from retrieval right away.
1800        db.set_memory_expires_at(expiring, Some(&past)).unwrap();
1801
1802        let ctx = db.get_memories_for_context(7, 50).unwrap();
1803        let ids: Vec<i64> = ctx.iter().map(|m| m.id).collect();
1804        assert!(ids.contains(&durable), "durable memory should be visible");
1805        assert!(
1806            !ids.contains(&expiring),
1807            "expired memory must not appear in context retrieval"
1808        );
1809
1810        // Search also filters.
1811        let hits = db.search_memories(7, "fact", 50).unwrap();
1812        assert!(hits.iter().all(|m| m.id != expiring));
1813
1814        // Prune deletes the row.
1815        let now = chrono::Utc::now().to_rfc3339();
1816        let pruned = db.prune_expired_memories(&now).unwrap();
1817        assert_eq!(pruned, 1);
1818        assert!(db.get_memory_by_id(expiring).unwrap().is_none());
1819        assert!(db.get_memory_by_id(durable).unwrap().is_some());
1820        cleanup(&dir);
1821    }
1822
1823    #[cfg(feature = "sqlite-vec")]
1824    #[test]
1825    fn test_sqlite_vec_prepare_and_knn() {
1826        let (db, dir) = test_db();
1827        db.prepare_vector_index(3).unwrap();
1828        let id1 = db
1829            .insert_memory(Some(100), "vector one", "KNOWLEDGE")
1830            .unwrap();
1831        let id2 = db
1832            .insert_memory(Some(100), "vector two", "KNOWLEDGE")
1833            .unwrap();
1834        db.upsert_memory_vec(id1, &[1.0, 0.0, 0.0]).unwrap();
1835        db.upsert_memory_vec(id2, &[0.0, 1.0, 0.0]).unwrap();
1836
1837        let nearest = db.knn_memories(100, &[0.95, 0.05, 0.0], 1).unwrap();
1838        assert_eq!(nearest.len(), 1);
1839        assert_eq!(nearest[0].0, id1);
1840        assert!(nearest[0].1 >= 0.0);
1841
1842        cleanup(&dir);
1843    }
1844}