Skip to main content

zeroclaw_memory/
snapshot.rs

1//! Memory snapshot — export/import core memories as human-readable Markdown.
2//!
3//! **Atomic Soul Export**: dumps `MemoryCategory::Core` from SQLite into
4//! `MEMORY_SNAPSHOT.md` so the agent's "soul" is always Git-visible.
5//!
6//! **Auto-Hydration**: if `brain.db` is missing but `MEMORY_SNAPSHOT.md` exists,
7//! re-indexes all entries back into a fresh SQLite database.
8
9use anyhow::Result;
10use chrono::Local;
11use rusqlite::{Connection, params};
12use std::fmt::Write;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16/// Filename for the snapshot (lives at workspace root for Git visibility).
17pub const SNAPSHOT_FILENAME: &str = "MEMORY_SNAPSHOT.md";
18
19/// Header written at the top of every snapshot file.
20const SNAPSHOT_HEADER: &str = "# 🧠 ZeroClaw Memory Snapshot\n\n\
21    > Auto-generated by ZeroClaw. Do not edit manually unless you know what you're doing.\n\
22    > This file is the \"soul\" of your agent — if `brain.db` is lost, start the agent\n\
23    > in this workspace and it will auto-hydrate from this file.\n\n";
24
25/// Export all `Core` memories from SQLite → `MEMORY_SNAPSHOT.md`.
26///
27/// Returns the number of entries exported.
28pub fn export_snapshot(workspace_dir: &Path) -> Result<usize> {
29    let db_path = workspace_dir.join("memory").join("brain.db");
30    if !db_path.exists() {
31        ::zeroclaw_log::record!(
32            DEBUG,
33            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
34            "snapshot export skipped: brain.db does not exist"
35        );
36        return Ok(0);
37    }
38
39    let conn = Connection::open(&db_path)?;
40    conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?;
41
42    let mut stmt = conn.prepare(
43        "SELECT key, content, category, created_at, updated_at
44         FROM memories
45         WHERE category = 'core'
46         ORDER BY updated_at DESC",
47    )?;
48
49    let rows: Vec<(String, String, String, String, String)> = stmt
50        .query_map([], |row| {
51            Ok((
52                row.get(0)?,
53                row.get(1)?,
54                row.get(2)?,
55                row.get(3)?,
56                row.get(4)?,
57            ))
58        })?
59        .filter_map(|r| r.ok())
60        .collect();
61
62    if rows.is_empty() {
63        ::zeroclaw_log::record!(
64            DEBUG,
65            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
66            "snapshot export: no core memories to export"
67        );
68        return Ok(0);
69    }
70
71    let mut output = String::with_capacity(rows.len() * 200);
72    output.push_str(SNAPSHOT_HEADER);
73
74    let now = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
75    write!(output, "**Last exported:** {now}\n\n").unwrap();
76    write!(output, "**Total core memories:** {}\n\n---\n\n", rows.len()).unwrap();
77
78    for (key, content, _category, created_at, updated_at) in &rows {
79        write!(output, "### 🔑 `{key}`\n\n").unwrap();
80        write!(output, "{content}\n\n").unwrap();
81        write!(
82            output,
83            "*Created: {created_at} | Updated: {updated_at}*\n\n---\n\n"
84        )
85        .unwrap();
86    }
87
88    let snapshot_path = snapshot_path(workspace_dir);
89    fs::write(&snapshot_path, output)?;
90
91    ::zeroclaw_log::record!(
92        INFO,
93        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
94        &format!(
95            "📸 Memory snapshot exported: {} core memories → {}",
96            rows.len(),
97            snapshot_path.display().to_string()
98        )
99    );
100
101    Ok(rows.len())
102}
103
104/// Import memories from `MEMORY_SNAPSHOT.md` into SQLite.
105///
106/// Called during cold-boot when `brain.db` doesn't exist but the snapshot does.
107/// Returns the number of entries hydrated.
108pub fn hydrate_from_snapshot(workspace_dir: &Path) -> Result<usize> {
109    let snapshot = snapshot_path(workspace_dir);
110    if !snapshot.exists() {
111        return Ok(0);
112    }
113
114    let content = fs::read_to_string(&snapshot)?;
115    let entries = parse_snapshot(&content);
116
117    if entries.is_empty() {
118        return Ok(0);
119    }
120
121    // Ensure the memory directory exists
122    let db_dir = workspace_dir.join("memory");
123    fs::create_dir_all(&db_dir)?;
124
125    let db_path = db_dir.join("brain.db");
126    let conn = Connection::open(&db_path)?;
127    conn.execute_batch("PRAGMA journal_mode = WAL; PRAGMA synchronous = NORMAL;")?;
128
129    // Initialize schema (same as SqliteMemory::init_schema)
130    conn.execute_batch(
131        "CREATE TABLE IF NOT EXISTS memories (
132            id         TEXT PRIMARY KEY,
133            key        TEXT NOT NULL UNIQUE,
134            content    TEXT NOT NULL,
135            category   TEXT NOT NULL DEFAULT 'core',
136            embedding  BLOB,
137            created_at TEXT NOT NULL,
138            updated_at TEXT NOT NULL
139        );
140        CREATE INDEX IF NOT EXISTS idx_mem_key ON memories(key);
141        CREATE INDEX IF NOT EXISTS idx_mem_cat ON memories(category);
142        CREATE INDEX IF NOT EXISTS idx_mem_updated ON memories(updated_at);
143
144        CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
145            USING fts5(key, content, content='memories', content_rowid='rowid');
146
147        CREATE TABLE IF NOT EXISTS embedding_cache (
148            content_hash TEXT PRIMARY KEY,
149            embedding    BLOB NOT NULL,
150            created_at   TEXT NOT NULL
151        );",
152    )?;
153
154    let now = Local::now().to_rfc3339();
155    let mut hydrated = 0;
156
157    for (key, content) in &entries {
158        let id = uuid::Uuid::new_v4().to_string();
159        let result = conn.execute(
160            "INSERT OR IGNORE INTO memories (id, key, content, category, created_at, updated_at)
161             VALUES (?1, ?2, ?3, 'core', ?4, ?5)",
162            params![id, key, content, now, now],
163        );
164
165        match result {
166            Ok(changed) if changed > 0 => {
167                // Populate FTS5
168                let _ = conn.execute(
169                    "INSERT INTO memories_fts(key, content) VALUES (?1, ?2)",
170                    params![key, content],
171                );
172                hydrated += 1;
173            }
174            Ok(_) => {
175                ::zeroclaw_log::record!(
176                    DEBUG,
177                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
178                        .with_attrs(::serde_json::json!({"key": key})),
179                    "hydrate: key '' already exists, skipping"
180                );
181            }
182            Err(e) => {
183                ::zeroclaw_log::record!(
184                    WARN,
185                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
186                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
187                        .with_attrs(::serde_json::json!({"error": format!("{}", e), "key": key})),
188                    "hydrate: failed to insert key ''"
189                );
190            }
191        }
192    }
193
194    ::zeroclaw_log::record!(
195        INFO,
196        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
197        &format!(
198            "🧬 Memory hydration complete: {} entries restored from {}",
199            hydrated,
200            snapshot.display().to_string()
201        )
202    );
203
204    Ok(hydrated)
205}
206
207/// Check if we should auto-hydrate on startup.
208///
209/// Returns `true` if:
210/// 1. `brain.db` does NOT exist (or is empty)
211/// 2. `MEMORY_SNAPSHOT.md` DOES exist
212pub fn should_hydrate(workspace_dir: &Path) -> bool {
213    let db_path = workspace_dir.join("memory").join("brain.db");
214    let snapshot = snapshot_path(workspace_dir);
215
216    let db_missing_or_empty = if db_path.exists() {
217        // DB exists but might be empty (freshly created)
218        fs::metadata(&db_path)
219            .map(|m| m.len() < 4096) // SQLite header is ~4096 bytes minimum
220            .unwrap_or(true)
221    } else {
222        true
223    };
224
225    db_missing_or_empty && snapshot.exists()
226}
227
228/// Path to the snapshot file.
229fn snapshot_path(workspace_dir: &Path) -> PathBuf {
230    workspace_dir.join(SNAPSHOT_FILENAME)
231}
232
233/// Parse the structured markdown snapshot back into (key, content) pairs.
234fn parse_snapshot(input: &str) -> Vec<(String, String)> {
235    let mut entries = Vec::new();
236    let mut current_key: Option<String> = None;
237    let mut current_content = String::new();
238
239    for line in input.lines() {
240        let trimmed = line.trim();
241
242        // Match: ### 🔑 `key_name`
243        if trimmed.starts_with("### 🔑 `") && trimmed.ends_with('`') {
244            // Save previous entry
245            if let Some(key) = current_key.take() {
246                let content = current_content.trim().to_string();
247                if !content.is_empty() {
248                    entries.push((key, content));
249                }
250            }
251
252            // Extract new key
253            let key = trimmed
254                .strip_prefix("### 🔑 `")
255                .and_then(|s| s.strip_suffix('`'))
256                .unwrap_or("")
257                .to_string();
258
259            if !key.is_empty() {
260                current_key = Some(key);
261                current_content = String::new();
262            }
263        } else if current_key.is_some() {
264            // Skip metadata lines and separators
265            if trimmed.starts_with("*Created:") || trimmed == "---" {
266                continue;
267            }
268            // Accumulate content
269            if !current_content.is_empty() || !trimmed.is_empty() {
270                if !current_content.is_empty() {
271                    current_content.push('\n');
272                }
273                current_content.push_str(line);
274            }
275        }
276    }
277
278    // Don't forget the last entry
279    if let Some(key) = current_key {
280        let content = current_content.trim().to_string();
281        if !content.is_empty() {
282            entries.push((key, content));
283        }
284    }
285
286    entries
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use tempfile::TempDir;
293
294    #[test]
295    fn parse_snapshot_basic() {
296        let input = r#"# 🧠 ZeroClaw Memory Snapshot
297
298> Auto-generated by ZeroClaw.
299
300**Last exported:** 2025-01-15 14:30:00
301
302**Total core memories:** 2
303
304---
305
306### 🔑 `identity`
307
308I am ZeroClaw, a self-preserving AI agent.
309
310*Created: 2025-01-15 | Updated: 2025-01-15*
311
312---
313
314### 🔑 `preference_lang`
315
316The user prefers Rust for systems programming.
317
318*Created: 2025-01-14 | Updated: 2025-01-15*
319
320---
321"#;
322
323        let entries = parse_snapshot(input);
324        assert_eq!(entries.len(), 2);
325        assert_eq!(entries[0].0, "identity");
326        assert!(entries[0].1.contains("self-preserving"));
327        assert_eq!(entries[1].0, "preference_lang");
328        assert!(entries[1].1.contains("Rust"));
329    }
330
331    #[test]
332    fn parse_snapshot_empty() {
333        let input = "# 🧠 ZeroClaw Memory Snapshot\n\n> Nothing here.\n";
334        let entries = parse_snapshot(input);
335        assert!(entries.is_empty());
336    }
337
338    #[test]
339    fn parse_snapshot_multiline_content() {
340        let input = r#"### 🔑 `rules`
341
342Rule 1: Always be helpful.
343Rule 2: Never lie.
344Rule 3: Protect the user.
345
346*Created: 2025-01-15 | Updated: 2025-01-15*
347
348---
349"#;
350
351        let entries = parse_snapshot(input);
352        assert_eq!(entries.len(), 1);
353        assert!(entries[0].1.contains("Rule 1"));
354        assert!(entries[0].1.contains("Rule 3"));
355    }
356
357    #[test]
358    fn export_no_db_returns_zero() {
359        let tmp = TempDir::new().unwrap();
360        let count = export_snapshot(tmp.path()).unwrap();
361        assert_eq!(count, 0);
362    }
363
364    #[test]
365    fn export_and_hydrate_roundtrip() {
366        let tmp = TempDir::new().unwrap();
367        let workspace = tmp.path();
368
369        // Create a brain.db manually with some core memories
370        let db_dir = workspace.join("memory");
371        fs::create_dir_all(&db_dir).unwrap();
372        let db_path = db_dir.join("brain.db");
373
374        let conn = Connection::open(&db_path).unwrap();
375        conn.execute_batch(
376            "PRAGMA journal_mode = WAL;
377             CREATE TABLE IF NOT EXISTS memories (
378                id TEXT PRIMARY KEY,
379                key TEXT NOT NULL UNIQUE,
380                content TEXT NOT NULL,
381                category TEXT NOT NULL DEFAULT 'core',
382                embedding BLOB,
383                created_at TEXT NOT NULL,
384                updated_at TEXT NOT NULL
385             );
386             CREATE INDEX IF NOT EXISTS idx_mem_key ON memories(key);",
387        )
388        .unwrap();
389
390        let now = Local::now().to_rfc3339();
391        conn.execute(
392            "INSERT INTO memories (id, key, content, category, created_at, updated_at)
393             VALUES ('id1', 'identity', 'I am a test agent', 'core', ?1, ?2)",
394            params![now, now],
395        )
396        .unwrap();
397        conn.execute(
398            "INSERT INTO memories (id, key, content, category, created_at, updated_at)
399             VALUES ('id2', 'preference', 'User likes Rust', 'core', ?1, ?2)",
400            params![now, now],
401        )
402        .unwrap();
403        // Non-core entry (should NOT be exported)
404        conn.execute(
405            "INSERT INTO memories (id, key, content, category, created_at, updated_at)
406             VALUES ('id3', 'conv1', 'Random convo', 'conversation', ?1, ?2)",
407            params![now, now],
408        )
409        .unwrap();
410        drop(conn);
411
412        // Export snapshot
413        let exported = export_snapshot(workspace).unwrap();
414        assert_eq!(exported, 2, "Should export only core memories");
415
416        // Verify the file exists and is readable
417        let snapshot = workspace.join(SNAPSHOT_FILENAME);
418        assert!(snapshot.exists());
419        let content = fs::read_to_string(&snapshot).unwrap();
420        assert!(content.contains("identity"));
421        assert!(content.contains("I am a test agent"));
422        assert!(content.contains("preference"));
423        assert!(!content.contains("Random convo"));
424
425        // Simulate catastrophic failure: delete brain.db
426        fs::remove_file(&db_path).unwrap();
427        assert!(!db_path.exists());
428
429        // Verify should_hydrate detects the scenario
430        assert!(should_hydrate(workspace));
431
432        // Hydrate from snapshot
433        let hydrated = hydrate_from_snapshot(workspace).unwrap();
434        assert_eq!(hydrated, 2, "Should hydrate both core memories");
435
436        // Verify brain.db was recreated
437        assert!(db_path.exists());
438
439        // Verify the data is actually in the new database
440        let conn = Connection::open(&db_path).unwrap();
441        let count: i64 = conn
442            .query_row("SELECT COUNT(*) FROM memories", [], |row| row.get(0))
443            .unwrap();
444        assert_eq!(count, 2);
445
446        let identity: String = conn
447            .query_row(
448                "SELECT content FROM memories WHERE key = 'identity'",
449                [],
450                |row| row.get(0),
451            )
452            .unwrap();
453        assert_eq!(identity, "I am a test agent");
454    }
455
456    #[test]
457    fn should_hydrate_only_when_needed() {
458        let tmp = TempDir::new().unwrap();
459        let workspace = tmp.path();
460
461        // No DB, no snapshot → false
462        assert!(!should_hydrate(workspace));
463
464        // Create snapshot but no DB → true
465        let snapshot = workspace.join(SNAPSHOT_FILENAME);
466        fs::write(&snapshot, "### 🔑 `test`\n\nHello\n").unwrap();
467        assert!(should_hydrate(workspace));
468
469        // Create a real DB → false
470        let db_dir = workspace.join("memory");
471        fs::create_dir_all(&db_dir).unwrap();
472        let db_path = db_dir.join("brain.db");
473        let conn = Connection::open(&db_path).unwrap();
474        conn.execute_batch(
475            "CREATE TABLE IF NOT EXISTS memories (
476                id TEXT PRIMARY KEY,
477                key TEXT NOT NULL UNIQUE,
478                content TEXT NOT NULL,
479                category TEXT NOT NULL DEFAULT 'core',
480                embedding BLOB,
481                created_at TEXT NOT NULL,
482                updated_at TEXT NOT NULL
483             );
484             INSERT INTO memories VALUES('x','x','x','core',NULL,'2025-01-01','2025-01-01');",
485        )
486        .unwrap();
487        drop(conn);
488        assert!(!should_hydrate(workspace));
489    }
490
491    #[test]
492    fn hydrate_no_snapshot_returns_zero() {
493        let tmp = TempDir::new().unwrap();
494        let count = hydrate_from_snapshot(tmp.path()).unwrap();
495        assert_eq!(count, 0);
496    }
497}