clawft_core/
memory_bootstrap.rs1use std::path::Path;
11
12use tracing::{debug, info, warn};
13
14use crate::embeddings::Embedder;
15use crate::embeddings::rvf_stub::{RvfError, RvfStore};
16
17#[non_exhaustive]
19#[derive(Debug)]
20pub enum BootstrapError {
21 Io(std::io::Error),
23 Embedding(crate::embeddings::EmbeddingError),
25 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
59pub async fn bootstrap_memory_index(
82 memory_path: &Path,
83 index_path: &Path,
84 embedder: &dyn Embedder,
85) -> Result<usize, BootstrapError> {
86 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 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 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 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 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
170fn split_into_sections(content: &str) -> Vec<String> {
178 let header_sections: Vec<String> = split_by_headers(content);
180 if header_sections.len() >= 2 {
181 return header_sections;
182 }
183
184 split_by_paragraphs(content)
186}
187
188fn 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
209fn 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 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(); 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"); 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 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 let first = &results[0];
424 assert!(first.metadata.get("text").is_some());
425
426 let _ = std::fs::remove_dir_all(&dir);
427 }
428}