1use anyhow::Result;
10use chrono::Local;
11use rusqlite::{Connection, params};
12use std::fmt::Write;
13use std::fs;
14use std::path::{Path, PathBuf};
15
16pub const SNAPSHOT_FILENAME: &str = "MEMORY_SNAPSHOT.md";
18
19const 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
25pub 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
104pub 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 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 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 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
207pub 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 fs::metadata(&db_path)
219 .map(|m| m.len() < 4096) .unwrap_or(true)
221 } else {
222 true
223 };
224
225 db_missing_or_empty && snapshot.exists()
226}
227
228fn snapshot_path(workspace_dir: &Path) -> PathBuf {
230 workspace_dir.join(SNAPSHOT_FILENAME)
231}
232
233fn 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 if trimmed.starts_with("### 🔑 `") && trimmed.ends_with('`') {
244 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 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 if trimmed.starts_with("*Created:") || trimmed == "---" {
266 continue;
267 }
268 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 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 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 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 let exported = export_snapshot(workspace).unwrap();
414 assert_eq!(exported, 2, "Should export only core memories");
415
416 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 fs::remove_file(&db_path).unwrap();
427 assert!(!db_path.exists());
428
429 assert!(should_hydrate(workspace));
431
432 let hydrated = hydrate_from_snapshot(workspace).unwrap();
434 assert_eq!(hydrated, 2, "Should hydrate both core memories");
435
436 assert!(db_path.exists());
438
439 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 assert!(!should_hydrate(workspace));
463
464 let snapshot = workspace.join(SNAPSHOT_FILENAME);
466 fs::write(&snapshot, "### 🔑 `test`\n\nHello\n").unwrap();
467 assert!(should_hydrate(workspace));
468
469 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}