Skip to main content

vtcode_core/
loop_memory.rs

1//! Loop memory store for durable loop-engineering state.
2//!
3//! The loop memory store provides append-only markdown files that the agent
4//! writes during a loop run and reads on the next iteration. This is the
5//! "memory" primitive from Osmani's loop engineering pattern:
6//!
7//! > "The agent forgets, the repo doesn't."
8//!
9//! The default implementation writes to `{workspace}/.vtcode/state/notes.md`
10//! and `{workspace}/.vtcode/state/decisions.md`.
11
12use anyhow::{Context, Result};
13use std::fs::{self, OpenOptions};
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use crate::loop_state::state_dir;
18
19const NOTES_FILENAME: &str = "notes.md";
20const DECISIONS_FILENAME: &str = "decisions.md";
21
22// ─── Trait ───────────────────────────────────────────────────────────────────
23
24/// A trait for persisting loop-level memory across runs. Implementations may
25/// store notes and decisions in markdown, a database, or any other durable form.
26pub trait LoopMemoryStore: Send + Sync {
27    /// Read all accumulated notes.
28    fn read_notes(&self) -> Result<String>;
29
30    /// Append a note. Notes are agent-written, human-readable, append-only.
31    fn write_note(&self, note: &str) -> Result<()>;
32
33    /// Read all accumulated decisions.
34    fn read_decisions(&self) -> Result<String>;
35
36    /// Append a decision entry. Decisions are the agent's choices that should
37    /// survive across loop iterations.
38    fn write_decision(&self, decision: &str) -> Result<()>;
39}
40
41// ─── Markdown Implementation ─────────────────────────────────────────────────
42
43/// Markdown-backed loop memory store. Writes to `.vtcode/state/notes.md` and
44/// `.vtcode/state/decisions.md` under the workspace root.
45pub struct MarkdownLoopMemory {
46    notes_path: PathBuf,
47    decisions_path: PathBuf,
48}
49
50impl MarkdownLoopMemory {
51    /// Create a new markdown loop memory store for the given workspace.
52    pub fn new(workspace_root: &Path) -> Self {
53        let dir = state_dir(workspace_root);
54        Self {
55            notes_path: dir.join(NOTES_FILENAME),
56            decisions_path: dir.join(DECISIONS_FILENAME),
57        }
58    }
59
60    /// Create a new markdown loop memory store with explicit paths (for testing).
61    pub fn with_paths(notes_path: PathBuf, decisions_path: PathBuf) -> Self {
62        Self { notes_path, decisions_path }
63    }
64}
65
66impl LoopMemoryStore for MarkdownLoopMemory {
67    fn read_notes(&self) -> Result<String> {
68        read_markdown_file(&self.notes_path)
69    }
70
71    fn write_note(&self, note: &str) -> Result<()> {
72        append_markdown_entry(&self.notes_path, note, "# Loop Notes\n\n")
73    }
74
75    fn read_decisions(&self) -> Result<String> {
76        read_markdown_file(&self.decisions_path)
77    }
78
79    fn write_decision(&self, decision: &str) -> Result<()> {
80        append_markdown_entry(&self.decisions_path, decision, "# Loop Decisions\n\n")
81    }
82}
83
84// ─── Helpers ─────────────────────────────────────────────────────────────────
85
86fn read_markdown_file(path: &Path) -> Result<String> {
87    if !path.exists() {
88        return Ok(String::new());
89    }
90    fs::read_to_string(path).with_context(|| format!("Failed to read {}", path.display()))
91}
92
93fn append_markdown_entry(path: &Path, content: &str, header: &str) -> Result<()> {
94    let trimmed = content.trim();
95    if trimmed.is_empty() {
96        return Ok(());
97    }
98
99    if let Some(parent) = path.parent() {
100        fs::create_dir_all(parent).with_context(|| format!("Failed to create directory {}", parent.display()))?;
101    }
102
103    let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
104    let entry = format!("- [{timestamp}] {trimmed}\n");
105
106    // Open a single file handle with read+append+create to avoid TOCTOU race
107    // between checking whether the header exists and writing the entry.
108    let mut file = OpenOptions::new()
109        .read(true)
110        .create(true)
111        .append(true)
112        .open(path)
113        .with_context(|| format!("Failed to open {} for appending", path.display()))?;
114
115    // Check if the file is empty (needs a header) using the same handle.
116    let needs_header = {
117        let metadata = file.metadata().with_context(|| format!("Failed to stat {}", path.display()))?;
118        metadata.len() == 0
119    };
120
121    if needs_header {
122        file.write_all(header.as_bytes())
123            .with_context(|| format!("Failed to write header to {}", path.display()))?;
124    }
125
126    file.write_all(entry.as_bytes())
127        .with_context(|| format!("Failed to append to {}", path.display()))?;
128    Ok(())
129}
130
131// ─── Sqlite Implementation ───────────────────────────────────────────────────
132
133/// Sqlite-backed loop memory store for faster queries. Enabled behind the
134/// `sqlite` feature flag. Stores notes and decisions in a single database
135/// file under `.vtcode/state/loop_memory.db`.
136#[cfg(feature = "sqlite")]
137pub struct SqliteLoopMemory {
138    conn: std::sync::Mutex<rusqlite::Connection>,
139}
140
141#[cfg(feature = "sqlite")]
142impl SqliteLoopMemory {
143    /// Create a new sqlite loop memory store for the given workspace.
144    pub fn new(workspace_root: &Path) -> Result<Self> {
145        let dir = state_dir(workspace_root);
146        let db_path = dir.join("loop_memory.db");
147        Self::from_path(db_path)
148    }
149
150    /// Create with an explicit database path (for testing).
151    pub fn with_path(db_path: PathBuf) -> Result<Self> {
152        Self::from_path(db_path)
153    }
154
155    fn from_path(db_path: PathBuf) -> Result<Self> {
156        let conn = rusqlite::Connection::open(&db_path)?;
157        conn.execute_batch(
158            "CREATE TABLE IF NOT EXISTS notes (
159                id INTEGER PRIMARY KEY AUTOINCREMENT,
160                timestamp TEXT NOT NULL,
161                content TEXT NOT NULL
162            );
163            CREATE TABLE IF NOT EXISTS decisions (
164                id INTEGER PRIMARY KEY AUTOINCREMENT,
165                timestamp TEXT NOT NULL,
166                content TEXT NOT NULL
167            );",
168        )?;
169        Ok(Self { conn: std::sync::Mutex::new(conn) })
170    }
171}
172
173#[cfg(feature = "sqlite")]
174impl LoopMemoryStore for SqliteLoopMemory {
175    fn read_notes(&self) -> Result<String> {
176        let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("Lock poisoned: {e}"))?;
177        let mut stmt = conn.prepare("SELECT timestamp, content FROM notes ORDER BY id")?;
178        let rows = stmt.query_map([], |row| {
179            let ts: String = row.get(0)?;
180            let content: String = row.get(1)?;
181            Ok(format!("- [{ts}] {content}"))
182        })?;
183
184        let entries: Vec<String> = rows.collect::<Result<Vec<_>, _>>()?;
185        // Match Markdown behavior: return empty string when no entries exist.
186        if entries.is_empty() {
187            return Ok(String::new());
188        }
189        let mut output = String::from("# Loop Notes\n\n");
190        for entry in entries {
191            output.push_str(&entry);
192            output.push('\n');
193        }
194        Ok(output)
195    }
196
197    fn write_note(&self, note: &str) -> Result<()> {
198        let trimmed = note.trim();
199        if trimmed.is_empty() {
200            return Ok(());
201        }
202        let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
203        let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("Lock poisoned: {e}"))?;
204        conn.execute(
205            "INSERT INTO notes (timestamp, content) VALUES (?1, ?2)",
206            rusqlite::params![timestamp.to_string(), trimmed],
207        )?;
208        Ok(())
209    }
210
211    fn read_decisions(&self) -> Result<String> {
212        let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("Lock poisoned: {e}"))?;
213        let mut stmt = conn.prepare("SELECT timestamp, content FROM decisions ORDER BY id")?;
214        let rows = stmt.query_map([], |row| {
215            let ts: String = row.get(0)?;
216            let content: String = row.get(1)?;
217            Ok(format!("- [{ts}] {content}"))
218        })?;
219
220        let entries: Vec<String> = rows.collect::<Result<Vec<_>, _>>()?;
221        if entries.is_empty() {
222            return Ok(String::new());
223        }
224        let mut output = String::from("# Loop Decisions\n\n");
225        for entry in entries {
226            output.push_str(&entry);
227            output.push('\n');
228        }
229        Ok(output)
230    }
231
232    fn write_decision(&self, decision: &str) -> Result<()> {
233        let trimmed = decision.trim();
234        if trimmed.is_empty() {
235            return Ok(());
236        }
237        let timestamp = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
238        let conn = self.conn.lock().map_err(|e| anyhow::anyhow!("Lock poisoned: {e}"))?;
239        conn.execute(
240            "INSERT INTO decisions (timestamp, content) VALUES (?1, ?2)",
241            rusqlite::params![timestamp.to_string(), trimmed],
242        )?;
243        Ok(())
244    }
245}
246
247// ─── Tests ───────────────────────────────────────────────────────────────────
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252    use tempfile::TempDir;
253
254    #[test]
255    fn markdown_memory_read_empty_when_no_files() {
256        let tmp = TempDir::new().expect("temp dir");
257        let memory = MarkdownLoopMemory::new(tmp.path());
258        assert_eq!(memory.read_notes().expect("read"), "");
259        assert_eq!(memory.read_decisions().expect("read"), "");
260    }
261
262    #[test]
263    fn markdown_memory_write_and_read_notes() {
264        let tmp = TempDir::new().expect("temp dir");
265        let memory = MarkdownLoopMemory::new(tmp.path());
266
267        memory.write_note("First observation").expect("write");
268        memory.write_note("Second observation").expect("write");
269
270        let notes = memory.read_notes().expect("read");
271        assert!(notes.contains("First observation"));
272        assert!(notes.contains("Second observation"));
273        assert!(notes.starts_with("# Loop Notes"));
274    }
275
276    #[test]
277    fn markdown_memory_write_and_read_decisions() {
278        let tmp = TempDir::new().expect("temp dir");
279        let memory = MarkdownLoopMemory::new(tmp.path());
280
281        memory.write_decision("Use retry with backoff").expect("write");
282        memory.write_decision("Skip tests for now").expect("write");
283
284        let decisions = memory.read_decisions().expect("read");
285        assert!(decisions.contains("Use retry with backoff"));
286        assert!(decisions.contains("Skip tests for now"));
287        assert!(decisions.starts_with("# Loop Decisions"));
288    }
289
290    #[test]
291    fn markdown_memory_ignores_empty_entries() {
292        let tmp = TempDir::new().expect("temp dir");
293        let memory = MarkdownLoopMemory::new(tmp.path());
294
295        memory.write_note("").expect("write");
296        memory.write_note("   ").expect("write");
297
298        let notes = memory.read_notes().expect("read");
299        assert_eq!(notes, "");
300    }
301
302    #[test]
303    fn markdown_memory_entries_are_timestamped() {
304        let tmp = TempDir::new().expect("temp dir");
305        let notes_path = tmp.path().join("notes.md");
306        let decisions_path = tmp.path().join("decisions.md");
307        let memory = MarkdownLoopMemory::with_paths(notes_path.clone(), decisions_path);
308
309        memory.write_note("test entry").expect("write");
310
311        let content = fs::read_to_string(&notes_path).expect("read");
312        // Should contain a timestamp in [YYYY-MM-DDTHH:MM:SSZ] format
313        assert!(content.contains("[20"));
314        assert!(content.contains("] test entry"));
315    }
316
317    #[test]
318    fn markdown_memory_append_is_durable() {
319        let tmp = TempDir::new().expect("temp dir");
320        let notes_path = tmp.path().join("notes.md");
321        let decisions_path = tmp.path().join("decisions.md");
322
323        {
324            let memory = MarkdownLoopMemory::with_paths(notes_path.clone(), decisions_path.clone());
325            memory.write_note("first run note").expect("write");
326        }
327        // Simulate a new run (drop and recreate)
328        {
329            let memory = MarkdownLoopMemory::with_paths(notes_path, decisions_path);
330            memory.write_note("second run note").expect("write");
331            let notes = memory.read_notes().expect("read");
332            assert!(notes.contains("first run note"));
333            assert!(notes.contains("second run note"));
334        }
335    }
336
337    #[cfg(feature = "sqlite")]
338    mod sqlite_tests {
339        use super::super::*;
340        use tempfile::TempDir;
341
342        #[test]
343        fn sqlite_memory_read_empty_when_no_data() {
344            let tmp = TempDir::new().expect("temp dir");
345            let db_path = tmp.path().join("test.db");
346            let memory = SqliteLoopMemory::with_path(db_path).expect("create");
347            // Empty state matches Markdown behavior: returns empty string.
348            assert_eq!(memory.read_notes().expect("read"), "");
349            assert_eq!(memory.read_decisions().expect("read"), "");
350        }
351
352        #[test]
353        fn sqlite_memory_write_and_read_notes() {
354            let tmp = TempDir::new().expect("temp dir");
355            let db_path = tmp.path().join("test.db");
356            let memory = SqliteLoopMemory::with_path(db_path).expect("create");
357
358            memory.write_note("First observation").expect("write");
359            memory.write_note("Second observation").expect("write");
360
361            let notes = memory.read_notes().expect("read");
362            assert!(notes.contains("First observation"));
363            assert!(notes.contains("Second observation"));
364            assert!(notes.starts_with("# Loop Notes"));
365        }
366
367        #[test]
368        fn sqlite_memory_write_and_read_decisions() {
369            let tmp = TempDir::new().expect("temp dir");
370            let db_path = tmp.path().join("test.db");
371            let memory = SqliteLoopMemory::with_path(db_path).expect("create");
372
373            memory.write_decision("Use retry with backoff").expect("write");
374            memory.write_decision("Skip tests for now").expect("write");
375
376            let decisions = memory.read_decisions().expect("read");
377            assert!(decisions.contains("Use retry with backoff"));
378            assert!(decisions.contains("Skip tests for now"));
379            assert!(decisions.starts_with("# Loop Decisions"));
380        }
381
382        #[test]
383        fn sqlite_memory_ignores_empty_entries() {
384            let tmp = TempDir::new().expect("temp dir");
385            let db_path = tmp.path().join("test.db");
386            let memory = SqliteLoopMemory::with_path(db_path).expect("create");
387
388            memory.write_note("").expect("write");
389            memory.write_note("   ").expect("write");
390
391            let notes = memory.read_notes().expect("read");
392            // Empty entries are skipped, so the result is empty (no rows).
393            assert_eq!(notes, "");
394        }
395
396        #[test]
397        fn sqlite_memory_survives_reopen() {
398            let tmp = TempDir::new().expect("temp dir");
399            let db_path = tmp.path().join("test.db");
400
401            {
402                let memory = SqliteLoopMemory::with_path(db_path.clone()).expect("create");
403                memory.write_note("first run note").expect("write");
404            }
405            {
406                let memory = SqliteLoopMemory::with_path(db_path).expect("reopen");
407                memory.write_note("second run note").expect("write");
408                let notes = memory.read_notes().expect("read");
409                assert!(notes.contains("first run note"));
410                assert!(notes.contains("second run note"));
411            }
412        }
413    }
414}