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 pub expires_at: Option<String>,
21}
22
23#[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 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 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 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 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 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 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 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 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 #[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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let capped = db
1514 .kg_neighborhood(chat, &["Alice".to_string()], 3, 1)
1515 .unwrap();
1516 assert_eq!(capped.len(), 1);
1517
1518 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 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); 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()); 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 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 db.set_memory_expires_at(durable, Some(&future)).unwrap();
1799 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 let hits = db.search_memories(7, "fact", 50).unwrap();
1812 assert!(hits.iter().all(|m| m.id != expiring));
1813
1814 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}