Skip to main content

conch_core/
store.rs

1use chrono::{DateTime, Duration, Utc};
2use rusqlite::{params, Connection, OptionalExtension, Result as SqlResult};
3use sha2::{Digest, Sha256};
4use std::path::Path;
5
6use crate::memory::{AuditEntry, CorruptedMemory, Episode, Fact, MemoryKind, MemoryRecord, MemoryStats, VerifyResult};
7
8pub struct MemoryStore {
9    conn: Connection,
10}
11
12impl MemoryStore {
13    pub fn conn(&self) -> &Connection {
14        &self.conn
15    }
16
17    pub fn open<P: AsRef<Path>>(path: P) -> SqlResult<Self> {
18        let conn = Connection::open(path)?;
19        let store = Self { conn };
20        store.init_schema()?;
21        Ok(store)
22    }
23
24    pub fn open_in_memory() -> SqlResult<Self> {
25        let conn = Connection::open_in_memory()?;
26        let store = Self { conn };
27        store.init_schema()?;
28        Ok(store)
29    }
30
31    fn init_schema(&self) -> SqlResult<()> {
32        self.conn.execute_batch(
33            "CREATE TABLE IF NOT EXISTS memories (
34                id              INTEGER PRIMARY KEY AUTOINCREMENT,
35                kind            TEXT NOT NULL CHECK(kind IN ('fact', 'episode')),
36                subject         TEXT,
37                relation        TEXT,
38                object          TEXT,
39                episode_text    TEXT,
40                strength        REAL NOT NULL DEFAULT 1.0,
41                embedding       BLOB,
42                created_at      TEXT NOT NULL,
43                last_accessed_at TEXT NOT NULL,
44                access_count    INTEGER NOT NULL DEFAULT 0
45            );
46            CREATE INDEX IF NOT EXISTS idx_memories_subject ON memories(subject);
47            CREATE INDEX IF NOT EXISTS idx_memories_kind ON memories(kind);",
48        )?;
49        // Migration: add tags column if missing
50        let has_tags: bool = self.conn
51            .prepare("SELECT tags FROM memories LIMIT 0")
52            .is_ok();
53        if !has_tags {
54            self.conn.execute_batch(
55                "ALTER TABLE memories ADD COLUMN tags TEXT NOT NULL DEFAULT '';"
56            )?;
57        }
58        // Migration: add source tracking columns if missing
59        let has_source: bool = self.conn
60            .prepare("SELECT source FROM memories LIMIT 0")
61            .is_ok();
62        if !has_source {
63            self.conn.execute_batch(
64                "ALTER TABLE memories ADD COLUMN source TEXT;
65                 ALTER TABLE memories ADD COLUMN session_id TEXT;
66                 ALTER TABLE memories ADD COLUMN channel TEXT;"
67            )?;
68        }
69        // Migration: add importance column if missing
70        let has_importance: bool = self.conn
71            .prepare("SELECT importance FROM memories LIMIT 0")
72            .is_ok();
73        if !has_importance {
74            self.conn.execute_batch(
75                "ALTER TABLE memories ADD COLUMN importance REAL NOT NULL DEFAULT 0.5;"
76            )?;
77        }
78        // Migration: add namespace column if missing
79        let has_namespace: bool = self.conn
80            .prepare("SELECT namespace FROM memories LIMIT 0")
81            .is_ok();
82        if !has_namespace {
83            self.conn.execute_batch(
84                "ALTER TABLE memories ADD COLUMN namespace TEXT NOT NULL DEFAULT 'default';"
85            )?;
86        }
87        self.conn.execute_batch(
88            "CREATE INDEX IF NOT EXISTS idx_memories_namespace ON memories(namespace);"
89        )?;
90        // Migration: add checksum column if missing
91        let has_checksum: bool = self.conn
92            .prepare("SELECT checksum FROM memories LIMIT 0")
93            .is_ok();
94        if !has_checksum {
95            self.conn.execute_batch(
96                "ALTER TABLE memories ADD COLUMN checksum TEXT;"
97            )?;
98        }
99        // Audit log table
100        self.conn.execute_batch(
101            "CREATE TABLE IF NOT EXISTS audit_log (
102                id          INTEGER PRIMARY KEY AUTOINCREMENT,
103                timestamp   TEXT NOT NULL,
104                action      TEXT NOT NULL,
105                memory_id   INTEGER,
106                actor       TEXT NOT NULL DEFAULT 'system',
107                details_json TEXT
108            );
109            CREATE INDEX IF NOT EXISTS idx_audit_log_memory_id ON audit_log(memory_id);
110            CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor);
111            CREATE INDEX IF NOT EXISTS idx_audit_log_timestamp ON audit_log(timestamp);"
112        )?;
113        Ok(())
114    }
115
116    // ── Remember ─────────────────────────────────────────────
117
118    pub fn remember_fact(&self, subject: &str, relation: &str, object: &str, embedding: Option<&[f32]>) -> SqlResult<i64> {
119        self.remember_fact_with_tags(subject, relation, object, embedding, &[])
120    }
121
122    pub fn remember_fact_with_tags(&self, subject: &str, relation: &str, object: &str, embedding: Option<&[f32]>, tags: &[String]) -> SqlResult<i64> {
123        self.remember_fact_full(subject, relation, object, embedding, tags, None, None, None)
124    }
125
126    #[allow(clippy::too_many_arguments)]
127    pub fn remember_fact_full(
128        &self, subject: &str, relation: &str, object: &str, embedding: Option<&[f32]>,
129        tags: &[String], source: Option<&str>, session_id: Option<&str>, channel: Option<&str>,
130    ) -> SqlResult<i64> {
131        self.remember_fact_ns(subject, relation, object, embedding, tags, source, session_id, channel, "default")
132    }
133
134    #[allow(clippy::too_many_arguments)]
135    pub fn remember_fact_ns(
136        &self, subject: &str, relation: &str, object: &str, embedding: Option<&[f32]>,
137        tags: &[String], source: Option<&str>, session_id: Option<&str>, channel: Option<&str>,
138        namespace: &str,
139    ) -> SqlResult<i64> {
140        let now = Utc::now().to_rfc3339();
141        let emb_blob = embedding.map(embedding_to_blob);
142        let tags_str = tags.join(",");
143        let content = format!("{subject} {relation} {object}");
144        let checksum = compute_checksum(&content);
145        self.conn.execute(
146            "INSERT INTO memories (kind, subject, relation, object, embedding, created_at, last_accessed_at, tags, source, session_id, channel, namespace, checksum)
147             VALUES ('fact', ?1, ?2, ?3, ?4, ?5, ?5, ?6, ?7, ?8, ?9, ?10, ?11)",
148            params![subject, relation, object, emb_blob, now, tags_str, source, session_id, channel, namespace, checksum],
149        )?;
150        let id = self.conn.last_insert_rowid();
151        self.log_audit("remember", Some(id), "system", Some(&format!("{{\"kind\":\"fact\",\"subject\":{},\"relation\":{},\"object\":{},\"namespace\":{}}}", serde_json::json!(subject), serde_json::json!(relation), serde_json::json!(object), serde_json::json!(namespace))))?;
152        Ok(id)
153    }
154
155    // ── Upsert ────────────────────────────────────────────────
156
157    /// Upsert a fact: if a fact with the same subject+relation exists in the same namespace,
158    /// update its object (and embedding/tags). Otherwise insert a new fact.
159    /// Returns `(id, was_updated)`.
160    #[allow(clippy::too_many_arguments)]
161    pub fn upsert_fact(
162        &self, subject: &str, relation: &str, object: &str, embedding: Option<&[f32]>,
163        tags: &[String], source: Option<&str>, session_id: Option<&str>, channel: Option<&str>,
164    ) -> SqlResult<(i64, bool)> {
165        self.upsert_fact_ns(subject, relation, object, embedding, tags, source, session_id, channel, "default")
166    }
167
168    #[allow(clippy::too_many_arguments)]
169    pub fn upsert_fact_ns(
170        &self, subject: &str, relation: &str, object: &str, embedding: Option<&[f32]>,
171        tags: &[String], source: Option<&str>, session_id: Option<&str>, channel: Option<&str>,
172        namespace: &str,
173    ) -> SqlResult<(i64, bool)> {
174        // Check for existing fact with same subject+relation in the same namespace
175        let existing_id: Option<i64> = self.conn.query_row(
176            "SELECT id FROM memories WHERE kind = 'fact' AND subject = ?1 AND relation = ?2 AND namespace = ?3 LIMIT 1",
177            params![subject, relation, namespace],
178            |row| row.get(0),
179        ).optional()?;
180
181        if let Some(id) = existing_id {
182            // Update existing fact
183            let now = Utc::now().to_rfc3339();
184            let emb_blob = embedding.map(embedding_to_blob);
185            let tags_str = tags.join(",");
186            let content = format!("{subject} {relation} {object}");
187            let checksum = compute_checksum(&content);
188            self.conn.execute(
189                "UPDATE memories SET object = ?1, embedding = COALESCE(?2, embedding), \
190                 last_accessed_at = ?3, access_count = access_count + 1, \
191                 tags = ?4, source = COALESCE(?5, source), \
192                 session_id = COALESCE(?6, session_id), channel = COALESCE(?7, channel), \
193                 checksum = ?8 \
194                 WHERE id = ?9",
195                params![object, emb_blob, now, tags_str, source, session_id, channel, checksum, id],
196            )?;
197            self.log_audit("update", Some(id), "system", Some(&format!("{{\"kind\":\"fact\",\"subject\":{},\"relation\":{},\"object\":{},\"namespace\":{}}}", serde_json::json!(subject), serde_json::json!(relation), serde_json::json!(object), serde_json::json!(namespace))))?;
198            Ok((id, true))
199        } else {
200            let id = self.remember_fact_ns(subject, relation, object, embedding, tags, source, session_id, channel, namespace)?;
201            Ok((id, false))
202        }
203    }
204
205    pub fn remember_episode(&self, text: &str, embedding: Option<&[f32]>) -> SqlResult<i64> {
206        self.remember_episode_with_tags(text, embedding, &[])
207    }
208
209    pub fn remember_episode_with_tags(&self, text: &str, embedding: Option<&[f32]>, tags: &[String]) -> SqlResult<i64> {
210        self.remember_episode_full(text, embedding, tags, None, None, None)
211    }
212
213    #[allow(clippy::too_many_arguments)]
214    pub fn remember_episode_full(
215        &self, text: &str, embedding: Option<&[f32]>,
216        tags: &[String], source: Option<&str>, session_id: Option<&str>, channel: Option<&str>,
217    ) -> SqlResult<i64> {
218        self.remember_episode_ns(text, embedding, tags, source, session_id, channel, "default")
219    }
220
221    #[allow(clippy::too_many_arguments)]
222    pub fn remember_episode_ns(
223        &self, text: &str, embedding: Option<&[f32]>,
224        tags: &[String], source: Option<&str>, session_id: Option<&str>, channel: Option<&str>,
225        namespace: &str,
226    ) -> SqlResult<i64> {
227        let now = Utc::now().to_rfc3339();
228        let emb_blob = embedding.map(embedding_to_blob);
229        let tags_str = tags.join(",");
230        let checksum = compute_checksum(text);
231        self.conn.execute(
232            "INSERT INTO memories (kind, episode_text, embedding, created_at, last_accessed_at, tags, source, session_id, channel, namespace, checksum)
233             VALUES ('episode', ?1, ?2, ?3, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
234            params![text, emb_blob, now, tags_str, source, session_id, channel, namespace, checksum],
235        )?;
236        let id = self.conn.last_insert_rowid();
237        self.log_audit("remember", Some(id), "system", Some(&format!("{{\"kind\":\"episode\",\"namespace\":{}}}", serde_json::json!(namespace))))?;
238        Ok(id)
239    }
240
241    // ── Recall ───────────────────────────────────────────────
242
243    pub fn all_memories_with_text(&self) -> SqlResult<Vec<(MemoryRecord, String)>> {
244        self.all_memories_with_text_ns("default")
245    }
246
247    pub fn all_memories_with_text_ns(&self, namespace: &str) -> SqlResult<Vec<(MemoryRecord, String)>> {
248        let mut stmt = self.conn.prepare(
249            "SELECT id, kind, subject, relation, object, episode_text,
250                    strength, embedding, created_at, last_accessed_at, access_count,
251                    tags, source, session_id, channel, importance, namespace, checksum
252             FROM memories WHERE strength > 0.01 AND namespace = ?1",
253        )?;
254        let rows = stmt.query_map(params![namespace], |row| {
255            let mem = row_to_memory(row)?;
256            let text = mem.text_for_embedding();
257            Ok((mem, text))
258        })?;
259        rows.collect()
260    }
261
262    pub fn all_memories_with_text_filtered_by_tag(&self, tag: &str) -> SqlResult<Vec<(MemoryRecord, String)>> {
263        self.all_memories_with_text_filtered_by_tag_ns(tag, "default")
264    }
265
266    pub fn all_memories_with_text_filtered_by_tag_ns(&self, tag: &str, namespace: &str) -> SqlResult<Vec<(MemoryRecord, String)>> {
267        let pattern = format!("%{}%", tag);
268        let mut stmt = self.conn.prepare(
269            "SELECT id, kind, subject, relation, object, episode_text,
270                    strength, embedding, created_at, last_accessed_at, access_count,
271                    tags, source, session_id, channel, importance, namespace, checksum
272             FROM memories WHERE strength > 0.01 AND tags LIKE ?1 AND namespace = ?2",
273        )?;
274        let rows = stmt.query_map(params![pattern, namespace], |row| {
275            let mem = row_to_memory(row)?;
276            let text = mem.text_for_embedding();
277            Ok((mem, text))
278        })?;
279        // Post-filter for exact tag match (LIKE is approximate)
280        let all: Vec<(MemoryRecord, String)> = rows.collect::<SqlResult<Vec<_>>>()?;
281        Ok(all.into_iter().filter(|(m, _)| m.tags.iter().any(|t| t == tag)).collect())
282    }
283
284    pub fn touch_memory_with_strength(&self, id: i64, strength: f64, now: DateTime<Utc>) -> SqlResult<()> {
285        self.conn.execute(
286            "UPDATE memories SET last_accessed_at = ?1, access_count = access_count + 1, strength = ?2 WHERE id = ?3",
287            params![now.to_rfc3339(), strength.clamp(0.0, 1.0), id],
288        )?;
289        Ok(())
290    }
291
292    pub fn get_memory(&self, id: i64) -> SqlResult<Option<MemoryRecord>> {
293        let mut stmt = self.conn.prepare(
294            "SELECT id, kind, subject, relation, object, episode_text,
295                    strength, embedding, created_at, last_accessed_at, access_count,
296                    tags, source, session_id, channel, importance, namespace, checksum
297             FROM memories WHERE id = ?1",
298        )?;
299        let mut rows = stmt.query_map(params![id], row_to_memory)?;
300        match rows.next() {
301            Some(r) => Ok(Some(r?)),
302            None => Ok(None),
303        }
304    }
305
306    // ── Decay ────────────────────────────────────────────────
307
308    pub fn decay_all(&self, decay_factor: f64, half_life_hours: f64) -> SqlResult<usize> {
309        self.decay_all_ns(decay_factor, half_life_hours, "default")
310    }
311
312    pub fn decay_all_ns(&self, decay_factor: f64, half_life_hours: f64, namespace: &str) -> SqlResult<usize> {
313        let now = Utc::now();
314        let mut stmt = self.conn.prepare(
315            "SELECT id, last_accessed_at, strength, importance FROM memories WHERE strength > 0.01 AND namespace = ?1",
316        )?;
317        let rows: Vec<(i64, String, f64, f64)> = stmt
318            .query_map(params![namespace], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get::<_, Option<f64>>(3)?.unwrap_or(0.5))))?
319            .collect::<SqlResult<Vec<_>>>()?;
320
321        let mut count = 0;
322        for (id, last_accessed, strength, importance) in &rows {
323            let last = DateTime::parse_from_rfc3339(last_accessed)
324                .unwrap_or_else(|_| now.into())
325                .with_timezone(&Utc);
326            let hours = (now - last).num_seconds() as f64 / 3600.0;
327            // Importance slows decay: effective_half_life = base_half_life * (1 + importance)
328            // importance=0 → half_life*1, importance=1 → half_life*2
329            let effective_half_life = half_life_hours * (1.0 + importance);
330            let new_strength = (strength * decay_factor.powf(hours / effective_half_life)).max(0.0);
331            if (new_strength - strength).abs() > 1e-6 {
332                self.conn.execute("UPDATE memories SET strength = ?1 WHERE id = ?2", params![new_strength, id])?;
333                count += 1;
334            }
335        }
336        if count > 0 {
337            self.log_audit("decay", None, "system", Some(&format!("{{\"decayed\":{count},\"namespace\":{}}}", serde_json::json!(namespace))))?;
338        }
339        Ok(count)
340    }
341
342    // ── Forget ───────────────────────────────────────────────
343
344    pub fn forget_by_subject(&self, subject: &str) -> SqlResult<usize> {
345        self.forget_by_subject_ns(subject, "default")
346    }
347
348    pub fn forget_by_subject_ns(&self, subject: &str, namespace: &str) -> SqlResult<usize> {
349        let count = self.conn.execute("DELETE FROM memories WHERE subject = ?1 AND namespace = ?2", params![subject, namespace])?;
350        if count > 0 {
351            self.log_audit("forget", None, "system", Some(&format!("{{\"by\":\"subject\",\"subject\":{},\"count\":{},\"namespace\":{}}}", serde_json::json!(subject), count, serde_json::json!(namespace))))?;
352        }
353        Ok(count)
354    }
355
356    pub fn forget_by_id(&self, id: &str) -> SqlResult<usize> {
357        let changed = self.conn.execute("DELETE FROM memories WHERE id = ?1", params![id])?;
358        if changed > 0 {
359            self.log_audit("forget", Some(id.parse::<i64>().unwrap_or(0)), "system", Some(&format!("{{\"by\":\"id\",\"id\":{}}}", serde_json::json!(id))))?;
360        }
361        Ok(changed)
362    }
363
364    pub fn forget_older_than(&self, duration: Duration) -> SqlResult<usize> {
365        self.forget_older_than_ns(duration, "default")
366    }
367
368    pub fn forget_older_than_ns(&self, duration: Duration, namespace: &str) -> SqlResult<usize> {
369        let cutoff = (Utc::now() - duration).to_rfc3339();
370        let count = self.conn.execute("DELETE FROM memories WHERE created_at < ?1 AND namespace = ?2", params![cutoff, namespace])?;
371        if count > 0 {
372            self.log_audit("forget", None, "system", Some(&format!("{{\"by\":\"older_than\",\"count\":{},\"namespace\":{}}}", count, serde_json::json!(namespace))))?;
373        }
374        Ok(count)
375    }
376
377    // ── Embed ────────────────────────────────────────────────
378
379    pub fn memories_missing_embeddings(&self) -> SqlResult<Vec<MemoryRecord>> {
380        let mut stmt = self.conn.prepare(
381            "SELECT id, kind, subject, relation, object, episode_text,
382                    strength, embedding, created_at, last_accessed_at, access_count,
383                    tags, source, session_id, channel, importance, namespace, checksum
384             FROM memories WHERE embedding IS NULL",
385        )?;
386        let rows = stmt.query_map([], row_to_memory)?;
387        rows.collect()
388    }
389
390    pub fn update_embedding(&self, id: i64, embedding: &[f32]) -> SqlResult<()> {
391        self.conn.execute("UPDATE memories SET embedding = ?1 WHERE id = ?2", params![embedding_to_blob(embedding), id])?;
392        Ok(())
393    }
394
395    // ── Export / Import ──────────────────────────────────────
396
397    pub fn all_memories(&self) -> SqlResult<Vec<MemoryRecord>> {
398        let mut stmt = self.conn.prepare(
399            "SELECT id, kind, subject, relation, object, episode_text,
400                    strength, embedding, created_at, last_accessed_at, access_count,
401                    tags, source, session_id, channel, importance, namespace, checksum
402             FROM memories",
403        )?;
404        let rows = stmt.query_map([], row_to_memory)?;
405        rows.collect()
406    }
407
408    pub fn all_memories_ns(&self, namespace: &str) -> SqlResult<Vec<MemoryRecord>> {
409        let mut stmt = self.conn.prepare(
410            "SELECT id, kind, subject, relation, object, episode_text,
411                    strength, embedding, created_at, last_accessed_at, access_count,
412                    tags, source, session_id, channel, importance, namespace, checksum
413             FROM memories WHERE namespace = ?1",
414        )?;
415        let rows = stmt.query_map(params![namespace], row_to_memory)?;
416        rows.collect()
417    }
418
419    #[allow(clippy::too_many_arguments)]
420    pub fn import_fact(
421        &self, subject: &str, relation: &str, object: &str, strength: f64,
422        embedding: Option<&[f32]>, created_at: &str, last_accessed_at: &str, access_count: i64,
423        tags: &[String], source: Option<&str>, session_id: Option<&str>, channel: Option<&str>,
424    ) -> SqlResult<i64> {
425        self.import_fact_ns(subject, relation, object, strength, embedding, created_at, last_accessed_at, access_count, tags, source, session_id, channel, "default")
426    }
427
428    #[allow(clippy::too_many_arguments)]
429    pub fn import_fact_ns(
430        &self, subject: &str, relation: &str, object: &str, strength: f64,
431        embedding: Option<&[f32]>, created_at: &str, last_accessed_at: &str, access_count: i64,
432        tags: &[String], source: Option<&str>, session_id: Option<&str>, channel: Option<&str>,
433        namespace: &str,
434    ) -> SqlResult<i64> {
435        let emb_blob = embedding.map(embedding_to_blob);
436        let tags_str = tags.join(",");
437        let content = format!("{subject} {relation} {object}");
438        let checksum = compute_checksum(&content);
439        self.conn.execute(
440            "INSERT INTO memories (kind, subject, relation, object, strength, embedding, created_at, last_accessed_at, access_count, tags, source, session_id, channel, namespace, checksum)
441             VALUES ('fact', ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)",
442            params![subject, relation, object, strength, emb_blob, created_at, last_accessed_at, access_count, tags_str, source, session_id, channel, namespace, checksum],
443        )?;
444        Ok(self.conn.last_insert_rowid())
445    }
446
447    #[allow(clippy::too_many_arguments)]
448    pub fn import_episode(
449        &self, text: &str, strength: f64, embedding: Option<&[f32]>,
450        created_at: &str, last_accessed_at: &str, access_count: i64,
451        tags: &[String], source: Option<&str>, session_id: Option<&str>, channel: Option<&str>,
452    ) -> SqlResult<i64> {
453        self.import_episode_ns(text, strength, embedding, created_at, last_accessed_at, access_count, tags, source, session_id, channel, "default")
454    }
455
456    #[allow(clippy::too_many_arguments)]
457    pub fn import_episode_ns(
458        &self, text: &str, strength: f64, embedding: Option<&[f32]>,
459        created_at: &str, last_accessed_at: &str, access_count: i64,
460        tags: &[String], source: Option<&str>, session_id: Option<&str>, channel: Option<&str>,
461        namespace: &str,
462    ) -> SqlResult<i64> {
463        let emb_blob = embedding.map(embedding_to_blob);
464        let tags_str = tags.join(",");
465        let checksum = compute_checksum(text);
466        self.conn.execute(
467            "INSERT INTO memories (kind, episode_text, strength, embedding, created_at, last_accessed_at, access_count, tags, source, session_id, channel, namespace, checksum)
468             VALUES ('episode', ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
469            params![text, strength, emb_blob, created_at, last_accessed_at, access_count, tags_str, source, session_id, channel, namespace, checksum],
470        )?;
471        Ok(self.conn.last_insert_rowid())
472    }
473
474    /// Update tags on an existing memory.
475    pub fn update_tags(&self, id: i64, tags: &[String]) -> SqlResult<()> {
476        let tags_str = tags.join(",");
477        self.conn.execute(
478            "UPDATE memories SET tags = ?1 WHERE id = ?2",
479            params![tags_str, id],
480        )?;
481        Ok(())
482    }
483
484    /// Update the importance score of a memory.
485    pub fn update_importance(&self, id: i64, importance: f64) -> SqlResult<()> {
486        self.conn.execute(
487            "UPDATE memories SET importance = ?1 WHERE id = ?2",
488            params![importance.clamp(0.0, 1.0), id],
489        )?;
490        Ok(())
491    }
492
493    /// Set strength to zero (archive) for a memory.
494    pub fn archive_memory(&self, id: i64) -> SqlResult<()> {
495        self.conn.execute(
496            "UPDATE memories SET strength = 0.0 WHERE id = ?1",
497            params![id],
498        )?;
499        Ok(())
500    }
501
502    /// Delete a memory by numeric ID.
503    pub fn delete_memory(&self, id: i64) -> SqlResult<()> {
504        self.conn.execute(
505            "DELETE FROM memories WHERE id = ?1",
506            params![id],
507        )?;
508        Ok(())
509    }
510
511    // ── Dedup ─────────────────────────────────────────────────
512
513    /// Fetch all memory IDs with their embeddings for dedup similarity checks.
514    pub fn all_embeddings(&self) -> SqlResult<Vec<(i64, Vec<f32>)>> {
515        self.all_embeddings_ns("default")
516    }
517
518    pub fn all_embeddings_ns(&self, namespace: &str) -> SqlResult<Vec<(i64, Vec<f32>)>> {
519        let mut stmt = self.conn.prepare(
520            "SELECT id, embedding FROM memories WHERE embedding IS NOT NULL AND namespace = ?1",
521        )?;
522        let rows = stmt.query_map(params![namespace], |row| {
523            let id: i64 = row.get(0)?;
524            let blob: Vec<u8> = row.get(1)?;
525            Ok((id, blob_to_embedding(&blob)))
526        })?;
527        rows.collect()
528    }
529
530    /// Reinforce an existing memory's strength (clamped to 1.0) and bump access count.
531    pub fn reinforce_memory(&self, id: i64, boost: f64) -> SqlResult<()> {
532        let now = Utc::now().to_rfc3339();
533        self.conn.execute(
534            "UPDATE memories SET strength = MIN(strength + ?1, 1.0), \
535             last_accessed_at = ?2, access_count = access_count + 1 WHERE id = ?3",
536            params![boost, now, id],
537        )?;
538        self.log_audit("reinforce", Some(id), "system", Some(&format!("{{\"boost\":{boost}}}")))
539    }
540
541    // ── Graph traversal ────────────────────────────────────────
542
543    /// Find all facts where the given entity appears as subject OR object.
544    pub fn facts_involving(&self, entity: &str) -> SqlResult<Vec<MemoryRecord>> {
545        let mut stmt = self.conn.prepare(
546            "SELECT id, kind, subject, relation, object, episode_text,
547                    strength, embedding, created_at, last_accessed_at, access_count,
548                    tags, source, session_id, channel, importance, namespace, checksum
549             FROM memories WHERE kind = 'fact' AND (subject = ?1 OR object = ?1)",
550        )?;
551        let rows = stmt.query_map(params![entity], row_to_memory)?;
552        rows.collect()
553    }
554
555    // ── Stats ────────────────────────────────────────────────
556
557    pub fn stats(&self) -> SqlResult<MemoryStats> {
558        self.stats_ns("default")
559    }
560
561    pub fn stats_ns(&self, namespace: &str) -> SqlResult<MemoryStats> {
562        let total_memories: i64 = self.conn.query_row("SELECT COUNT(*) FROM memories WHERE namespace = ?1", params![namespace], |r| r.get(0))?;
563        let total_facts: i64 = self.conn.query_row("SELECT COUNT(*) FROM memories WHERE kind = 'fact' AND namespace = ?1", params![namespace], |r| r.get(0))?;
564        let total_episodes: i64 = self.conn.query_row("SELECT COUNT(*) FROM memories WHERE kind = 'episode' AND namespace = ?1", params![namespace], |r| r.get(0))?;
565        let avg_strength: f64 = self.conn.query_row("SELECT COALESCE(AVG(strength), 0.0) FROM memories WHERE namespace = ?1", params![namespace], |r| r.get(0))?;
566        Ok(MemoryStats { total_memories, total_facts, total_episodes, avg_strength })
567    }
568    // ── Audit Log ─────────────────────────────────────────────
569
570    pub fn log_audit(&self, action: &str, memory_id: Option<i64>, actor: &str, details_json: Option<&str>) -> SqlResult<()> {
571        let now = Utc::now().to_rfc3339();
572        self.conn.execute(
573            "INSERT INTO audit_log (timestamp, action, memory_id, actor, details_json)
574             VALUES (?1, ?2, ?3, ?4, ?5)",
575            params![now, action, memory_id, actor, details_json],
576        )?;
577        Ok(())
578    }
579
580    pub fn get_audit_log(&self, limit: usize, memory_id: Option<i64>, actor: Option<&str>) -> SqlResult<Vec<AuditEntry>> {
581        let (sql, param_values) = build_audit_query(limit, memory_id, actor);
582        let mut stmt = self.conn.prepare(&sql)?;
583        let rows = stmt.query_map(rusqlite::params_from_iter(param_values.iter()), |row| {
584            Ok(AuditEntry {
585                id: row.get(0)?,
586                timestamp: parse_datetime(&row.get::<_, String>(1)?),
587                action: row.get(2)?,
588                memory_id: row.get(3)?,
589                actor: row.get(4)?,
590                details_json: row.get(5)?,
591            })
592        })?;
593        rows.collect()
594    }
595
596    // ── Verify ──────────────────────────────────────────────
597
598    pub fn verify_integrity(&self) -> SqlResult<VerifyResult> {
599        self.verify_integrity_ns("default")
600    }
601
602    pub fn verify_integrity_ns(&self, namespace: &str) -> SqlResult<VerifyResult> {
603        let mut stmt = self.conn.prepare(
604            "SELECT id, kind, subject, relation, object, episode_text, checksum
605             FROM memories WHERE namespace = ?1",
606        )?;
607        let rows: Vec<(i64, String, Option<String>, Option<String>, Option<String>, Option<String>, Option<String>)> = stmt
608            .query_map(params![namespace], |row| {
609                Ok((
610                    row.get(0)?, row.get(1)?, row.get(2)?,
611                    row.get(3)?, row.get(4)?, row.get(5)?, row.get(6)?,
612                ))
613            })?
614            .collect::<SqlResult<Vec<_>>>()?;
615
616        let mut total_checked = 0;
617        let mut valid = 0;
618        let mut corrupted = Vec::new();
619        let mut missing_checksum = 0;
620
621        for (id, kind, subject, relation, object, episode_text, stored_checksum) in rows {
622            total_checked += 1;
623            let content = match kind.as_str() {
624                "fact" => format!("{} {} {}",
625                    subject.as_deref().unwrap_or(""),
626                    relation.as_deref().unwrap_or(""),
627                    object.as_deref().unwrap_or("")),
628                _ => episode_text.unwrap_or_default(),
629            };
630            let actual_checksum = compute_checksum(&content);
631            match stored_checksum {
632                Some(expected) => {
633                    if expected == actual_checksum {
634                        valid += 1;
635                    } else {
636                        corrupted.push(CorruptedMemory {
637                            id,
638                            expected,
639                            actual: actual_checksum,
640                        });
641                    }
642                }
643                None => {
644                    missing_checksum += 1;
645                }
646            }
647        }
648
649        Ok(VerifyResult { total_checked, valid, corrupted, missing_checksum })
650    }
651}
652
653// ── Helpers ──────────────────────────────────────────────────
654
655fn compute_checksum(content: &str) -> String {
656    let mut hasher = Sha256::new();
657    hasher.update(content.as_bytes());
658    format!("{:x}", hasher.finalize())
659}
660
661/// Public helper to compute checksum for content (used in lib.rs)
662pub fn content_checksum(content: &str) -> String {
663    compute_checksum(content)
664}
665
666fn build_audit_query(limit: usize, memory_id: Option<i64>, actor: Option<&str>) -> (String, Vec<String>) {
667    let mut conditions = Vec::new();
668    let mut param_values: Vec<String> = Vec::new();
669
670    if let Some(mid) = memory_id {
671        param_values.push(mid.to_string());
672        conditions.push(format!("memory_id = ?{}", param_values.len()));
673    }
674    if let Some(a) = actor {
675        param_values.push(a.to_string());
676        conditions.push(format!("actor = ?{}", param_values.len()));
677    }
678
679    let where_clause = if conditions.is_empty() {
680        String::new()
681    } else {
682        format!(" WHERE {}", conditions.join(" AND "))
683    };
684
685    param_values.push(limit.to_string());
686    let sql = format!(
687        "SELECT id, timestamp, action, memory_id, actor, details_json FROM audit_log{} ORDER BY id DESC LIMIT ?{}",
688        where_clause, param_values.len()
689    );
690    (sql, param_values)
691}
692
693fn embedding_to_blob(emb: &[f32]) -> Vec<u8> {
694    emb.iter().flat_map(|f| f.to_le_bytes()).collect()
695}
696
697fn blob_to_embedding(blob: &[u8]) -> Vec<f32> {
698    blob.chunks_exact(4)
699        .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
700        .collect()
701}
702
703fn parse_datetime(s: &str) -> DateTime<Utc> {
704    DateTime::parse_from_rfc3339(s)
705        .map(|dt| dt.with_timezone(&Utc))
706        .unwrap_or_else(|_| Utc::now())
707}
708
709#[cfg(test)]
710mod tests {
711    use super::*;
712
713    #[test]
714    fn remember_fact_with_tags_stores_and_retrieves() {
715        let store = MemoryStore::open_in_memory().unwrap();
716        let tags = vec!["preference".to_string(), "technical".to_string()];
717        let id = store.remember_fact_with_tags("Rust", "is", "fast", None, &tags).unwrap();
718        let mem = store.get_memory(id).unwrap().unwrap();
719        assert_eq!(mem.tags, vec!["preference", "technical"]);
720    }
721
722    #[test]
723    fn remember_episode_with_tags_stores_and_retrieves() {
724        let store = MemoryStore::open_in_memory().unwrap();
725        let tags = vec!["decision".to_string()];
726        let id = store.remember_episode_with_tags("chose Rust over Go", None, &tags).unwrap();
727        let mem = store.get_memory(id).unwrap().unwrap();
728        assert_eq!(mem.tags, vec!["decision"]);
729    }
730
731    #[test]
732    fn remember_without_tags_returns_empty_vec() {
733        let store = MemoryStore::open_in_memory().unwrap();
734        let id = store.remember_fact("Jared", "likes", "pizza", None).unwrap();
735        let mem = store.get_memory(id).unwrap().unwrap();
736        assert!(mem.tags.is_empty());
737    }
738
739    #[test]
740    fn update_tags_modifies_existing_memory() {
741        let store = MemoryStore::open_in_memory().unwrap();
742        let id = store.remember_fact("Jared", "uses", "Linux", None).unwrap();
743        let mem = store.get_memory(id).unwrap().unwrap();
744        assert!(mem.tags.is_empty());
745
746        store.update_tags(id, &["technical".to_string(), "preference".to_string()]).unwrap();
747        let mem = store.get_memory(id).unwrap().unwrap();
748        assert_eq!(mem.tags, vec!["technical", "preference"]);
749    }
750
751    #[test]
752    fn tags_survive_export_import_roundtrip() {
753        let store = MemoryStore::open_in_memory().unwrap();
754        let tags = vec!["project".to_string(), "meta".to_string()];
755        store.remember_fact_with_tags("Conch", "is_a", "memory system", None, &tags).unwrap();
756
757        let all = store.all_memories().unwrap();
758        assert_eq!(all.len(), 1);
759        assert_eq!(all[0].tags, vec!["project", "meta"]);
760
761        // Import into a new store
762        let store2 = MemoryStore::open_in_memory().unwrap();
763        let mem = &all[0];
764        let created = mem.created_at.to_rfc3339();
765        let accessed = mem.last_accessed_at.to_rfc3339();
766        if let MemoryKind::Fact(f) = &mem.kind {
767            store2.import_fact(&f.subject, &f.relation, &f.object, mem.strength, None, &created, &accessed, mem.access_count, &mem.tags, None, None, None).unwrap();
768        }
769        let imported = store2.all_memories().unwrap();
770        assert_eq!(imported.len(), 1);
771        assert_eq!(imported[0].tags, vec!["project", "meta"]);
772    }
773
774    #[test]
775    fn tags_appear_in_all_memories_with_text() {
776        let store = MemoryStore::open_in_memory().unwrap();
777        let tags = vec!["person".to_string()];
778        store.remember_fact_with_tags("Jared", "is", "developer", None, &tags).unwrap();
779        let results = store.all_memories_with_text().unwrap();
780        assert_eq!(results.len(), 1);
781        assert_eq!(results[0].0.tags, vec!["person"]);
782    }
783
784    #[test]
785    fn tag_filtered_recall_returns_only_matching() {
786        let store = MemoryStore::open_in_memory().unwrap();
787        store.remember_fact_with_tags("A", "is", "tagged", None, &["technical".to_string()]).unwrap();
788        store.remember_fact("B", "is", "untagged", None).unwrap();
789        store.remember_fact_with_tags("C", "is", "also-tagged", None, &["technical".to_string(), "project".to_string()]).unwrap();
790
791        let results = store.all_memories_with_text_filtered_by_tag("technical").unwrap();
792        assert_eq!(results.len(), 2);
793        for (m, _) in &results {
794            assert!(m.tags.contains(&"technical".to_string()));
795        }
796    }
797
798    #[test]
799    fn tag_filter_exact_match_not_substring() {
800        let store = MemoryStore::open_in_memory().unwrap();
801        store.remember_fact_with_tags("A", "is", "tech", None, &["technical".to_string()]).unwrap();
802        store.remember_fact_with_tags("B", "is", "meta", None, &["meta".to_string()]).unwrap();
803
804        // Filtering by "tech" should NOT match "technical"
805        let results = store.all_memories_with_text_filtered_by_tag("tech").unwrap();
806        assert_eq!(results.len(), 0);
807
808        // Filtering by "meta" should match exactly
809        let results = store.all_memories_with_text_filtered_by_tag("meta").unwrap();
810        assert_eq!(results.len(), 1);
811    }
812
813    #[test]
814    fn tag_filter_returns_empty_when_no_match() {
815        let store = MemoryStore::open_in_memory().unwrap();
816        store.remember_fact_with_tags("A", "is", "B", None, &["technical".to_string()]).unwrap();
817
818        let results = store.all_memories_with_text_filtered_by_tag("nonexistent").unwrap();
819        assert!(results.is_empty());
820    }
821
822    // ── Graph traversal tests ─────────────────────────────────
823
824    #[test]
825    fn facts_involving_finds_subject_and_object() {
826        let store = MemoryStore::open_in_memory().unwrap();
827        store.remember_fact("Alice", "knows", "Bob", None).unwrap();
828        store.remember_fact("Bob", "works_at", "Acme", None).unwrap();
829        store.remember_fact("Charlie", "knows", "Alice", None).unwrap();
830
831        // Alice appears as subject in fact 1, and object in fact 3
832        let results = store.facts_involving("Alice").unwrap();
833        assert_eq!(results.len(), 2, "Alice should appear in 2 facts");
834
835        // Bob appears as object in fact 1, and subject in fact 2
836        let results = store.facts_involving("Bob").unwrap();
837        assert_eq!(results.len(), 2, "Bob should appear in 2 facts");
838
839        // Acme only appears as object in fact 2
840        let results = store.facts_involving("Acme").unwrap();
841        assert_eq!(results.len(), 1, "Acme should appear in 1 fact");
842    }
843
844    #[test]
845    fn facts_involving_ignores_episodes() {
846        let store = MemoryStore::open_in_memory().unwrap();
847        store.remember_fact("Alice", "knows", "Bob", None).unwrap();
848        store.remember_episode("Alice had a meeting", None).unwrap();
849
850        let results = store.facts_involving("Alice").unwrap();
851        assert_eq!(results.len(), 1, "should only return facts, not episodes");
852    }
853
854    #[test]
855    fn facts_involving_returns_empty_for_unknown() {
856        let store = MemoryStore::open_in_memory().unwrap();
857        store.remember_fact("Alice", "knows", "Bob", None).unwrap();
858
859        let results = store.facts_involving("Unknown").unwrap();
860        assert!(results.is_empty());
861    }
862
863    // ── Source tracking tests ────────────────────────────────
864
865    #[test]
866    fn remember_fact_full_stores_source_fields() {
867        let store = MemoryStore::open_in_memory().unwrap();
868        let id = store.remember_fact_full(
869            "Jared", "uses", "conch", None, &[],
870            Some("cli"), Some("session-123"), Some("#general"),
871        ).unwrap();
872        let mem = store.get_memory(id).unwrap().unwrap();
873        assert_eq!(mem.source.as_deref(), Some("cli"));
874        assert_eq!(mem.session_id.as_deref(), Some("session-123"));
875        assert_eq!(mem.channel.as_deref(), Some("#general"));
876    }
877
878    #[test]
879    fn remember_episode_full_stores_source_fields() {
880        let store = MemoryStore::open_in_memory().unwrap();
881        let id = store.remember_episode_full(
882            "had a meeting", None, &[],
883            Some("discord"), Some("sess-abc"), Some("#dev"),
884        ).unwrap();
885        let mem = store.get_memory(id).unwrap().unwrap();
886        assert_eq!(mem.source.as_deref(), Some("discord"));
887        assert_eq!(mem.session_id.as_deref(), Some("sess-abc"));
888        assert_eq!(mem.channel.as_deref(), Some("#dev"));
889    }
890
891    #[test]
892    fn remember_without_source_returns_none() {
893        let store = MemoryStore::open_in_memory().unwrap();
894        let id = store.remember_fact("X", "Y", "Z", None).unwrap();
895        let mem = store.get_memory(id).unwrap().unwrap();
896        assert!(mem.source.is_none());
897        assert!(mem.session_id.is_none());
898        assert!(mem.channel.is_none());
899    }
900
901    #[test]
902    fn source_fields_survive_export_import_roundtrip() {
903        let store = MemoryStore::open_in_memory().unwrap();
904        store.remember_fact_full(
905            "Conch", "source_test", "value", None, &["meta".to_string()],
906            Some("cron"), Some("daily-job"), None,
907        ).unwrap();
908
909        let all = store.all_memories().unwrap();
910        assert_eq!(all.len(), 1);
911        assert_eq!(all[0].source.as_deref(), Some("cron"));
912        assert_eq!(all[0].session_id.as_deref(), Some("daily-job"));
913        assert!(all[0].channel.is_none());
914
915        // Import into a new store
916        let store2 = MemoryStore::open_in_memory().unwrap();
917        let mem = &all[0];
918        let created = mem.created_at.to_rfc3339();
919        let accessed = mem.last_accessed_at.to_rfc3339();
920        if let MemoryKind::Fact(f) = &mem.kind {
921            store2.import_fact(
922                &f.subject, &f.relation, &f.object, mem.strength, None,
923                &created, &accessed, mem.access_count, &mem.tags,
924                mem.source.as_deref(), mem.session_id.as_deref(), mem.channel.as_deref(),
925            ).unwrap();
926        }
927        let imported = store2.all_memories().unwrap();
928        assert_eq!(imported.len(), 1);
929        assert_eq!(imported[0].source.as_deref(), Some("cron"));
930        assert_eq!(imported[0].session_id.as_deref(), Some("daily-job"));
931        assert!(imported[0].channel.is_none());
932    }
933
934    #[test]
935    fn source_fields_appear_in_all_memories_with_text() {
936        let store = MemoryStore::open_in_memory().unwrap();
937        store.remember_fact_full(
938            "test", "source", "recall", None, &[],
939            Some("mcp"), None, Some("#test-channel"),
940        ).unwrap();
941        let results = store.all_memories_with_text().unwrap();
942        assert_eq!(results.len(), 1);
943        assert_eq!(results[0].0.source.as_deref(), Some("mcp"));
944        assert!(results[0].0.session_id.is_none());
945        assert_eq!(results[0].0.channel.as_deref(), Some("#test-channel"));
946    }
947
948    // ── Upsert tests ────────────────────────────────────────
949
950    #[test]
951    fn upsert_fact_inserts_when_no_match() {
952        let store = MemoryStore::open_in_memory().unwrap();
953        let (id, was_updated) = store.upsert_fact("Jared", "favorite_color", "blue", None, &[], None, None, None).unwrap();
954        assert!(!was_updated);
955        let mem = store.get_memory(id).unwrap().unwrap();
956        if let MemoryKind::Fact(f) = &mem.kind {
957            assert_eq!(f.object, "blue");
958        } else { panic!("expected fact"); }
959    }
960
961    #[test]
962    fn upsert_fact_updates_existing_object() {
963        let store = MemoryStore::open_in_memory().unwrap();
964        let (id1, _) = store.upsert_fact("Jared", "favorite_color", "blue", None, &[], None, None, None).unwrap();
965        let (id2, was_updated) = store.upsert_fact("Jared", "favorite_color", "green", None, &[], None, None, None).unwrap();
966        assert!(was_updated);
967        assert_eq!(id1, id2, "should update the same row");
968
969        let mem = store.get_memory(id2).unwrap().unwrap();
970        if let MemoryKind::Fact(f) = &mem.kind {
971            assert_eq!(f.object, "green", "object should be updated to green");
972        } else { panic!("expected fact"); }
973
974        // Should only have 1 memory total
975        let stats = store.stats().unwrap();
976        assert_eq!(stats.total_memories, 1);
977    }
978
979    #[test]
980    fn upsert_fact_bumps_access_count() {
981        let store = MemoryStore::open_in_memory().unwrap();
982        store.upsert_fact("Jared", "age", "30", None, &[], None, None, None).unwrap();
983        let (id, _) = store.upsert_fact("Jared", "age", "31", None, &[], None, None, None).unwrap();
984        let mem = store.get_memory(id).unwrap().unwrap();
985        assert_eq!(mem.access_count, 1, "access count should be bumped on upsert");
986    }
987
988    #[test]
989    fn upsert_fact_different_relation_creates_new() {
990        let store = MemoryStore::open_in_memory().unwrap();
991        let (id1, _) = store.upsert_fact("Jared", "likes", "Rust", None, &[], None, None, None).unwrap();
992        let (id2, was_updated) = store.upsert_fact("Jared", "uses", "Rust", None, &[], None, None, None).unwrap();
993        assert!(!was_updated, "different relation should insert new fact");
994        assert_ne!(id1, id2);
995        assert_eq!(store.stats().unwrap().total_memories, 2);
996    }
997
998    #[test]
999    fn upsert_fact_preserves_tags() {
1000        let store = MemoryStore::open_in_memory().unwrap();
1001        store.upsert_fact("Jared", "color", "blue", None, &["preference".to_string()], None, None, None).unwrap();
1002        let (id, _) = store.upsert_fact("Jared", "color", "green", None, &["preference".to_string(), "updated".to_string()], None, None, None).unwrap();
1003        let mem = store.get_memory(id).unwrap().unwrap();
1004        assert_eq!(mem.tags, vec!["preference", "updated"]);
1005    }
1006
1007    // ════════════════════════════════════════════════════════════
1008    // Security Feature Tests
1009    // ════════════════════════════════════════════════════════════
1010
1011    // ── Audit log tests ─────────────────────────────────────
1012
1013    #[test]
1014    fn audit_log_records_remember_fact() {
1015        let store = MemoryStore::open_in_memory().unwrap();
1016        store.remember_fact("Jared", "likes", "Rust", None).unwrap();
1017
1018        let log = store.get_audit_log(10, None, None).unwrap();
1019        assert!(!log.is_empty());
1020        let remember_entries: Vec<_> = log.iter().filter(|e| e.action == "remember").collect();
1021        assert_eq!(remember_entries.len(), 1);
1022        assert!(remember_entries[0].memory_id.is_some());
1023        assert_eq!(remember_entries[0].actor, "system");
1024    }
1025
1026    #[test]
1027    fn audit_log_records_remember_episode() {
1028        let store = MemoryStore::open_in_memory().unwrap();
1029        store.remember_episode("had coffee", None).unwrap();
1030
1031        let log = store.get_audit_log(10, None, None).unwrap();
1032        let remember_entries: Vec<_> = log.iter().filter(|e| e.action == "remember").collect();
1033        assert_eq!(remember_entries.len(), 1);
1034    }
1035
1036    #[test]
1037    fn audit_log_records_forget_by_id() {
1038        let store = MemoryStore::open_in_memory().unwrap();
1039        let id = store.remember_fact("X", "Y", "Z", None).unwrap();
1040        store.forget_by_id(&id.to_string()).unwrap();
1041
1042        let log = store.get_audit_log(10, None, None).unwrap();
1043        assert!(log.iter().any(|e| e.action == "forget"));
1044    }
1045
1046    #[test]
1047    fn audit_log_records_forget_by_subject() {
1048        let store = MemoryStore::open_in_memory().unwrap();
1049        store.remember_fact("Jared", "likes", "Rust", None).unwrap();
1050        store.forget_by_subject("Jared").unwrap();
1051
1052        let log = store.get_audit_log(10, None, None).unwrap();
1053        assert!(log.iter().any(|e| e.action == "forget"));
1054    }
1055
1056    #[test]
1057    fn audit_log_records_upsert_update() {
1058        let store = MemoryStore::open_in_memory().unwrap();
1059        store.upsert_fact("Jared", "color", "blue", None, &[], None, None, None).unwrap();
1060        store.upsert_fact("Jared", "color", "green", None, &[], None, None, None).unwrap();
1061
1062        let log = store.get_audit_log(10, None, None).unwrap();
1063        assert!(log.iter().any(|e| e.action == "update"));
1064    }
1065
1066    #[test]
1067    fn audit_log_records_reinforce() {
1068        let store = MemoryStore::open_in_memory().unwrap();
1069        let id = store.remember_fact("A", "B", "C", None).unwrap();
1070        store.reinforce_memory(id, 0.1).unwrap();
1071
1072        let log = store.get_audit_log(10, None, None).unwrap();
1073        assert!(log.iter().any(|e| e.action == "reinforce"));
1074    }
1075
1076    #[test]
1077    fn audit_log_records_decay() {
1078        let store = MemoryStore::open_in_memory().unwrap();
1079        store.remember_fact("A", "B", "C", None).unwrap();
1080
1081        // Make memory old so decay actually happens
1082        let old_time = (Utc::now() - chrono::Duration::hours(48)).to_rfc3339();
1083        store.conn().execute("UPDATE memories SET last_accessed_at = ?1", params![old_time]).unwrap();
1084
1085        store.decay_all(0.5, 24.0).unwrap();
1086        let log = store.get_audit_log(10, None, None).unwrap();
1087        assert!(log.iter().any(|e| e.action == "decay"), "should log decay action");
1088    }
1089
1090    #[test]
1091    fn audit_log_filter_by_memory_id() {
1092        let store = MemoryStore::open_in_memory().unwrap();
1093        let id1 = store.remember_fact("A", "B", "C", None).unwrap();
1094        store.remember_fact("D", "E", "F", None).unwrap();
1095
1096        let log = store.get_audit_log(10, Some(id1), None).unwrap();
1097        for entry in &log {
1098            assert_eq!(entry.memory_id, Some(id1));
1099        }
1100    }
1101
1102    #[test]
1103    fn audit_log_filter_by_actor() {
1104        let store = MemoryStore::open_in_memory().unwrap();
1105        store.remember_fact("A", "B", "C", None).unwrap();
1106        store.log_audit("custom_action", None, "agent-x", None).unwrap();
1107
1108        let log = store.get_audit_log(10, None, Some("agent-x")).unwrap();
1109        assert_eq!(log.len(), 1);
1110        assert_eq!(log[0].actor, "agent-x");
1111    }
1112
1113    #[test]
1114    fn audit_log_limit_works() {
1115        let store = MemoryStore::open_in_memory().unwrap();
1116        for i in 0..10 {
1117            store.remember_fact(&format!("S{i}"), "R", "O", None).unwrap();
1118        }
1119        let log = store.get_audit_log(3, None, None).unwrap();
1120        assert_eq!(log.len(), 3);
1121    }
1122
1123    #[test]
1124    fn audit_log_has_details_json() {
1125        let store = MemoryStore::open_in_memory().unwrap();
1126        store.remember_fact("Jared", "likes", "Rust", None).unwrap();
1127
1128        let log = store.get_audit_log(1, None, None).unwrap();
1129        assert!(log[0].details_json.is_some());
1130        let details = log[0].details_json.as_ref().unwrap();
1131        assert!(details.contains("\"kind\":\"fact\""));
1132        assert!(details.contains("\"subject\":\"Jared\""));
1133    }
1134
1135    // ── Checksum tests ──────────────────────────────────────
1136
1137    #[test]
1138    fn fact_gets_checksum_on_insert() {
1139        let store = MemoryStore::open_in_memory().unwrap();
1140        let id = store.remember_fact("Jared", "likes", "Rust", None).unwrap();
1141        let mem = store.get_memory(id).unwrap().unwrap();
1142        assert!(mem.checksum.is_some());
1143        assert!(!mem.checksum.as_ref().unwrap().is_empty());
1144    }
1145
1146    #[test]
1147    fn episode_gets_checksum_on_insert() {
1148        let store = MemoryStore::open_in_memory().unwrap();
1149        let id = store.remember_episode("had coffee", None).unwrap();
1150        let mem = store.get_memory(id).unwrap().unwrap();
1151        assert!(mem.checksum.is_some());
1152    }
1153
1154    #[test]
1155    fn checksum_is_consistent_for_same_content() {
1156        let store = MemoryStore::open_in_memory().unwrap();
1157        let id1 = store.remember_fact_ns("A", "B", "C", None, &[], None, None, None, "ns1").unwrap();
1158        let id2 = store.remember_fact_ns("A", "B", "C", None, &[], None, None, None, "ns2").unwrap();
1159        let mem1 = store.get_memory(id1).unwrap().unwrap();
1160        let mem2 = store.get_memory(id2).unwrap().unwrap();
1161        assert_eq!(mem1.checksum, mem2.checksum, "same content should produce same checksum");
1162    }
1163
1164    #[test]
1165    fn checksum_differs_for_different_content() {
1166        let store = MemoryStore::open_in_memory().unwrap();
1167        let id1 = store.remember_fact("A", "B", "C", None).unwrap();
1168        let id2 = store.remember_fact("X", "Y", "Z", None).unwrap();
1169        let mem1 = store.get_memory(id1).unwrap().unwrap();
1170        let mem2 = store.get_memory(id2).unwrap().unwrap();
1171        assert_ne!(mem1.checksum, mem2.checksum);
1172    }
1173
1174    #[test]
1175    fn upsert_updates_checksum() {
1176        let store = MemoryStore::open_in_memory().unwrap();
1177        let (id, _) = store.upsert_fact("Jared", "color", "blue", None, &[], None, None, None).unwrap();
1178        let mem1 = store.get_memory(id).unwrap().unwrap();
1179        let checksum1 = mem1.checksum.clone().unwrap();
1180
1181        store.upsert_fact("Jared", "color", "green", None, &[], None, None, None).unwrap();
1182        let mem2 = store.get_memory(id).unwrap().unwrap();
1183        let checksum2 = mem2.checksum.clone().unwrap();
1184        assert_ne!(checksum1, checksum2, "upsert with new object should change checksum");
1185    }
1186
1187    // ── Verify integrity tests ──────────────────────────────
1188
1189    #[test]
1190    fn verify_all_valid() {
1191        let store = MemoryStore::open_in_memory().unwrap();
1192        store.remember_fact("A", "B", "C", None).unwrap();
1193        store.remember_episode("hello", None).unwrap();
1194
1195        let result = store.verify_integrity().unwrap();
1196        assert_eq!(result.total_checked, 2);
1197        assert_eq!(result.valid, 2);
1198        assert!(result.corrupted.is_empty());
1199        assert_eq!(result.missing_checksum, 0);
1200    }
1201
1202    #[test]
1203    fn verify_detects_corrupted_fact() {
1204        let store = MemoryStore::open_in_memory().unwrap();
1205        let id = store.remember_fact("Jared", "likes", "Rust", None).unwrap();
1206
1207        // Corrupt the data
1208        store.conn().execute("UPDATE memories SET object = 'Go' WHERE id = ?1", params![id]).unwrap();
1209
1210        let result = store.verify_integrity().unwrap();
1211        assert_eq!(result.corrupted.len(), 1);
1212        assert_eq!(result.corrupted[0].id, id);
1213    }
1214
1215    #[test]
1216    fn verify_detects_corrupted_episode() {
1217        let store = MemoryStore::open_in_memory().unwrap();
1218        let id = store.remember_episode("original text", None).unwrap();
1219
1220        // Corrupt the data
1221        store.conn().execute("UPDATE memories SET episode_text = 'modified text' WHERE id = ?1", params![id]).unwrap();
1222
1223        let result = store.verify_integrity().unwrap();
1224        assert_eq!(result.corrupted.len(), 1);
1225        assert_eq!(result.corrupted[0].id, id);
1226    }
1227
1228    #[test]
1229    fn verify_reports_missing_checksums() {
1230        let store = MemoryStore::open_in_memory().unwrap();
1231        store.remember_fact("A", "B", "C", None).unwrap();
1232
1233        // Null out checksum
1234        store.conn().execute("UPDATE memories SET checksum = NULL", []).unwrap();
1235
1236        let result = store.verify_integrity().unwrap();
1237        assert_eq!(result.missing_checksum, 1);
1238        assert_eq!(result.valid, 0);
1239        assert!(result.corrupted.is_empty());
1240    }
1241
1242    #[test]
1243    fn verify_namespace_scoped() {
1244        let store = MemoryStore::open_in_memory().unwrap();
1245        store.remember_fact_ns("A", "B", "C", None, &[], None, None, None, "ns-a").unwrap();
1246        store.remember_fact_ns("X", "Y", "Z", None, &[], None, None, None, "ns-b").unwrap();
1247
1248        // Corrupt ns-b data
1249        store.conn().execute("UPDATE memories SET object = 'CORRUPTED' WHERE namespace = 'ns-b'", []).unwrap();
1250
1251        let result_a = store.verify_integrity_ns("ns-a").unwrap();
1252        let result_b = store.verify_integrity_ns("ns-b").unwrap();
1253
1254        assert_eq!(result_a.valid, 1);
1255        assert!(result_a.corrupted.is_empty(), "ns-a should be clean");
1256        assert_eq!(result_b.corrupted.len(), 1, "ns-b should have corruption");
1257    }
1258
1259    // ── Namespace isolation tests ───────────────────────────
1260
1261    #[test]
1262    fn namespace_default_on_remember() {
1263        let store = MemoryStore::open_in_memory().unwrap();
1264        let id = store.remember_fact("A", "B", "C", None).unwrap();
1265        let mem = store.get_memory(id).unwrap().unwrap();
1266        assert_eq!(mem.namespace, "default");
1267    }
1268
1269    #[test]
1270    fn namespace_set_on_remember_ns() {
1271        let store = MemoryStore::open_in_memory().unwrap();
1272        let id = store.remember_fact_ns("A", "B", "C", None, &[], None, None, None, "project-x").unwrap();
1273        let mem = store.get_memory(id).unwrap().unwrap();
1274        assert_eq!(mem.namespace, "project-x");
1275    }
1276
1277    #[test]
1278    fn namespace_isolates_all_memories_with_text() {
1279        let store = MemoryStore::open_in_memory().unwrap();
1280        store.remember_fact_ns("A", "B", "C", None, &[], None, None, None, "ns1").unwrap();
1281        store.remember_fact_ns("X", "Y", "Z", None, &[], None, None, None, "ns2").unwrap();
1282
1283        let ns1 = store.all_memories_with_text_ns("ns1").unwrap();
1284        let ns2 = store.all_memories_with_text_ns("ns2").unwrap();
1285        assert_eq!(ns1.len(), 1);
1286        assert_eq!(ns2.len(), 1);
1287        assert_ne!(ns1[0].0.id, ns2[0].0.id);
1288    }
1289
1290    #[test]
1291    fn namespace_isolates_stats() {
1292        let store = MemoryStore::open_in_memory().unwrap();
1293        store.remember_fact_ns("A", "B", "C", None, &[], None, None, None, "ns1").unwrap();
1294        store.remember_fact_ns("D", "E", "F", None, &[], None, None, None, "ns1").unwrap();
1295        store.remember_fact_ns("X", "Y", "Z", None, &[], None, None, None, "ns2").unwrap();
1296
1297        let stats1 = store.stats_ns("ns1").unwrap();
1298        let stats2 = store.stats_ns("ns2").unwrap();
1299        assert_eq!(stats1.total_memories, 2);
1300        assert_eq!(stats2.total_memories, 1);
1301    }
1302
1303    #[test]
1304    fn namespace_isolates_upsert() {
1305        let store = MemoryStore::open_in_memory().unwrap();
1306        store.upsert_fact_ns("Jared", "color", "blue", None, &[], None, None, None, "ns1").unwrap();
1307        store.upsert_fact_ns("Jared", "color", "red", None, &[], None, None, None, "ns2").unwrap();
1308
1309        // Upsert in ns1 should only update ns1
1310        let (_, updated) = store.upsert_fact_ns("Jared", "color", "green", None, &[], None, None, None, "ns1").unwrap();
1311        assert!(updated);
1312
1313        let ns2_mems = store.all_memories_ns("ns2").unwrap();
1314        if let MemoryKind::Fact(f) = &ns2_mems[0].kind {
1315            assert_eq!(f.object, "red", "ns2 should be unaffected");
1316        } else { panic!("expected fact"); }
1317    }
1318
1319    #[test]
1320    fn namespace_isolates_forget_by_subject() {
1321        let store = MemoryStore::open_in_memory().unwrap();
1322        store.remember_fact_ns("Jared", "likes", "A", None, &[], None, None, None, "ns1").unwrap();
1323        store.remember_fact_ns("Jared", "likes", "B", None, &[], None, None, None, "ns2").unwrap();
1324
1325        let deleted = store.forget_by_subject_ns("Jared", "ns1").unwrap();
1326        assert_eq!(deleted, 1);
1327
1328        // ns2 should be untouched
1329        assert_eq!(store.stats_ns("ns2").unwrap().total_memories, 1);
1330        assert_eq!(store.stats_ns("ns1").unwrap().total_memories, 0);
1331    }
1332
1333    #[test]
1334    fn namespace_isolates_decay() {
1335        let store = MemoryStore::open_in_memory().unwrap();
1336        store.remember_fact_ns("A", "B", "C", None, &[], None, None, None, "ns1").unwrap();
1337        store.remember_fact_ns("X", "Y", "Z", None, &[], None, None, None, "ns2").unwrap();
1338
1339        // Make all memories old
1340        let old_time = (Utc::now() - chrono::Duration::hours(48)).to_rfc3339();
1341        store.conn().execute("UPDATE memories SET last_accessed_at = ?1", params![old_time]).unwrap();
1342
1343        let decayed = store.decay_all_ns(0.5, 24.0, "ns1").unwrap();
1344        assert_eq!(decayed, 1, "should only decay ns1 memories");
1345
1346        // ns2 should be untouched (strength still 1.0)
1347        let ns2_mems = store.all_memories_ns("ns2").unwrap();
1348        assert!((ns2_mems[0].strength - 1.0).abs() < 0.01, "ns2 should not be decayed");
1349    }
1350
1351    #[test]
1352    fn namespace_isolates_embeddings() {
1353        let store = MemoryStore::open_in_memory().unwrap();
1354        store.remember_fact_ns("A", "B", "C", Some(&[1.0, 0.0]), &[], None, None, None, "ns1").unwrap();
1355        store.remember_fact_ns("X", "Y", "Z", Some(&[0.0, 1.0]), &[], None, None, None, "ns2").unwrap();
1356
1357        let emb1 = store.all_embeddings_ns("ns1").unwrap();
1358        let emb2 = store.all_embeddings_ns("ns2").unwrap();
1359        assert_eq!(emb1.len(), 1);
1360        assert_eq!(emb2.len(), 1);
1361    }
1362
1363    #[test]
1364    fn namespace_episode_isolation() {
1365        let store = MemoryStore::open_in_memory().unwrap();
1366        store.remember_episode_ns("event in ns1", None, &[], None, None, None, "ns1").unwrap();
1367        store.remember_episode_ns("event in ns2", None, &[], None, None, None, "ns2").unwrap();
1368
1369        let ns1 = store.all_memories_with_text_ns("ns1").unwrap();
1370        let ns2 = store.all_memories_with_text_ns("ns2").unwrap();
1371        assert_eq!(ns1.len(), 1);
1372        assert_eq!(ns2.len(), 1);
1373        assert!(ns1[0].1.contains("ns1"));
1374        assert!(ns2[0].1.contains("ns2"));
1375    }
1376
1377    #[test]
1378    fn namespace_import_export_scoped() {
1379        let store = MemoryStore::open_in_memory().unwrap();
1380        store.remember_fact_ns("A", "B", "C", None, &[], None, None, None, "ns1").unwrap();
1381        store.remember_fact_ns("X", "Y", "Z", None, &[], None, None, None, "ns2").unwrap();
1382
1383        let ns1_mems = store.all_memories_ns("ns1").unwrap();
1384        assert_eq!(ns1_mems.len(), 1);
1385        assert_eq!(ns1_mems[0].namespace, "ns1");
1386    }
1387}
1388
1389fn row_to_memory(row: &rusqlite::Row) -> SqlResult<MemoryRecord> {
1390    let kind_str: String = row.get(1)?;
1391    let kind = match kind_str.as_str() {
1392        "fact" => MemoryKind::Fact(Fact {
1393            subject: row.get(2)?,
1394            relation: row.get(3)?,
1395            object: row.get(4)?,
1396        }),
1397        _ => MemoryKind::Episode(Episode {
1398            text: row.get::<_, Option<String>>(5)?.unwrap_or_default(),
1399        }),
1400    };
1401    let embedding: Option<Vec<u8>> = row.get(7)?;
1402    let tags_str: String = row.get::<_, Option<String>>(11)?.unwrap_or_default();
1403    let tags: Vec<String> = if tags_str.is_empty() {
1404        vec![]
1405    } else {
1406        tags_str.split(',').map(|s| s.trim().to_string()).collect()
1407    };
1408    Ok(MemoryRecord {
1409        id: row.get(0)?,
1410        kind,
1411        strength: row.get(6)?,
1412        embedding: embedding.map(|b| blob_to_embedding(&b)),
1413        created_at: parse_datetime(&row.get::<_, String>(8)?),
1414        last_accessed_at: parse_datetime(&row.get::<_, String>(9)?),
1415        access_count: row.get(10)?,
1416        tags,
1417        source: row.get::<_, Option<String>>(12)?,
1418        session_id: row.get::<_, Option<String>>(13)?,
1419        channel: row.get::<_, Option<String>>(14)?,
1420        importance: row.get::<_, Option<f64>>(15)?.unwrap_or(0.5),
1421        namespace: row.get::<_, Option<String>>(16)?.unwrap_or_else(|| "default".to_string()),
1422        checksum: row.get::<_, Option<String>>(17)?,
1423    })
1424}