vtcode_core/
loop_memory.rs1use 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
22pub trait LoopMemoryStore: Send + Sync {
27 fn read_notes(&self) -> Result<String>;
29
30 fn write_note(&self, note: &str) -> Result<()>;
32
33 fn read_decisions(&self) -> Result<String>;
35
36 fn write_decision(&self, decision: &str) -> Result<()>;
39}
40
41pub struct MarkdownLoopMemory {
46 notes_path: PathBuf,
47 decisions_path: PathBuf,
48}
49
50impl MarkdownLoopMemory {
51 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 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
84fn 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 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 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#[cfg(feature = "sqlite")]
137pub struct SqliteLoopMemory {
138 conn: std::sync::Mutex<rusqlite::Connection>,
139}
140
141#[cfg(feature = "sqlite")]
142impl SqliteLoopMemory {
143 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 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 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#[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(¬es_path).expect("read");
312 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 {
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 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 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}