Skip to main content

clawft_core/
memory_bootstrap.rs

1//! Memory bootstrap: index existing MEMORY.md into a vector store.
2//!
3//! On first startup (or when the index file is missing), this module reads
4//! the workspace's `MEMORY.md`, splits it into sections, embeds each
5//! section, and stores the results in an [`RvfStore`] for later semantic
6//! search.
7//!
8//! This module is gated behind the `rvf` feature flag.
9
10use std::path::Path;
11
12use tracing::{debug, info, warn};
13
14use crate::embeddings::Embedder;
15use crate::embeddings::rvf_stub::{RvfError, RvfStore};
16
17/// Errors that can occur during memory bootstrap.
18#[non_exhaustive]
19#[derive(Debug)]
20pub enum BootstrapError {
21    /// An I/O error occurred reading files.
22    Io(std::io::Error),
23    /// An embedding error occurred.
24    Embedding(crate::embeddings::EmbeddingError),
25    /// An RVF store error occurred.
26    Store(RvfError),
27}
28
29impl std::fmt::Display for BootstrapError {
30    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31        match self {
32            BootstrapError::Io(e) => write!(f, "bootstrap I/O error: {e}"),
33            BootstrapError::Embedding(e) => write!(f, "bootstrap embedding error: {e}"),
34            BootstrapError::Store(e) => write!(f, "bootstrap store error: {e}"),
35        }
36    }
37}
38
39impl std::error::Error for BootstrapError {}
40
41impl From<std::io::Error> for BootstrapError {
42    fn from(e: std::io::Error) -> Self {
43        BootstrapError::Io(e)
44    }
45}
46
47impl From<crate::embeddings::EmbeddingError> for BootstrapError {
48    fn from(e: crate::embeddings::EmbeddingError) -> Self {
49        BootstrapError::Embedding(e)
50    }
51}
52
53impl From<RvfError> for BootstrapError {
54    fn from(e: RvfError) -> Self {
55        BootstrapError::Store(e)
56    }
57}
58
59/// Bootstrap the memory vector index from an existing MEMORY.md file.
60///
61/// # Algorithm
62///
63/// 1. Check if the index file at `index_path` already exists -- if so, skip.
64/// 2. Read the MEMORY.md file at `memory_path`.
65/// 3. Split the content into sections by `## ` headers or double-newline
66///    paragraphs.
67/// 4. Embed each section using the provided [`Embedder`].
68/// 5. Store embeddings in an [`RvfStore`] and compact (persist) it.
69/// 6. Return the number of sections indexed.
70///
71/// # Arguments
72///
73/// * `memory_path` - Path to the MEMORY.md file.
74/// * `index_path` - Path where the index file will be stored (e.g. `memory.rvf.json`).
75/// * `embedder` - The embedder to use for generating vectors.
76///
77/// # Errors
78///
79/// Returns [`BootstrapError`] if reading the memory file, embedding, or
80/// persisting the index fails.
81pub async fn bootstrap_memory_index(
82    memory_path: &Path,
83    index_path: &Path,
84    embedder: &dyn Embedder,
85) -> Result<usize, BootstrapError> {
86    // 1. Check if index already exists
87    if index_path.exists() {
88        info!(
89            path = %index_path.display(),
90            "memory index already exists, skipping bootstrap"
91        );
92        return Ok(0);
93    }
94
95    // 2. Read MEMORY.md
96    if !memory_path.exists() {
97        info!(
98            path = %memory_path.display(),
99            "MEMORY.md not found, skipping bootstrap"
100        );
101        return Ok(0);
102    }
103
104    let content = std::fs::read_to_string(memory_path)?;
105    if content.trim().is_empty() {
106        info!("MEMORY.md is empty, skipping bootstrap");
107        return Ok(0);
108    }
109
110    debug!(
111        path = %memory_path.display(),
112        bytes = content.len(),
113        "read MEMORY.md for bootstrap"
114    );
115
116    // 3. Split into sections
117    let sections = split_into_sections(&content);
118    if sections.is_empty() {
119        info!("no sections found in MEMORY.md");
120        return Ok(0);
121    }
122
123    debug!(count = sections.len(), "split MEMORY.md into sections");
124
125    // 4. Embed each section
126    let mut store = RvfStore::create(Some(index_path));
127    let mut indexed = 0;
128
129    for (i, section) in sections.iter().enumerate() {
130        let trimmed = section.trim();
131        if trimmed.is_empty() {
132            continue;
133        }
134
135        match embedder.embed(trimmed).await {
136            Ok(embedding) => {
137                let id = format!("memory-section-{i}");
138                let metadata = serde_json::json!({
139                    "source": "MEMORY.md",
140                    "section_index": i,
141                    "text": trimmed,
142                    "char_count": trimmed.len(),
143                });
144                store.ingest(id, embedding, metadata);
145                indexed += 1;
146            }
147            Err(e) => {
148                warn!(
149                    section = i,
150                    error = %e,
151                    "failed to embed section, skipping"
152                );
153            }
154        }
155    }
156
157    // 5. Persist the store
158    store.compact()?;
159
160    info!(
161        indexed,
162        total_sections = sections.len(),
163        path = %index_path.display(),
164        "memory bootstrap complete"
165    );
166
167    Ok(indexed)
168}
169
170/// Split markdown content into sections.
171///
172/// Splitting strategy:
173/// 1. First try splitting by `## ` headers (level-2 markdown headings).
174/// 2. If that produces fewer than 2 sections, fall back to splitting by
175///    double newlines (paragraphs).
176/// 3. Filter out empty sections.
177fn split_into_sections(content: &str) -> Vec<String> {
178    // Try header-based splitting first
179    let header_sections: Vec<String> = split_by_headers(content);
180    if header_sections.len() >= 2 {
181        return header_sections;
182    }
183
184    // Fall back to paragraph splitting
185    split_by_paragraphs(content)
186}
187
188/// Split by `## ` markdown headers, keeping the header with its content.
189fn split_by_headers(content: &str) -> Vec<String> {
190    let mut sections = Vec::new();
191    let mut current = String::new();
192
193    for line in content.lines() {
194        if line.starts_with("## ") && !current.trim().is_empty() {
195            sections.push(current.trim().to_string());
196            current = String::new();
197        }
198        current.push_str(line);
199        current.push('\n');
200    }
201
202    if !current.trim().is_empty() {
203        sections.push(current.trim().to_string());
204    }
205
206    sections.into_iter().filter(|s| !s.is_empty()).collect()
207}
208
209/// Split by double newlines (paragraph breaks).
210fn split_by_paragraphs(content: &str) -> Vec<String> {
211    content
212        .split("\n\n")
213        .map(|s| s.trim().to_string())
214        .filter(|s| !s.is_empty())
215        .collect()
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221    use crate::embeddings::api_embedder::ApiEmbedder;
222    use std::sync::atomic::{AtomicU64, Ordering};
223
224    static TEST_COUNTER: AtomicU64 = AtomicU64::new(0);
225
226    fn temp_dir(label: &str) -> std::path::PathBuf {
227        let n = TEST_COUNTER.fetch_add(1, Ordering::Relaxed);
228        let pid = std::process::id();
229        std::env::temp_dir().join(format!("clawft_bootstrap_test_{label}_{pid}_{n}"))
230    }
231
232    #[test]
233    fn split_by_headers_basic() {
234        let content = "# Title\n\nIntro text.\n\n## Section One\n\nContent one.\n\n## Section Two\n\nContent two.";
235        let sections = split_by_headers(content);
236        assert_eq!(sections.len(), 3);
237        assert!(sections[0].starts_with("# Title"));
238        assert!(sections[1].starts_with("## Section One"));
239        assert!(sections[2].starts_with("## Section Two"));
240    }
241
242    #[test]
243    fn split_by_headers_no_headers() {
244        let content = "Just some plain text\nwith no headers.";
245        let sections = split_by_headers(content);
246        assert_eq!(sections.len(), 1);
247    }
248
249    #[test]
250    fn split_by_paragraphs_basic() {
251        let content = "First paragraph.\n\nSecond paragraph.\n\nThird paragraph.";
252        let sections = split_by_paragraphs(content);
253        assert_eq!(sections.len(), 3);
254        assert_eq!(sections[0], "First paragraph.");
255        assert_eq!(sections[1], "Second paragraph.");
256        assert_eq!(sections[2], "Third paragraph.");
257    }
258
259    #[test]
260    fn split_into_sections_uses_headers_when_available() {
261        let content = "## One\n\nContent.\n\n## Two\n\nMore content.";
262        let sections = split_into_sections(content);
263        assert_eq!(sections.len(), 2);
264    }
265
266    #[test]
267    fn split_into_sections_falls_back_to_paragraphs() {
268        let content = "Para one.\n\nPara two.\n\nPara three.";
269        let sections = split_into_sections(content);
270        assert_eq!(sections.len(), 3);
271    }
272
273    #[test]
274    fn split_into_sections_empty() {
275        let sections = split_into_sections("");
276        assert!(sections.is_empty());
277    }
278
279    #[test]
280    fn split_into_sections_whitespace_only() {
281        let sections = split_into_sections("   \n\n   \n\n   ");
282        assert!(sections.is_empty());
283    }
284
285    #[tokio::test]
286    async fn bootstrap_creates_index() {
287        let dir = temp_dir("creates_index");
288        std::fs::create_dir_all(&dir).unwrap();
289
290        let memory_path = dir.join("MEMORY.md");
291        let index_path = dir.join("memory.rvf.json");
292
293        std::fs::write(
294            &memory_path,
295            "## Facts\n\nThe sky is blue.\n\n## Notes\n\nRust is fast.",
296        )
297        .unwrap();
298
299        let embedder = ApiEmbedder::hash_only(64);
300        let count = bootstrap_memory_index(&memory_path, &index_path, &embedder)
301            .await
302            .unwrap();
303
304        assert_eq!(count, 2);
305        assert!(index_path.exists());
306
307        // Verify we can reopen the store
308        let store = RvfStore::open(&index_path).unwrap();
309        assert_eq!(store.len(), 2);
310
311        let _ = std::fs::remove_dir_all(&dir);
312    }
313
314    #[tokio::test]
315    async fn bootstrap_skips_existing_index() {
316        let dir = temp_dir("skips_existing");
317        std::fs::create_dir_all(&dir).unwrap();
318
319        let memory_path = dir.join("MEMORY.md");
320        let index_path = dir.join("memory.rvf.json");
321
322        std::fs::write(&memory_path, "## Stuff\n\nContent.").unwrap();
323        std::fs::write(&index_path, "{}").unwrap(); // pre-existing index
324
325        let embedder = ApiEmbedder::hash_only(64);
326        let count = bootstrap_memory_index(&memory_path, &index_path, &embedder)
327            .await
328            .unwrap();
329
330        assert_eq!(count, 0, "should skip when index already exists");
331
332        let _ = std::fs::remove_dir_all(&dir);
333    }
334
335    #[tokio::test]
336    async fn bootstrap_skips_missing_memory() {
337        let dir = temp_dir("missing_memory");
338        std::fs::create_dir_all(&dir).unwrap();
339
340        let memory_path = dir.join("MEMORY.md"); // does not exist
341        let index_path = dir.join("memory.rvf.json");
342
343        let embedder = ApiEmbedder::hash_only(64);
344        let count = bootstrap_memory_index(&memory_path, &index_path, &embedder)
345            .await
346            .unwrap();
347
348        assert_eq!(count, 0, "should skip when MEMORY.md is missing");
349
350        let _ = std::fs::remove_dir_all(&dir);
351    }
352
353    #[tokio::test]
354    async fn bootstrap_skips_empty_memory() {
355        let dir = temp_dir("empty_memory");
356        std::fs::create_dir_all(&dir).unwrap();
357
358        let memory_path = dir.join("MEMORY.md");
359        let index_path = dir.join("memory.rvf.json");
360
361        std::fs::write(&memory_path, "").unwrap();
362
363        let embedder = ApiEmbedder::hash_only(64);
364        let count = bootstrap_memory_index(&memory_path, &index_path, &embedder)
365            .await
366            .unwrap();
367
368        assert_eq!(count, 0, "should skip when MEMORY.md is empty");
369
370        let _ = std::fs::remove_dir_all(&dir);
371    }
372
373    #[tokio::test]
374    async fn bootstrap_paragraph_splitting() {
375        let dir = temp_dir("paragraphs");
376        std::fs::create_dir_all(&dir).unwrap();
377
378        let memory_path = dir.join("MEMORY.md");
379        let index_path = dir.join("memory.rvf.json");
380
381        std::fs::write(
382            &memory_path,
383            "First fact about something.\n\nSecond fact about another thing.\n\nThird fact.",
384        )
385        .unwrap();
386
387        let embedder = ApiEmbedder::hash_only(64);
388        let count = bootstrap_memory_index(&memory_path, &index_path, &embedder)
389            .await
390            .unwrap();
391
392        assert_eq!(count, 3);
393
394        let _ = std::fs::remove_dir_all(&dir);
395    }
396
397    #[tokio::test]
398    async fn bootstrap_index_is_searchable() {
399        let dir = temp_dir("searchable");
400        std::fs::create_dir_all(&dir).unwrap();
401
402        let memory_path = dir.join("MEMORY.md");
403        let index_path = dir.join("memory.rvf.json");
404
405        std::fs::write(
406            &memory_path,
407            "## Rust\n\nRust is a systems programming language.\n\n## Python\n\nPython is interpreted.",
408        )
409        .unwrap();
410
411        let embedder = ApiEmbedder::hash_only(64);
412        bootstrap_memory_index(&memory_path, &index_path, &embedder)
413            .await
414            .unwrap();
415
416        // Reopen and search
417        let store = RvfStore::open(&index_path).unwrap();
418        let query_emb = embedder.hash_fallback("rust programming");
419        let results = store.query(&query_emb, 2);
420        assert_eq!(results.len(), 2);
421
422        // Verify metadata contains text
423        let first = &results[0];
424        assert!(first.metadata.get("text").is_some());
425
426        let _ = std::fs::remove_dir_all(&dir);
427    }
428}